fix(persistence): tighten LockfileRepository follow-ups from #1298 review#1299
fix(persistence): tighten LockfileRepository follow-ups from #1298 review#1299
Conversation
…view Three small fixes to LockfileRepository surfaced after the W2 prequel (swamp-club#233 / #1298) merged: 1. **Lock-contention now throws UserError instead of plain Error.** The pre-W2-prequel `acquireLock` in pull.ts threw UserError, which the top-level error handler at error_output.ts:64-68 renders as a clean "Error: <message>" line. The consolidated helper threw plain Error, so a real lock-exhaustion failure during `swamp extension pull` produced a stack trace instead of the clean message. (rm.ts also threw plain Error pre-prequel — this consolidates on the better UX, not the worse one.) New test pins the `instanceof UserError` contract. 2. **removeEntry now mkdir's parent dir symmetric with writeEntry.** Currently unreachable in practice (rm.ts confirms the entry exists first, which implies the lockfile was readable) but the asymmetry would surface as an unhelpful NotFound from acquireLock if a future caller path ever hit it. Test constructs against a non-existent parent dir and verifies removeEntry creates it. 3. **getAllEntries returns a deep copy via structuredClone.** The previous shallow copy `{ ...this.cache }` shared inner UpstreamExtensionEntry references — a caller doing `entries["@x/y"].files!.push("injected")` could corrupt the internal cache. All current callers are read-only so this was theoretical, but the original test only asserted top-level key deletion. Test now also verifies nested array mutation cannot affect the cache. Test plan: - [x] deno check, lint, fmt clean - [x] deno run test — 5399 passed (3 new, 0 failed) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Clean, well-scoped tightening pass. All three fixes are correct, well-tested, and the DDD layering is sound (infrastructure → domain import for UserError is the right direction).
Blocking Issues
None.
Suggestions
-
Inline comments reference callers and task history that will rot — The
removeEntrymkdir comment (lines 167–172) namesrm.tsas a caller and theUserErrorcomment (lines 203–206) references "pre-W2-prequel behavior in pull.ts." Per CLAUDE.md, caller references and task-specific history belong in the PR description, not inline. A one-liner like// Defensive: ensure parent dir exists before lock acquisitionwould preserve the WHY without the rot risk. -
getEntryreturns a direct cache reference — The PR hardensgetAllEntrieswithstructuredClonebutgetEntrystill returnsthis.cache[name]directly. All current callers are read-only (confirmed by grep), so this isn't a bug today, but the asymmetry means a future caller doingrepo.getEntry("@x/y")!.files!.push(...)would corrupt the cache. Worth considering for consistency — or at minimum a JSDoc note ongetEntrythat the returned object is shared. -
Redundant
instanceofassertion in lock-exhaustion test — Line 274 (assertEquals(error instanceof UserError, true)) is already guaranteed by theassertRejects(..., UserError, ...)call on line 266–270 which both checks the type and returns the typed error. The extra assertion doesn't add coverage. -
getAllEntriesJSDoc mentions implementation details — The note aboutstructuredClonebeing "in scope on Deno via the global" is an implementation detail that doesn't help callers. The contract ("returns a deep copy; safe to mutate without affecting the cache") is the valuable part.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None.
Medium
-
getEntryreturns a direct mutable reference to the cache (lockfile_repository.ts:98-99).The PR correctly upgrades
getAllEntriesfrom a shallow copy tostructuredClone, preventing callers from corrupting the cache via nested array mutation. However,getEntrystill returns the raw cached object:getEntry(name: string): UpstreamExtensionEntry | null { return this.cache[name] ?? null; }
A caller doing
repo.getEntry("@scope/foo")!.files!.push("injected")would mutate the internal cache through the shared reference — the exact class of buggetAllEntrieswas just hardened against. All current callers are read-only so this is not exploitable today, but the asymmetry is worth noting: the PR closes one door and leaves the adjacent one open.Not a regression — this was pre-existing. But if the goal is defensive consistency,
getEntryshould also return a clone (or the JSDoc should document that it returns a live reference).
Low
- Dead code after the for loop in
acquireLock(lockfile_repository.ts:214-216). Thethrow new UserError(...)after the for loop is unreachable: every iteration either returns (success), throwsUserError(last-retryAlreadyExists), or re-throws the original error (non-AlreadyExists). The loop can never exit normally. Pre-existing, not introduced by this PR — just changed fromErrortoUserErrorfor consistency. Harmless but worth a future cleanup.
Verdict
PASS. Three well-scoped, well-tested defensive fixes. The structuredClone upgrade, UserError consolidation, and removeEntry mkdir symmetry are all correct. The getEntry gap is pre-existing and theoretical. No blocking issues.
Summary
Three follow-up fixes to
LockfileRepositoryraised in review of #1298 (the W2 prequel). All small, all tested.Changes
1. Lock-contention throws
UserError(clean CLI message), not plainErrorBefore #1298,
acquireLockinpull.tsthrewUserError. The top-level error handler aterror_output.ts:64-68keys offinstanceof UserErrorto emit a cleanError: <message>line; plainErrorfalls through to a stack trace.When #1298 consolidated the duplicate
acquireLockhelpers frompull.tsandrm.tsinto the new repository, it picked uprm.ts's plain-Errorshape. Result: a real lock-exhaustion failure duringswamp extension pull(two concurrent users, the loser retries 10× and gives up) now shows a stack trace instead of the clean message it used to.This restores the
pull.tsUX (the better of the two pre-prequel implementations). New test pins theinstanceof UserErrorcontract so a future refactor can't quietly break it again.2.
removeEntrynow mkdir's parent dir, symmetric withwriteEntryCurrently unreachable in practice —
rm.tsonly callsremoveEntryafterextensionRmPreviewconfirmed the entry exists, which implies the lockfile was readable. But the asymmetry would surface as an unhelpfulNotFoundfromDeno.openif a future caller path ever hit a missing-parent-dir state. Defensive consistency fix; test constructs against a non-existent parent dir and verifiesremoveEntrycreates it.3.
getAllEntriesreturns a deep copy viastructuredCloneThe previous shallow copy
{ ...this.cache }only protected against top-level key deletion. A caller doingentries[\"@x/y\"].files!.push(\"injected\")could mutate the internal cache through the sharedUpstreamExtensionEntryreference.All current callers in this repo are read-only so this was theoretical, but the original regression test only verified top-level deletion — nested array mutation would have slipped through. Test now also verifies
files[]andinclude[]array mutation cannot reach the cache.Test plan
deno checkcleandeno lintcleandeno fmtcleandeno run test— 5399 passed, 0 failed (3 new tests: UserError contract, removeEntry mkdir symmetry, deep-copy nested mutation)Out of scope
This is a tightening pass, not a behavioral change. Lockfile JSON format unchanged; no migration; trivial revert.
🤖 Generated with Claude Code