Skip to content

fix: atomic NOT EXISTS click guard in SlugRepository.remove#14

Open
DennisAlund wants to merge 1 commit into
mainfrom
claude/adoring-dirac-uaz8f2
Open

fix: atomic NOT EXISTS click guard in SlugRepository.remove#14
DennisAlund wants to merge 1 commit into
mainfrom
claude/adoring-dirac-uaz8f2

Conversation

@DennisAlund

Copy link
Copy Markdown
Member

Summary

Weekly defect review. One confirmed new bug found; all other candidates were refuted or pre-existing.

What was wrong

SlugRepository.remove checked click_count > 0 via a pre-read SELECT, then ran the batch DELETE unconditionally. A click arriving between the pre-read and the DELETE would produce an orphaned clicks row: PRAGMA foreign_keys is not set anywhere in the codebase (confirmed by search), so D1's FK cascade is inactive and clicks.slug ON DELETE CASCADE never fires.

LinkRepository.delete was hardened against the same race in PR #13 by embedding AND NOT EXISTS (SELECT 1 FROM clicks ...) inside the transactional batch. SlugRepository.remove was left unguarded by that pass.

The method's own comment states: "never drop a slug that has recorded any click, so analytics rows are not orphaned" — the pre-read alone does not enforce this under concurrent load.

The fix

  • Added AND NOT EXISTS (SELECT 1 FROM clicks WHERE slug = ?) to the DELETE inside the batch, matching the pattern used in LinkRepository.delete.
  • Checked deleteResult.meta.changes and return false when the guard blocks the delete (the service layer already maps this to 409 "Slug has click history").
  • Refactored the pre-read to call SlugRepository.findByValue (an existing public static method) instead of an inline db.prepare, making it spyable for the race-condition test.

The regression test

src/__tests__/repository/slug-repository.test.ts — "guard holds when a click lands between the pre-read and the batch delete":

  • Inserts a click record for the slug.
  • Spies on findByValue to return click_count: 0 (simulating the race window where the pre-read predates the click).
  • Calls remove and asserts it returns false.
  • Asserts both the slug row and the click row are still present.

On the old code (no NOT EXISTS), the DELETE would fire and remove would return true, failing the assertion. On the new code the guard blocks the delete.

Test suite: 933 tests, all pass.

https://claude.ai/code/session_01RCqBLUKrf78eW9EdrqD9Mi


Generated by Claude Code

The previous code checked click_count > 0 via a pre-read SELECT and then
ran the batch DELETE unconditionally. A click arriving between the pre-read
and the DELETE would be orphaned: FK cascade is not active (no
PRAGMA foreign_keys in D1), so the clicks row would reference a deleted
slug with no cascade cleanup.

LinkRepository.delete already guards against this race by embedding
AND NOT EXISTS (SELECT 1 FROM clicks ...) inside the transactional batch.
SlugRepository.remove was left unguarded by the same 0.35.3 hardening pass.

Fix: add the same NOT EXISTS re-check to the DELETE statement and check
the result (meta.changes) to detect when the guard blocks the delete.
Use SlugRepository.findByValue for the pre-read so the method is spyable
in tests.

Regression test: mocks findByValue to return click_count=0 while an
actual click row exists in the DB; asserts remove() returns false and
both the slug and click records survive.

https://claude.ai/code/session_01RCqBLUKrf78eW9EdrqD9Mi
Copilot AI review requested due to automatic review settings June 12, 2026 19:18
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
shrtnr 63d2ab3 Jun 12 2026, 07:19 PM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens SlugRepository.remove against a race where a click can arrive between the pre-read and the transactional delete, preventing orphaned clicks rows when FK cascade is inactive in D1.

Changes:

  • Refactors the pre-read in SlugRepository.remove to call SlugRepository.findByValue (enabling spying in tests).
  • Adds an atomic NOT EXISTS (SELECT 1 FROM clicks ...) guard to the slug DELETE inside the transactional batch and returns false when the guard blocks deletion.
  • Adds a regression test that simulates the race window by spying on findByValue to report click_count: 0 while a click row exists at DELETE time.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/db/slug-repository.ts Adds an atomic NOT EXISTS guard to prevent deleting slugs that have click history under concurrent load.
src/__tests__/repository/slug-repository.test.ts Adds a regression test for the race window between the pre-read and transactional delete.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/db/slug-repository.ts
Comment on lines 130 to +142
const statements = [];
if (row.is_primary) {
statements.push(
db.prepare("UPDATE slugs SET is_primary = 1 WHERE link_id = ? AND is_custom = 0").bind(row.link_id),
);
}
statements.push(db.prepare("DELETE FROM slugs WHERE slug = ?").bind(slug));
await db.batch(statements);
return true;
statements.push(
db.prepare("DELETE FROM slugs WHERE slug = ? AND NOT EXISTS (SELECT 1 FROM clicks WHERE slug = ?)")
.bind(slug, slug),
);
const results = await db.batch(statements);
const deleteResult = results[results.length - 1];
return (deleteResult.meta.changes ?? 0) > 0;
Comment on lines +237 to +242
expect(removed).toBe(false);
const clickRow = await env.DB.prepare("SELECT 1 FROM clicks WHERE slug = 'rmrace-c'").first();
const slugRow = await env.DB.prepare("SELECT 1 FROM slugs WHERE slug = 'rmrace-c'").first();
expect(clickRow).not.toBeNull();
expect(slugRow).not.toBeNull();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants