Fix project rename leaving documentation unreachable (fixes #129) #130

Merged
qwc merged 2 commits from fix/rename-storage-divorce into main 2026-08-28 15:35:45 +02:00
Owner

Fixes #129.

The bug

MoveProject treated a missing source directory as success:

oldPath := s.ProjectPath(oldSlug)
if _, err := os.Stat(oldPath); os.IsNotExist(err) {
    return nil          // <- silent success
}

Service.Update then committed the database rename believing the files had moved. The slug and the on-disk directory were now permanently divorced, and because serving recomputes VersionPath(slug, tag) from the slug (internal/handler/version.go:52), every document 404'd.

Reproduced at the handler level, matching the issue report line for line:

rename status with missing source dir = 303      <- looks successful to the user
after rename: new slug err=<nil> ; old slug err=sql: no rows in result set
docs at new slug = 404

Renaming back appears to fix it only because the files never moved.

Why it survived the #122 fix

PR #123 corrected the forward path but never repaired the projects #122 had already broken. Before v0.8.2 a rename moved no files at all, so any project renamed in that era has DB slug != on-disk directory name. Renaming such a project now finds nothing at ProjectPath(oldSlug), hits the silent no-op, and re-breaks it. Triage of the original #122 — renaming back and forth — is exactly what leaves projects in that state.

Ruled out along the way: storage is a single shared instance (no root mismatch), and v0.8.2 genuinely contains the #123 fix (git merge-base confirms). Six realistic variants all pass unmodified — custom visibility, multi-version, double rename, the /latest/ permalink, service-created projects, and an editor renaming their own project (PR #118's CanManage path).

The fix

Commit 1 — stop the silent breakage. docs.ErrNoSourceDir makes a missing source directory reportable instead of reporting success. Service.Update keeps the no-op only when the project has no versions (then there really is nothing to move); otherwise it rolls the rename back and returns ErrStorageMissing, which the admin handler surfaces as a 409 explaining the rename was not applied. A store failure while counting versions is treated as "has versions", so an unreadable database refuses rather than commits.

Commit 2 — repair the already-divorced projects. Version rows still carry the StoragePath recorded at upload time, and the pre-#122 code never rewrote it, so for a divorced project it points at where the files actually are. ReconcileStorage follows that breadcrumb at startup and logs every repair.

It only touches a project when all of these hold:

  • it has deployed versions,
  • nothing exists at its expected path,
  • its versions agree on a single existing source directory that is a direct child of the storage root.

A breadcrumb pointing outside the storage root is ignored, so a corrupt StoragePath cannot relocate anything from elsewhere on disk. The move reuses MoveProject, so the destination-exists guard applies here too. On a healthy install it is a no-op.

Testing

  • Rename refusal, DB rollback, and preservation of the recovery breadcrumb.
  • The still-valid never-deployed no-op (renaming an undeployed project still works).
  • Handler-level 409, asserting the project stays at its original slug.
  • Reconciliation repair, healthy install untouched, and refusal to follow a path outside the storage root.
  • The refusal test was verified to fail against the old behavior, not pass vacuously.
  • Full Go suite, npm test, and the release build all pass.

Built-in docs updated: a "Slugs and storage paths" section in explanation/architecture.md covering the refusal and the startup repair.

Note for review

This fixes the failure mechanism and repairs the resulting state. What I could not verify from here is which specific prod projects are divorced — the startup log line (repaired project storage directories) will report that on first boot after deploy. Worth watching the logs on the first restart.

AI-assisted.

Fixes #129. ## The bug `MoveProject` treated a missing source directory as success: ```go oldPath := s.ProjectPath(oldSlug) if _, err := os.Stat(oldPath); os.IsNotExist(err) { return nil // <- silent success } ``` `Service.Update` then committed the database rename believing the files had moved. The slug and the on-disk directory were now permanently divorced, and because serving recomputes `VersionPath(slug, tag)` from the slug (`internal/handler/version.go:52`), every document 404'd. Reproduced at the handler level, matching the issue report line for line: ``` rename status with missing source dir = 303 <- looks successful to the user after rename: new slug err=<nil> ; old slug err=sql: no rows in result set docs at new slug = 404 ``` Renaming back appears to fix it only because the files never moved. ## Why it survived the #122 fix PR #123 corrected the forward path but never repaired the projects #122 had already broken. Before v0.8.2 a rename moved **no files at all**, so any project renamed in that era has `DB slug != on-disk directory name`. Renaming such a project now finds nothing at `ProjectPath(oldSlug)`, hits the silent no-op, and re-breaks it. Triage of the original #122 — renaming back and forth — is exactly what leaves projects in that state. Ruled out along the way: storage is a single shared instance (no root mismatch), and v0.8.2 genuinely contains the #123 fix (`git merge-base` confirms). Six realistic variants all pass unmodified — custom visibility, multi-version, double rename, the `/latest/` permalink, service-created projects, and an editor renaming their own project (PR #118's `CanManage` path). ## The fix **Commit 1 — stop the silent breakage.** `docs.ErrNoSourceDir` makes a missing source directory reportable instead of reporting success. `Service.Update` keeps the no-op only when the project has no versions (then there really is nothing to move); otherwise it rolls the rename back and returns `ErrStorageMissing`, which the admin handler surfaces as a 409 explaining the rename was not applied. A store failure while counting versions is treated as "has versions", so an unreadable database refuses rather than commits. **Commit 2 — repair the already-divorced projects.** Version rows still carry the `StoragePath` recorded at upload time, and the pre-#122 code never rewrote it, so for a divorced project it points at where the files actually are. `ReconcileStorage` follows that breadcrumb at startup and logs every repair. It only touches a project when **all** of these hold: - it has deployed versions, - nothing exists at its expected path, - its versions agree on a single existing source directory that is a direct child of the storage root. A breadcrumb pointing outside the storage root is ignored, so a corrupt `StoragePath` cannot relocate anything from elsewhere on disk. The move reuses `MoveProject`, so the destination-exists guard applies here too. **On a healthy install it is a no-op.** ## Testing - Rename refusal, DB rollback, and preservation of the recovery breadcrumb. - The still-valid never-deployed no-op (renaming an undeployed project still works). - Handler-level 409, asserting the project stays at its original slug. - Reconciliation repair, healthy install untouched, and refusal to follow a path outside the storage root. - The refusal test was verified to **fail** against the old behavior, not pass vacuously. - Full Go suite, `npm test`, and the release build all pass. Built-in docs updated: a "Slugs and storage paths" section in `explanation/architecture.md` covering the refusal and the startup repair. ## Note for review This fixes the failure *mechanism* and repairs the resulting state. What I could not verify from here is which specific prod projects are divorced — the startup log line (`repaired project storage directories`) will report that on first boot after deploy. Worth watching the logs on the first restart. AI-assisted.
MoveProject treated a missing source directory as success, so Service.Update
committed the database rename believing the files had moved. The slug and the
on-disk directory were then permanently divorced, and because serving
recomputes the path from the slug every document 404'd -- while the old slug
reported "project not found" and renaming back appeared to fix it. That is
exactly the shape reported in #129.

The fix for #122 corrected the forward path but never repaired projects that
#122 had already broken: renames from before that fix moved no files at all,
so those projects have no directory where their slug says they should. Renaming
one now hits the silent no-op and re-breaks it.

- docs.ErrNoSourceDir: MoveProject reports a missing source instead of
  reporting success, leaving the decision to the caller.
- Service.Update keeps the no-op only when the project has no versions (then
  there really is nothing to move) and otherwise rolls the rename back and
  returns ErrStorageMissing. A store failure while counting versions is
  treated as "has versions" so an unreadable database refuses rather than
  commits.
- The admin handler maps that to a 409 explaining the rename was not applied.

Regression tests cover the refusal, the rollback, the still-valid
never-deployed no-op, and the user-visible 409 at the handler level.

Storage recovery for already-divorced projects follows in the next commit.

AI-assisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>
Repair projects whose storage directory no longer matches their slug (#129)
All checks were successful
CI / test (pull_request) Successful in 1m17s
CI / build (pull_request) Successful in 48s
CI / docker (pull_request) Has been skipped
4d73a10d00
Renames performed before the fix for #122 updated the database but never moved
the files, so those projects have their documentation sitting under a stale
directory name. Serving recomputes the path from the slug, so the files are
intact but unreachable, and the previous commit now refuses to rename them
rather than compounding the damage. Nothing yet puts them back.

Version rows still carry the StoragePath recorded at upload time, and the
pre-#122 code never rewrote it, so for a divorced project it points at where
the files actually are. ReconcileStorage uses that as the breadcrumb, runs at
startup, and logs every repair.

It only touches a project when all of these hold: it has deployed versions,
nothing exists at its expected path, and its versions agree on a single
existing source directory that is a direct child of the storage root. A
breadcrumb pointing outside the root is ignored, so a corrupt StoragePath
cannot relocate anything from elsewhere on disk. The move reuses MoveProject
so the destination-exists guard applies here too. On a healthy install it is
a no-op.

Tests cover the repair, the untouched healthy install, and the refusal to
follow a path outside the storage root.

AI-assisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>
qwc merged commit 7cf4ab2f8d into main 2026-08-28 15:35:45 +02:00
qwc deleted branch fix/rename-storage-divorce 2026-08-28 15:35:46 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
qwc-open/asiakirjat!130
No description provided.