feat(wiki): let a GM roll an entry back to an earlier version (#408) #476
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/408-lore-version-restore"
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 restore half of #408. Meets acceptance criteria 2 and 3; #408 stays open for the soft-delete half (see below).
The gap
Every edit to a lore entry has snapshotted the previous state since
d2e3f4a5b6c7, and that history has always been read-only —list_lore_versionswas the only version endpoint there was. So recovering from a bad edit meant copying an old body out of the history view and pasting it back into the editor field by field, with the sidebar and timeline JSON left to reconstruct by hand or simply given up on.The data to undo the mistake was already sitting there. Nothing could apply it.
What lands
Routed through
update_lore_entryrather than assigning the fields directly. That function is what snapshots the current state before changing anything, so a restore is itself recorded as a version and is itself undoable — the difference between an undo and a second way to lose the text you had. It also means restore inherits the rest of that path (search vector, summary regeneration, alias handling) instead of quietly diverging from it, which is how a second write path to the same table usually goes wrong.get_lore_versionscopes the lookup to the entry, so a version id belonging to another entry — or another campaign — cannot be written over this one. GM-only, matching every other write to an entry, and audited aslore.version_restored.One trap worth knowing about
The endpoint re-fetches through
get_lore_entryafter committing rather than callingrefresh. The commit expires the instance, and_lore_responsereads relationships that a refresh does not reload — touching one then raisesMissingGreenletfrom a lazy load with no greenlet to run in. Same shape as the beat-notes bug in #403, and it cost a debugging round here too.Why the soft-delete half is not in this PR
#408's other half — stopping a deletion from cascading the version history — is considerably larger than the issue suggests.
LoreEntryis referenced in 80 places across 20 files (routers, bot API, shelf, import/export, account deletion, lore generation, campaign service), so soft-delete means auditing every read path; miss one and a "deleted" entry keeps appearing somewhere. Plus a migration, a purge task, and a restore-entry flow.It is also the same work as #405's campaign soft-delete. Both are trash-and-grace-then-purge, #427 already set that precedent for audio, and
LootEntry.deleted_atis a column precedent. Doing them as one convention applied twice is better than two implementations that drift apart, so they will be tackled together.Verification
9 tests. 6 mutations, all caught: skipping the pre-restore snapshot, an unscoped version lookup, dropping the structured fields, dropping
gm_notes, a missing version that is not a 404, and a restore absent from the audit log.Full backend suite 1567 passed;
ruff checkandruff format --checkclean.🤖 Generated with Claude Code
Every edit to a lore entry has snapshotted the previous state since `d2e3f4a5b6c7`, and that history has always been read-only. `list_lore_versions` was the only version endpoint there was, so recovering from a bad edit meant copying an old body out of the history view and pasting it back into the editor field by field — with the sidebar and timeline JSON left to reconstruct by hand, or simply given up on. The data to undo the mistake was already there; nothing could apply it. POST /campaigns/{id}/lore/{entry_id}/versions/{version_id}/restore Deliberately routed through `update_lore_entry` rather than assigning the fields directly. That function is what snapshots current state before changing anything, so a restore is itself recorded as a version and is itself undoable — the difference between an undo and a second way to lose the text you had. It also means restore inherits the rest of that path (search vector, summary regeneration, alias handling) instead of quietly diverging from it, which is how a second write path to the same table usually goes wrong. `get_lore_version` scopes the lookup to the entry, so a version id belonging to another entry — or another campaign — cannot be written over this one. GM-only, matching every other write to an entry, and audited as `lore.version_restored`. One thing worth knowing for the next person: the endpoint re-fetches through `get_lore_entry` after committing rather than calling `refresh`. The commit expires the instance, and `_lore_response` reads relationships that a refresh does not reload — touching one then raises MissingGreenlet from a lazy load with no greenlet to run in. Same shape as the beat-notes bug in #403. This is the smaller half of #408. The soft-delete half — stopping a deletion cascading the version history — is much larger than the issue suggests: `LoreEntry` is referenced in 80 places across 20 files, so it means auditing every read path, plus a migration, a purge task and a restore-entry flow. It is also the same work as #405's campaign soft-delete, so both are better done as one trash-and-grace convention applied twice than as two implementations that drift apart. #408 stays open for it. Verification: 9 tests, 6 mutations all caught — skipping the pre-restore snapshot, an unscoped version lookup, dropping the structured fields, dropping gm_notes, a missing version that is not a 404, and a restore absent from the audit log. Full backend suite 1567 passed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>