From 14f1c97c0acbf12a8201ab909dc3d9fb07dda3a4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 04:31:13 +0200 Subject: [PATCH] fix(e12): a bare `up -d` picks the wrong compose file, and recreates the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round four, on round three's diff. 47 -> 34 -> 15 -> 7 raw findings. **The printed restart command could strand the restored data.** Round three replaced `start` with `up -d` because `start` ignores a newly pinned tag — but printed it bare. In the git-clone layout BOTH compose files exist, so the scripts' COMPOSE_FILE fallback never fires and a bare `docker compose up -d` reads `docker-compose.yml`: different volume names, locally built images, no IMAGE_TAG at all. `stop` and `exec` had been fine there (they resolve running containers by project and service), but `up -d` RECREATES — so the operator pastes the hint straight after a successful restore and gets `db` on a new empty volume and a first-run setup wizard, with their data in an orphaned volume. Verified: the two files resolve `siftlode_pgdata` vs `pgdata` and different images. The scripts no longer print a command that recreates from a file they did not resolve. They name the compose file when they resolved one, and otherwise say to start it the way the operator normally does — with the caveats on their own lines rather than in a parenthetical at the end of a very long one. **The reset flag is now decided where the live hash is already parsed.** Round three cleared it in the token effect, which only runs when a NEW verify token arrives — so the back button, or re-pasting an already-consumed verify link, still left the banner promising a recovery whose token was gone. Setting it from the hash on every fragment change is both smaller and closes those. Also: the doc said `start` "still carries the old tag" four lines after "pin the older IMAGE_TAG", where "old" then reads as the tag being rolled back TO — it now reuses the scripts' unambiguous wording; the Windows recipe removes a partially-copied dump before throwing, so a mid-stream `cp` failure cannot leave a truncated file under the final name; and `$WarningPreference` is pinned beside `$ErrorActionPreference` so an inherited SilentlyContinue cannot drop the half-restored warning, with the comment corrected to say what the warning stream actually is. Gate green: vitest 685, pytest 950. --- docs/self-hosting.md | 7 +++++-- frontend/src/lib/useVerifyEntry.ts | 14 +++++++------- scripts/restore.ps1 | 15 ++++++++++++--- scripts/restore.sh | 7 ++++++- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 38693ad4..b6265c73 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -196,7 +196,8 @@ docker compose -f docker-compose.selfhost.yml exec -T db \ # Start it again only once the restore reported no errors — and, if you are rolling a release back, # only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the # newer image back here would re-apply the migrations you just restored away. Use `up -d`, not -# `start`: `start` relaunches the container that already exists, which still carries the old tag. +# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and +# comes back on the image you are rolling back FROM. docker compose -f docker-compose.selfhost.yml up -d ``` @@ -224,7 +225,9 @@ copy would still run after a failed dump — and copy out the previous run's fil docker compose -f $compose cp db:/tmp/s.dump $out $code = $LASTEXITCODE docker compose -f $compose exec -T db rm -f /tmp/s.dump - if ($code -ne 0) { throw "BACKUP FAILED - could not copy the dump out (exit $code)" } + # Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump + # under the final name — the plausible-looking bad backup the .part dance above avoids. + if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" } if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" } "wrote $out" } diff --git a/frontend/src/lib/useVerifyEntry.ts b/frontend/src/lib/useVerifyEntry.ts index 17a00fed..87a04c2f 100644 --- a/frontend/src/lib/useVerifyEntry.ts +++ b/frontend/src/lib/useVerifyEntry.ts @@ -59,7 +59,13 @@ export function useVerifyEntry(): VerifyEntry { // Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a // same-document navigation the import-time snapshot never sees. The verify half got this // listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17). - if (hash.get("reset")) setResetPending(true); + // + // Set from the live hash rather than one-way, because the flag must not outlive the token it + // describes: navigating away from a `#reset=` (a pasted verify link, the back button) leaves + // the banner promising that signing out will resume a reset whose token is gone — and, being + // checked first, hiding whatever arrived in its place. `clearEntryUrl`'s own replaceState + // fires no hashchange, so this cannot undo the deliberate keep-the-reset-fragment behaviour. + setResetPending(!!hash.get("reset")); }; // replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot // feed itself. @@ -68,16 +74,10 @@ export function useVerifyEntry(): VerifyEntry { }, []); useEffect(() => { - // Read the fragment BEFORE clearing it: a verify link pasted over a standing reset notice - // replaces the whole fragment, so the reset token is already gone and the flag describing it - // must go too — otherwise the banner keeps promising a recovery that signing out can no longer - // complete, and (being checked first) hides the confirmation that just arrived. - const stillHasReset = new URLSearchParams(window.location.hash.replace(/^#/, "")).has("reset"); // Clear the address bar even with no token to consume, so a spent one leaves no trace on a // reload. An unspent `#reset=` is the exception and `clearEntryUrl` says why: nothing in a // signed-in tree reads it, so erasing it there is a dead end rather than a cleanup. clearEntryUrl(); - if (!stillHasReset) setResetPending(false); if (!token || consumed.current.has(token)) return; consumed.current.add(token); // A second link arriving over an unread first outcome must not leave the OLD banner standing. diff --git a/scripts/restore.ps1 b/scripts/restore.ps1 index cc60075f..b1ae4d2f 100644 --- a/scripts/restore.ps1 +++ b/scripts/restore.ps1 @@ -6,6 +6,7 @@ # values and refresh tokens will not be readable. See docs/self-hosting.md § Backups. param([Parameter(Mandatory = $true)][string]$DumpFile) $ErrorActionPreference = "Stop" +$WarningPreference = "Continue" # See backup.ps1: the dump travels with `docker compose cp`, never through the PowerShell pipeline, # which corrupts binary on 5.1 and cannot express `-Encoding Byte` at all on 7. @@ -54,10 +55,18 @@ Remove-RemoteDump $remote # reports every missing object it tried to drop), so a non-zero code is reported as "check this", # not swallowed and not dressed up as success. if ($restoreCode -ne 0) { - # Write-Warning, not Write-Output: this must ride the same stream as the exception below, or a - # `> restore.log` redirection files the explanation away and leaves only the raw error on screen. + # Write-Warning, not Write-Output: `>` redirects only the SUCCESS stream, so on stdout this + # explanation would be filed into the log while the bare exception stayed on screen. (The + # warning stream is its own stream, not the error stream — $WarningPreference is pinned at the + # top so an inherited "SilentlyContinue" cannot drop it.) Write-Warning "api and worker are left STOPPED: the database may be half-restored. Fix the dump and re-run before starting them." throw "pg_restore reported errors while restoring $DumpFile (exit $restoreCode) — read the output above before treating this instance as restored." } Write-Output "Restored $DumpFile" -Write-Output "api and worker are stopped. Start them with: docker compose up -d (rolling a release back? pin the older IMAGE_TAG in .env FIRST — 'docker compose start' would relaunch the CURRENT image and re-apply its migrations)" +$cf = if ($env:COMPOSE_FILE) { " -f $env:COMPOSE_FILE" } else { "" } +Write-Output "api and worker are stopped. Start them again the way you normally start Siftlode:" +Write-Output " docker compose$cf up -d" +Write-Output "Use the SAME compose file you started with — 'up -d' recreates from whatever file it reads," +Write-Output "and a different one points at different volumes. Rolling a release back? Pin the older" +Write-Output "IMAGE_TAG in .env FIRST: every start re-runs the migrations, and 'start' would relaunch the" +Write-Output "existing container on the image you are rolling back FROM." diff --git a/scripts/restore.sh b/scripts/restore.sh index 5d4611a5..4e89d9c0 100644 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -42,7 +42,12 @@ fi # not swallowed and not dressed up as success. if docker compose exec -T db pg_restore -U "$USER_NAME" -d "$DB_NAME" --clean --if-exists < "$FILE"; then echo "Restored $FILE" - echo "api and worker are stopped. Start them with: docker compose up -d (rolling a release back? pin the older IMAGE_TAG in .env FIRST — 'docker compose start' would relaunch the CURRENT image and re-apply its migrations)" + echo "api and worker are stopped. Start them again the way you normally start Siftlode:" + echo " docker compose ${COMPOSE_FILE:+-f $COMPOSE_FILE }up -d" + echo "Use the SAME compose file you started with — 'up -d' recreates from whatever file it reads," + echo "and a different one points at different volumes. Rolling a release back? Pin the older" + echo "IMAGE_TAG in .env FIRST: every start re-runs the migrations, and 'start' would relaunch the" + echo "existing container on the image you are rolling back FROM." else echo "pg_restore reported errors while restoring $FILE — read the output above before treating this instance as restored." >&2 echo "api and worker are left STOPPED: the database may be half-restored. Fix the dump and re-run before starting them." >&2