refactor: the structural dedup deferred from #12 #24
Loading…
Reference in a new issue
No description provided.
Delete branch "refactor/dedup-shared-code"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The four duplication items from #12 that #19 deliberately left alone. Targets
fix/issue-12-followupsso the diff is only the refactor — retarget tomainonce #19 merges.No behaviour change is intended anywhere except the one called out below.
deno task cigreen: fmt, lint, typecheck, 343 tests (up from 328; every new one covers code that had no direct test before).1. One compose-progress shape and one carry-forward rule
ComposeProgress,ComposeState,PullPhaseandPullSummarywere declared twice, and the fold that carries totals/current/fraction/phase across a terminal event existed asmerge()inbackend/jobs.tsand again inside the frontend reducer'spush. Both now inpackages/modules/docker/shared/progress.ts, which imports nothing — the frontend reaches it by relative path, and the built bundle inlines it (checked, not assumed).Not cosmetic:
sync()re-hydrates from the server every 15 s while a job runs, so live rows and the re-attach snapshot are shown alternately in the same table. When the two folds disagree, a completed layer visibly flips between readings. They had already drifted once.2. One registry v2 client
The docker module and
tools/dep-checkhad each grown their own reference parsing, bearer handshake and Link pagination.packages/registryowns what the protocol decides; each caller keeps what genuinely differs (timeouts, page caps, caching, result shapes).Falls out of having one copy: the origin guard now covers dep-check's forge pagination too, not just the registry path.
Verified past the unit tests by running the tool for real —
dep-check --dry-runreaches Docker Hub and two forges through the shared client, paginates, and reports the same four available updates as before.3. One version-tag parser — but two orderings, on purpose
The parse and the prerelease word list are now shared. The ordering is not, and that is the interesting part:
compare()pads a missing component with0in dep-check and-1in the module. dep-check treatsv6andv6.0.0as one release, because a project shippingv6thenv6.0.1has published something the reader wants; the module sorts1.2below1.2.0, because a pin's granularity is a choice the operator made. Merging them would have quietly broken one. Both files already carried comments warning whoever tried — so the shared module carries it too.The one intended behaviour change: the module's parser accepted only
vas a prefix, sobin-2.5.6did not parse and a service pinned to that line could never be offered an upgrade. The shared rule accepts any prefix ending in-or_, as dep-check's did. The guard that matters is unchanged — an arbitrary alphabetic prefix is still refused, which is what keepssha256-9f3a…from being read as version 256 of "sha". Both halves are tested.4. One pin pipeline — and a test for it at last
The two pin routes each walked a stack's compose files applying one service's new image, and the copies had diverged in ways that cost a review pass each: the ALREADY branch, the no-break-on-first-hit rule, which text a hunk is measured against.
mod.tsloses 147 lines and gains 50.applyPinis the stack route's version, which is the general one — a file may be visited more than once across services, so a hunk is measured against the text as it was before any of this request's edits. With a single service that reduces exactly to what the container route did. Two behaviours the container route inherits are strictly safer and unreachable in its single-service case: a file we already wrote that then fails to re-read throws rather than being skipped, and hunks come from the restore ledger's original.The point of this one is the test file. This logic sat behind an admin guard, a job slot and a compose validation, so none of it was ever exercised directly — which is exactly how two copies drifted unnoticed for as long as they did. It now takes its filesystem and restore ledger as arguments and runs against a real directory: nine cases, one per rule that had to be learned the hard way.
What I'd look at hardest
Item 4 touches the code that writes operator compose files, and a pure refactor has no failing-test-before/after to prove it. The nine new cases are the best evidence I can offer; the routes themselves still have no test above
applyPin. Worth a run throughdocker/dev-rigbefore this merges.ComposeProgress, ComposeState, PullPhase and PullSummary were declared twice — once in backend/compose_progress.ts, once in frontend/update_feed.svelte.ts — and the fold that carries totals, current, fraction and phase across a terminal event existed as merge() in backend/jobs.ts and again inside the frontend reducer's push. Both now live in packages/modules/docker/shared/progress.ts, which imports nothing: the module contract forbids bare imports in the frontend build, and the frontend reaches this by relative path. Verified in the built bundle — the code is inlined, not left as a dangling import. This pairing is not cosmetic. sync() re-hydrates the feed from the server every 15 seconds while a job runs, so the live rows and the re-attach snapshot are displayed alternately in the same table; when the two folds disagree, a completed layer visibly flips between readings. They had already drifted once, which is how the phase carry-forward came to be patched in both places in lockstep. One behaviour difference, deliberately taken: the frontend used to write `{ ...p, phase, total, current, fraction }`, setting those keys even when undefined, where the shared fold omits them. Every read site tests `!== undefined`, so nothing changes — but the objects now match the wire shape the server actually sends, which is what hydrate() puts in the same map.Self-review of the four refactor commits before asking anyone else to read them. One real defect, one documentation gap, both fixed in
17d7a6b. 344 tests.The defect: a false alarm on the last page of every paginated repository
listTagswarned "tag pagination left the registry's origin; stopping" whenever aLinkheader produced no next page. But a last page legitimately carriesrel="prev"orrel="last"and no next — so the most ordinary event in pagination logged a message about a credential boundary being crossed.Mine, introduced by the dedup: collapsing the old explicit
next.startsWith("/")branch into one call lost the distinction between "no next was offered" and "a next was offered and refused". The warning belongs only to the second, whichisNextRelnow decides. Test covers all three shapes — quiet last page, no header, genuinely off-origin — and reverting the condition makes it report the false alarm.The gap: two new conventions, neither documented
packages/registryin CLAUDE.md, including the ordering it deliberately does not own — that is the part a future reader is most likely to "tidy up".shared/directory indocs/modules.md, since a module author reaching for it needs to know the file must import nothing, or the frontend build rejects it.Checked and clean
deno install --frozenagainst the new workspace member — CI would have failed on a stale lockfile, and does not.total/current/fraction/phaseuses!== undefined,?? 0or truthiness, somergeProgressomitting undefined keys where the old inline fold set them is genuinely inert. Noin/hasOwnProperty/Object.keysanywhere near a progress object.already/donecollapse, thechangespush condition, the 404-vs-alreadyWritten split and the hunk base all behave as before. The container route's re-derivation of the hunk againstrestore.originalis a no-op there, since it equals the text just read.deno task buildend to end; the registry and shared tests are picked up by the root task rather than only when named.Two API message changes, deliberate and worth a second opinion
The container route's 404 went from "no compose file here declares an image for that service" to "no compose file declares an image for
<service>", and its interpolation error now names the service too. Both come from using the stack route's wording for both callers. Naming the service is better, but they are response strings — say if you would rather they stayed byte-identical.Still true from the PR body: the routes above
applyPinhave no test, anddocker/dev-rigis the thing that would exercise them.Folded into #19 and closing unmerged — nothing is lost.
fix/issue-12-followupsfast-forwarded onto this branch, so all five commits (bbbdd67,1db3355,1943d11,1cb1f11,17d7a6b) are now in #19 at17d7a6b, byte-identical.Splitting it out was my call and the reasoning still holds — a behaviour-preserving refactor and a set of behaviour-changing fixes touching the same three files are easier to read apart than together. But it left a PR stacked on an unmerged branch needing a retarget later, and that shape has already cost this stack one bad merge. One PR against
mainis the safer trade.Review #19 instead; it carries the whole thing.
thisilike referenced this pull request2026-08-11 08:38:05 +02:00
Pull request closed