Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree, `Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing else, and corruption therefore surfaced as whatever rusqlite error the first unlucky query happened to produce — "database disk image is malformed" attached to a thumbnail refresh, elided into a 34px banner, over an empty grid saying "No images found · Check the library folder". Two messages that disagreed, and no way forward but deleting catalog.sqlite by hand. The property that makes the second offer real was already here and load- bearing: the catalog is an index, not a source of truth, rebuildable from sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL database. What was missing was the check, the type, and the conversation. Four pieces: **The type.** `CatalogError::Corrupt`, and — the part that makes it worth having — a hand-written `From<rusqlite::Error>` that classifies rather than wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they arise, so a background job that trips over the damage first reports the same thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY` deliberately do not: a dropped network mount is a different problem, and telling someone to rebuild their index would be a wrong answer delivered confidently. **The check.** `Catalog::open_verified`, `quick_check` before the open rather than after, because opening runs migrations and a damaged catalog with an intact header would otherwise have structure rewritten on top of structure that is already wrong. Bound to `open_verified` and not to `open`: the check reads every page, which is affordable once at startup where a user can answer a question, and not affordable on the dozens of opens a session's background tasks make. **The backup.** NFR-R2's second clause, taken between `configure` and `migrate` in `Catalog::open`. A migration is the one routine operation that rewrites table structure, so it is the likeliest way this file becomes unreadable, and it is the last moment the pre-migration state exists to be copied. Three generations, through SQLite's backup API after a TRUNCATE checkpoint — never `fs::copy`, which on a WAL database backs up a state older than the catalog and possibly torn. A failure to take the copy is logged, not raised: a full disk must not be what makes a library unopenable. **The conversation.** The first line of the dialogue is that the photographs and the edits are safe, before the diagnosis, because that is the question the user is actually asking. Then the two offers, which are *not* interchangeable and are not presented as if they were: a restore keeps collections, and a rebuild cannot, because a manual collection is a set of images assembled by hand and nothing in the filesystem records it (docs/catalog.md §8.1). The labels say so, and the rebuild does not take the affirmative styling while a restore is on the table. One thing that is a fix rather than a feature: `show_catalog_now` now gates the scan. `Catalog::open` succeeds on a file whose header survived, so the scan that used to start immediately afterwards would write folder ETags and image rows into damaged pages in the seconds while the user was still reading the question — turning a file that had a backup into one where the backup is the only copy left. Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is easy to leave out and fatal to leave out: a journal belonging to the old file, sitting beside the new one under the same name, is replayed into it on the next open. That is not a restore, it is a fresh corruption with the evidence gone. Tested by corrupting a fixture catalog — 500 images and a collection, then every page past the second overwritten — and driving both branches. The restore is asserted on the collection, because a collection is precisely what distinguishes the two paths; the rebuild on the damaged file being kept and the next open producing an empty catalog at the current schema. Plus the `SQLITE_NOTADB` presentation, a damaged backup being refused rather than installed, and a v1 catalog whose pre-migration backup comes back reading v1 rather than v11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { GestureRow } from "gestures.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
import { RecoveryPrompt } from "recovery.slint";
|
||||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
@@ -365,6 +366,21 @@ export component AppWindow inherits Window {
|
||||
callback offline-prompt-release();
|
||||
callback offline-prompt-dismiss();
|
||||
|
||||
// The question a damaged catalog asks. Same shape as the prompt above and
|
||||
// for the same reason: an empty title is what closes it, and every word in
|
||||
// it is composed in Rust, which is the only side that knows what SQLite
|
||||
// said and which backups exist.
|
||||
in property <string> recovery-title: "";
|
||||
in property <string> recovery-detail: "";
|
||||
in property <string> recovery-diagnosis: "";
|
||||
in property <string> recovery-restore-label: "";
|
||||
in property <bool> recovery-can-restore: false;
|
||||
in property <string> recovery-rebuild-label: "";
|
||||
in property <bool> recovery-busy: false;
|
||||
callback recovery-restore();
|
||||
callback recovery-rebuild();
|
||||
callback recovery-dismiss();
|
||||
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
@@ -1145,6 +1161,14 @@ in property <bool> panel-visible: true;
|
||||
// would leave the library from behind an open question — the
|
||||
// view changing underneath a modal, which reads as the app
|
||||
// having lost its place.
|
||||
//
|
||||
// The recovery question is asked first because it is drawn
|
||||
// over everything, the offline prompt included: Back must
|
||||
// reach the thing the user can actually see.
|
||||
if (root.recovery-title != "") {
|
||||
root.recovery-dismiss();
|
||||
return accept;
|
||||
}
|
||||
if (root.offline-prompt-title != "") {
|
||||
root.offline-prompt-dismiss();
|
||||
return accept;
|
||||
@@ -2664,5 +2688,24 @@ in property <bool> panel-visible: true;
|
||||
release() => { root.offline-prompt-release(); }
|
||||
dismiss() => { root.offline-prompt-dismiss(); }
|
||||
}
|
||||
|
||||
// Last, and therefore over everything including the settings page and
|
||||
// the offline prompt. Not a preference about layering: this is asked
|
||||
// before the grid exists, and nothing else in the window is about a
|
||||
// library that can be read.
|
||||
RecoveryPrompt {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
title: root.recovery-title;
|
||||
detail: root.recovery-detail;
|
||||
diagnosis: root.recovery-diagnosis;
|
||||
restore-label: root.recovery-restore-label;
|
||||
can-restore: root.recovery-can-restore;
|
||||
rebuild-label: root.recovery-rebuild-label;
|
||||
busy: root.recovery-busy;
|
||||
restore() => { root.recovery-restore(); }
|
||||
rebuild() => { root.recovery-rebuild(); }
|
||||
dismiss() => { root.recovery-dismiss(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// The question asked when the catalog turns out to be damaged.
|
||||
//
|
||||
// # Why this is a modal, when almost nothing else here is
|
||||
//
|
||||
// The house rule in this interface is to put the consequence in the button's
|
||||
// label rather than to raise a dialogue — "Export 40", "Empty trash · 128" —
|
||||
// and a genuine modal is kept for the two cases where the answer commits
|
||||
// gigabytes. This is the third case, and it earns it for a different reason:
|
||||
// there is nothing behind it to interact with. The grid cannot be drawn, the
|
||||
// scan must not run (it would write into the damage), and every control in the
|
||||
// window is about a library that cannot be read. A banner over an empty grid
|
||||
// would be a question the user could scroll away from and then wonder why
|
||||
// nothing worked.
|
||||
//
|
||||
// # Why the backdrop does not dismiss it
|
||||
//
|
||||
// Every other overlay here closes on a tap outside, and this one deliberately
|
||||
// does not. A stray tap that loses the two offers leaves the application in a
|
||||
// state with no way forward and no obvious way back to the question. There is
|
||||
// a "Leave it for now" button instead, which says what it does.
|
||||
//
|
||||
// # Why the destructive answer is not the primary one
|
||||
//
|
||||
// A restore keeps the user's collections; a rebuild cannot, because a manual
|
||||
// collection is a set of images the user assembled by hand and nothing in the
|
||||
// filesystem records it (docs/catalog.md §8.1). So the two answers are not
|
||||
// interchangeable, the difference is stated in the button rather than in a
|
||||
// second dialogue after it, and the rebuild is the plain button even when it
|
||||
// is the only one available.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button } from "widgets.slint";
|
||||
|
||||
export component RecoveryPrompt inherits Rectangle {
|
||||
/// What went wrong, in the user's terms. Empty closes the prompt — one
|
||||
/// source for "is this open", rather than a bool that can disagree with
|
||||
/// the words beside it.
|
||||
in property <string> title;
|
||||
/// What is safe and what is not, which is the part that determines whether
|
||||
/// the next minute is frightening.
|
||||
in property <string> detail;
|
||||
/// What SQLite actually said, kept because a bug report needs it and
|
||||
/// because a diagnosis the user can read is worth more than a reassurance
|
||||
/// they cannot check.
|
||||
in property <string> diagnosis;
|
||||
/// The restore offer, naming the backup's date. Empty when there is no
|
||||
/// backup to restore from, which is the case a fresh install is in.
|
||||
in property <string> restore-label;
|
||||
in property <bool> can-restore: false;
|
||||
/// The rebuild offer, naming what it costs — a full rescan, and the
|
||||
/// collections it cannot bring back.
|
||||
in property <string> rebuild-label;
|
||||
/// Set while a restore or rebuild is running, so neither can be started
|
||||
/// twice against the same file.
|
||||
in property <bool> busy: false;
|
||||
|
||||
callback restore();
|
||||
callback rebuild();
|
||||
callback dismiss();
|
||||
|
||||
visible: root.title != "";
|
||||
background: #000000E0;
|
||||
|
||||
// Swallows everything that misses the card, and answers nothing. See the
|
||||
// header: losing this by a stray tap leaves nowhere to go.
|
||||
TouchArea { }
|
||||
|
||||
Rectangle {
|
||||
width: min(460px, parent.width - 2 * Theme.gap-lg);
|
||||
height: min(card.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
TouchArea { }
|
||||
|
||||
card := VerticalLayout {
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "Recover library";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.title;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-lg;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.detail;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// Wrapped rather than elided: this is the one line a bug report
|
||||
// needs verbatim, and a truncated SQLite message is no message.
|
||||
Text {
|
||||
text: root.diagnosis;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
// Stacked, not a row: each label carries what its answer costs —
|
||||
// a date, a count of photographs — and three of those side by side
|
||||
// elide away exactly the part that lets the user choose.
|
||||
if root.can-restore: Button {
|
||||
text: root.busy ? "Working…" : root.restore-label;
|
||||
primary: true;
|
||||
enabled: !root.busy;
|
||||
clicked => { root.restore(); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: root.busy ? "Working…" : root.rebuild-label;
|
||||
// Primary only when it is the only answer there is. A rebuild
|
||||
// discards collections, so it does not get the emphasis while
|
||||
// a restore that keeps them is on the table.
|
||||
primary: !root.can-restore;
|
||||
enabled: !root.busy;
|
||||
clicked => { root.rebuild(); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Leave it for now";
|
||||
enabled: !root.busy;
|
||||
clicked => { root.dismiss(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user