Skip to content

fix: honor repository guard return values in deleteLink and disableLink#11

Merged
DennisAlund merged 4 commits into
mainfrom
claude/adoring-dirac-Bn4Fq
May 30, 2026
Merged

fix: honor repository guard return values in deleteLink and disableLink#11
DennisAlund merged 4 commits into
mainfrom
claude/adoring-dirac-Bn4Fq

Conversation

@DennisAlund
Copy link
Copy Markdown
Member

Weekly defect review — 2026-05-29

Two bugs found and fixed. All 892 tests green.


Fix 1: deleteLink ignores return value of LinkRepository.delete()

What was wrong: deleteLink in src/services/link-management.ts checked link.total_clicks > 0 before calling LinkRepository.delete(), but ignored the boolean that delete() returns. LinkRepository.delete() performs its own click-count guard internally (a second getById + check). In the window between the service-level check and the repository DELETE, a concurrent click could be recorded. In that case the repository returned false (deletion blocked), but the service discarded the return value and responded { deleted: true } to the caller. The KV cache entries for the link's slugs were also incorrectly evicted.

Impact: API response lied — callers received 200 { deleted: true } for a link that still existed in the database. The KV eviction meant the next redirect fell through to D1 and re-populated KV, so redirect behaviour recovered, but the stated contract was broken.

Fix: Check the return value; propagate fail(400, ...) if the repository guard fired.

Regression test: deleteLink: DB-level guard return value is honored — uses vi.spyOn(LinkRepository, 'delete').mockResolvedValueOnce(false) to simulate the race. Failed before the fix (result.ok was true); passes after.


Fix 2: disableLink unsafe non-null assertion on LinkRepository.disable() result

What was wrong: disableLink called LinkRepository.disable() then immediately used disabled!.slugs with a non-null assertion, without checking for null. LinkRepository.disable() returns null when the link row does not exist at UPDATE time (the link could be deleted in the window between the service's preceding getById and the repository's UPDATE). The function would throw a TypeError: Cannot read properties of null (reading 'slugs') instead of returning a structured error.

enableLink (the counterpart) already had the correct null-guard (if (!enabled) return fail(404, ...)). disableLink was missing the same protection.

Impact: Concurrent delete during a disable produced an unhandled 500 error instead of a clean 404.

Fix: Added if (!disabled) return fail(404, "Link not found") immediately after the disable() call, matching the pattern in enableLink. Removed the ! assertions from subsequent uses of disabled.

Regression test: disableLink: null return from repository does not throw — uses vi.spyOn(LinkRepository, 'disable').mockResolvedValueOnce(null). Before the fix the function threw TypeError; after the fix it returns { ok: false, status: 404 }.

https://claude.ai/code/session_01PNGGm7htQKRuHz1nAy9Qib


Generated by Claude Code

…eLink

deleteLink silently returned { deleted: true } even when
LinkRepository.delete() returned false (the DB-level click-count
guard can fire in the window between the service-level check and the
repository delete, e.g. a concurrent click).  Added a check on the
boolean return so callers get a 400 instead.

disableLink used a non-null assertion (disabled!) on the result of
LinkRepository.disable(), which returns null when the link is deleted
between the preceding getById and the UPDATE inside disable().
That made the function throw a TypeError instead of returning a
structured error.  Added the same null-guard that enableLink already
had, returning 404 on null.

Regression tests added for both cases using vi.spyOn to simulate the
narrow race window.

https://claude.ai/code/session_01PNGGm7htQKRuHz1nAy9Qib
Copilot AI review requested due to automatic review settings May 29, 2026 19:20
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

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 58445e2 May 30 2026, 09:03 AM

Copy link
Copy Markdown

Copilot AI left a comment

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 fixes race-condition edge cases in link disable/delete service methods by honoring repository guard return values and ensuring null repository results are handled safely, with accompanying regression tests.

Changes:

  • disableLink: handle null return from LinkRepository.disable() and avoid unsafe non-null assertions.
  • deleteLink: honor boolean return from LinkRepository.delete() and fail the request when the repository-level guard blocks deletion.
  • Add Vitest regression tests covering both race scenarios.

Reviewed changes

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

File Description
src/services/link-management.ts Adds repository-return handling for disableLink and deleteLink to prevent incorrect success responses / runtime throws under races.
src/__tests__/service/ownership.test.ts Adds regression tests validating correct service behavior when repository guards return false/null.

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

Comment thread src/services/link-management.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts
Comment thread src/__tests__/service/ownership.test.ts
…delete

Copilot review on PR #11 flagged that deleteLink mapped every false from
LinkRepository.delete() to a 400 'Cannot delete a link with clicks'. The
repository returns false for two cases: a concurrent request removed the link,
or a concurrent click pushed total_clicks past the lifetime guard. Re-check
existence after a failed delete and return 404 for a vanished link, 400
otherwise.

Add a regression test covering the concurrent-delete path, and wrap the spy
usages in the deleteLink and disableLink tests in try/finally so a thrown call
cannot leak the mock into later tests.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
Comment thread src/__tests__/service/ownership.test.ts Outdated
…nitialized let

Copilot review flagged the uninitialized 'let result;' in the try/finally spy
blocks as an implicit any. tsc --noEmit passes regardless (evolving-let control
flow infers the type), but dropping the bare let removes the noise. Restore the
spy with a .finally() on the awaited call: restoration still runs on both
resolve and reject, and result is a typed const.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/services/link-management.ts Outdated
Comment thread src/services/link-management.ts Outdated
…eleteLink

Copilot review raised two issues on deleteLink:

- KV leak under a concurrent custom-slug add. The service evicted the slug
  list captured by its own initial getById, but LinkRepository.delete cascades
  every slug row at delete time. A slug added in the window was removed from D1
  yet left in KV (no TTL), so redirects kept resolving for a deleted link.
  delete() now returns the slug set it removed (string[] on success, false when
  blocked or gone, preserving the existing guard contract), and the service
  evicts exactly those.

- The failed-delete disambiguation called getById, loading the full link plus
  slugs and click aggregation only to test existence. Added LinkRepository.exists
  (SELECT 1) and use it instead.

Tests: repository returns-removed-slugs and exists cases; a service regression
proving a slug added between the read and the delete is still evicted from KV.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@DennisAlund DennisAlund merged commit f9fe9a7 into main May 30, 2026
10 checks passed
@DennisAlund DennisAlund deleted the claude/adoring-dirac-Bn4Fq branch May 30, 2026 09:07
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