M9: a campaign you can actually get back

A campaign could already be exported and imported. What could not survive the
trip was everything that explains it: the state events behind the authoritative
document, the prompt each turn was actually given, the passages it was shown,
the summaries that carry long-story continuity, and which take belonged to which
turn. An imported campaign could be read and could no longer say why it was what
it was — and a manual correction, the one state change no narration explains,
was indistinguishable from something the story had established.

The bundle is now `ai-dnd-adventure-v3`, and the version is the design rather
than a side effect. Everything added here could have been another optional key,
the way persona, Save Points, narrative state and imported knowledge each were.
That mechanism stops working at exactly this addition: a v2 file with no prompt
provenance is ambiguous between "written before M9" and "written by M9 from a
campaign that has none", and those are different facts about a campaign. A
version number is how a recovery file states what it was capable of recording.
v1 and v2 still import, and every seam from pre-active-head onward is tested for
the rule that an older file is never reinterpreted under a newer assumption.

Two categories became three. "Chosen travels, derived is recomputed" was enough
until stored prompts had to be decided: they are derived, and they must travel
anyway. The test that separates evidence from cache is not "could this be
recomputed" but "would a recomputation answer the same question" — a rebuilt
search index answers the same question, a rebuilt prompt says what the turn
would be told *now*, which is the opposite of what the inspector is for.

Also here: a real SQLite backup, through the online backup API rather than a
file copy, taken while the application is running and verified before it is
kept; story cards settled as compatibility-only legacy data and taken out of the
narrator's prompt, because they were the untracked path around knowledge
authority that IMPORTED-KNOWLEDGE-DESIGN §73 already forbade; and no schema
change at all, proved against a database M8's own code wrote.

Three defects, found by running the milestone's own tests rather than by reading
them. Deleting a campaign leaked its FTS index rows, and SQLite then handed the
freed ids to the next source imported into any campaign, which failed with an
integrity error that Reindex could not repair — both ends are closed, and a
database already carrying the damage now repairs itself. An imported node with
no state snapshot was being stamped with the campaign's head state, so an Undo
to turn 2 showed what the story knew at turn 20. And the snapshot relink did not
persist at all, because it mutated a dict in place on a column SQLAlchemy tracks
by assignment: it looked correct in memory and wrote the wrong ids to disk.

Carrying per-turn prompts looked like it would halve the length of campaign that
can be restored. Measured — and after compressing them inside the file —
everything M9 added costs 12% of it: the import ceiling moves from about 318
turns to about 279, against a 100-turn certification target. The dominant cost
is not M9's at all. The per-position narrative state document is 74% of a
bundle, and v2 already carried it.

Backend 1,102 passed / 14 skipped / 0 failed. Frontend 145 passed. Lint,
production build and Docker build clean. Verified across two server processes
with two data directories, and in a real browser against a real narrator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
This commit is contained in:
JesseMarkowitz
2026-09-07 01:55:45 -04:00
co-authored by Claude Opus 5
parent 1ce9972760
commit 44edece67e
46 changed files with 9227 additions and 178 deletions
+278
View File
@@ -0,0 +1,278 @@
"""M9: a consistent copy of the whole database, taken while the app is running.
This is **not** the campaign bundle, and the two are not alternatives. They are
different recovery tools and M9 keeps them apart deliberately:
campaign bundle one campaign, logical, portable between installations,
importable into a clean data directory on another
machine, readable by a human and by a later build
database backup every campaign, every setting, physical, this machine,
restored by putting the file back
The bundle is the primary cross-install recovery path and is what the acceptance
tests measure. This exists for the other question: the reader has one database
holding everything they have ever played, and wants a copy of it before they
upgrade, move a disk, or try something they might regret.
## Why not `cp data.db backup.db`
Because a copy taken with the application running is a copy of a moving target.
SQLite writes a database in pages, and a plain file copy can read page 5 before
a transaction and page 900 after it — the result is a file that opens, reports a
schema, and is silently missing or duplicating rows. In WAL mode it is worse: the
committed data may be in a `-wal` file the copy never touched. Nothing warns
anyone. The corruption is found later, by which time the original may be gone.
So this uses SQLite's own **online backup API** (`sqlite3.Connection.backup`),
which is the supported mechanism for exactly this: it copies page by page while
holding the right locks, restarts if a write moves the source underneath it, and
produces a file that is a transactionally consistent snapshot of some committed
point. The application keeps running throughout; no session is closed and no
turn is blocked.
## What the procedure guarantees
1. The source database is opened **read-only** and is never written to. A backup
that could damage what it is backing up would be worse than no backup.
2. The copy is written to a temporary file beside the destination and renamed
into place only after it has been verified, so an interrupted or failed run
never leaves a half-written file wearing a backup's name. `os.replace` is
atomic on the same filesystem, which is why the temporary sits in the
destination's own directory rather than in `/tmp`.
3. `PRAGMA quick_check` runs against the finished copy, opened as its own
database, before it is renamed. A backup nobody verified is a belief.
4. An existing file is never overwritten. Each run writes a new name stamped
with the time, so yesterday's backup survives today's mistake — which is most
of what a backup is for.
5. Failure is reported and leaves nothing behind but the log line.
## What it does not do
There is no restore endpoint. Restoring a whole database means replacing the
file the running application has open, and doing that from inside that
application is a way to lose both copies. The procedure is in `DEVELOPMENT.md`:
stop the app, move the file into place, start it. Campaign-level recovery — the
common case, and the one that crosses machines — is the bundle.
No path comes from a caller. The destination directory is derived from the
database the application is already using and the filename is generated here, so
there is no request that can direct a write anywhere else (H08).
"""
from __future__ import annotations
import logging
import os
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .database import DB_PATH
log = logging.getLogger(__name__)
#: Where backups go: a directory beside the database itself. Beside, rather than
#: inside a configurable location, because the one thing this must not do is
#: write somewhere a request can name.
DIRECTORY_NAME = "backups"
#: The stem every backup file carries, so a directory listing sorts by date and
#: says what these files are without being opened.
PREFIX = "adventure-storyteller"
class BackupError(RuntimeError):
"""A backup did not complete. The source database is untouched."""
@dataclass(frozen=True)
class Backup:
"""One finished, verified backup file."""
path: Path
bytes: int
pages: int
seconds: float
integrity: str
def as_dict(self) -> dict:
return {
# The name alone, not the path. The full path is a fact about this
# machine's filesystem, and the reader is told the directory once by
# the endpoint that lists them.
"filename": self.path.name,
"bytes": self.bytes,
"pages": self.pages,
"seconds": round(self.seconds, 3),
"integrity": self.integrity,
}
def directory(db_path: Path | None = None) -> Path:
"""The backup directory for a database, created if it does not exist."""
root = (db_path or DB_PATH).parent / DIRECTORY_NAME
root.mkdir(parents=True, exist_ok=True)
return root
def create(db_path: Path | None = None, *, now: datetime | None = None) -> Backup:
"""Takes one verified backup of the live database, and returns it.
Raises `BackupError` on any failure, having removed whatever it had written.
The source database is opened read-only and is never modified, so a failure
here costs the backup and nothing else.
"""
source_path = db_path or DB_PATH
if not source_path.exists():
raise BackupError(f"There is no database at {source_path}.")
stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
target = _unused_name(directory(source_path), stamp)
# The temporary sits in the destination directory so the rename below is a
# rename rather than a copy across filesystems, which would not be atomic.
working = target.with_name(target.name + ".partial")
started = datetime.now()
try:
pages = _copy(source_path, working)
integrity = _verify(working)
except BackupError:
_discard(working)
raise
except Exception as exc: # noqa: BLE001 - reported, never raised raw
_discard(working)
log.exception("Backup of %s failed", source_path)
raise BackupError(f"{type(exc).__name__}: {exc}") from exc
size = working.stat().st_size
# Only now does the file get the name a reader would trust.
os.replace(working, target)
return Backup(
path=target,
bytes=size,
pages=pages,
seconds=(datetime.now() - started).total_seconds(),
integrity=integrity,
)
def _copy(source_path: Path, working: Path) -> int:
"""Runs SQLite's online backup from `source_path` into a new file.
The source is opened through a URI with `mode=ro`, so this connection cannot
write to it even by accident. The destination is a fresh database that this
function creates; `backup()` overwrites whatever is in it, and the caller has
guaranteed the name is unused.
Returns the number of pages copied, which is the one honest measure of how
much was actually written — the file size counts pages the source had
already allocated.
"""
source = sqlite3.connect(f"file:{source_path}?mode=ro", uri=True)
try:
destination = sqlite3.connect(working)
try:
copied = 0
def progress(_status, remaining, total):
nonlocal copied
copied = total - remaining
# `pages=-1` copies the whole database in one step while holding the
# source's read lock, which is the right trade for a local
# single-user database: it is the fastest option, it cannot restart
# partway, and the lock it holds does not block readers.
source.backup(destination, pages=-1, progress=progress)
return copied
finally:
destination.close()
finally:
source.close()
def _verify(working: Path) -> str:
"""Runs `PRAGMA quick_check` against the finished copy.
Opened as its own connection, so what is checked is the file on disk rather
than any page cache the copy left behind. `quick_check` rather than
`integrity_check` because it does the structural work — every page reachable,
every record readable — without the full index cross-check, which on a large
database is minutes rather than moments. A backup nobody verified is a
belief; a backup verified slowly enough that nobody takes one is worse.
"""
connection = sqlite3.connect(f"file:{working}?mode=ro", uri=True)
try:
rows = connection.execute("PRAGMA quick_check").fetchall()
finally:
connection.close()
result = ", ".join(str(row[0]) for row in rows) if rows else "no result"
if result != "ok":
raise BackupError(
f"The backup was written but did not verify: {result}. It has been "
f"discarded; the original database is untouched."
)
return result
def _unused_name(root: Path, stamp: str) -> Path:
"""A name in `root` that nothing is using.
An existing backup is never overwritten. Two backups taken inside one second
are the only way to collide, and the counter settles that rather than one of
them silently replacing the other.
"""
candidate = root / f"{PREFIX}-{stamp}.db"
counter = 2
while candidate.exists() or candidate.with_name(candidate.name + ".partial").exists():
candidate = root / f"{PREFIX}-{stamp}-{counter}.db"
counter += 1
return candidate
def _discard(working: Path) -> None:
"""Removes a partial file, ignoring a file that is already gone."""
try:
working.unlink()
except OSError:
pass
def existing(db_path: Path | None = None) -> list[dict]:
"""Every backup in the directory, newest first.
Names and sizes only. Reading one to report what is inside it would mean
opening a database on every page load for a screen that is a list.
`taken_at` is read out of the **filename**, which is the stamp `create`
wrote when it took the backup, and falls back to the file's modification
time only for a name that does not parse. The two usually agree, and where
they disagree the name is the one telling the truth: copying a backup to
another disk, restoring it from an archive, or touching it all move the
mtime, and a list that then reordered itself would report when the file was
last handled rather than when the backup was taken.
"""
root = directory(db_path)
rows = []
for path in root.glob(f"{PREFIX}-*.db"):
try:
stat = path.stat()
except OSError:
continue
rows.append({
"filename": path.name,
"bytes": stat.st_size,
"taken_at": (
_stamp_in(path.name) or datetime.fromtimestamp(stat.st_mtime)
).isoformat(timespec="seconds"),
})
rows.sort(key=lambda row: (row["taken_at"], row["filename"]), reverse=True)
return rows
def _stamp_in(filename: str) -> datetime | None:
"""The time in a backup's name, or `None` if it does not carry one."""
rest = filename[len(PREFIX) + 1:].removesuffix(".db")
# A collision within one second gets a `-2` suffix, which is not the stamp.
stamp = "-".join(rest.split("-")[:2])
try:
return datetime.strptime(stamp, "%Y%m%d-%H%M%S")
except ValueError:
return None
+898 -47
View File
File diff suppressed because it is too large Load Diff
+43 -24
View File
@@ -29,7 +29,9 @@ from ..knowledge import records as knowledge_records
from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
# `CARD_BUDGET_SHARE = 0.4` was here, and is gone with the injection it bounded
# (M9). It is named rather than deleted silently because two other places
# reasoned about their own share against it.
NPC_WINDOW = 6 # actions of story searched for NPC trigger words ("in scene")
SEPARATOR = "\n\n"
@@ -508,30 +510,47 @@ def build_context(
adventure, available_after_knowledge, count_tokens, exclude_action_id
)
# ----- Story cards: triggered by recent story text (the window history could fill) -----
trigger_window = truncate_to_last_tokens(
SEPARATOR.join(a.text for a in actions), available_after_knowledge
)
triggered = match_cards(adventure.story_cards, trigger_window)
card_budget = int(available_after_knowledge * CARD_BUDGET_SHARE)
card_records = []
lore_lines: list[str] = []
# ----- Story cards: legacy, and no longer part of the narrator's prompt (M9)
#
# Until M9 a keyword-triggered story card was injected here as
# `World Lore: <entry>`, taking up to 40% of what was left after the
# imported knowledge had been placed.
#
# `IMPORTED-KNOWLEDGE-DESIGN.md` §73 settles that Story Cards are not the
# production imported-knowledge store and, in as many words, that they "must
# not become an alternate untracked path around the new knowledge
# authority/provenance rules". That is exactly what this was. A card entry
# arrived in front of the narrator as a world fact with:
#
# * no class — nothing said whether it was Canon, Reference or Inspiration,
# so nothing framed how far the narrator could rely on it;
# * no visibility — no narrator-only distinction at all;
# * no source, no hash, no lifecycle, nothing to disable it with;
# * no browser surface, since M8 removed the editor — so a reader could
# neither see it nor switch it off;
# * and no row in the context inspector, which renders `knowledge` and
# never rendered `cards`.
#
# It also competed with imported Canon for one budget, which is the
# arrangement M7 spent a milestone separating.
#
# M9's decision, recorded in the milestone report: story cards are
# **compatibility-only legacy data**. Nothing is deleted. The rows stay, the
# `/api/story-cards` endpoints stay, the bundle carries them out and back so
# a round trip destroys nothing, and `memorybank.cast_brief` still reads them
# as the summariser's character roster — a roster names who is on stage so a
# memory says "Aldric" rather than "he", it never reaches the narrator, and
# every memory written from it is authority-classified by the application
# afterwards. What stops is the one path that asserted campaign facts to the
# narrator without any of the controls §73 requires.
#
# `cards` stays in the report and is now always empty for a new turn.
# Removing the key would break the historical snapshots that have one, which
# M9 has just made portable: an old turn's evidence says story cards were
# included, and it must go on saying so.
card_records: list[dict] = []
lore_section = None
used = 0
for match in triggered:
line = f"World Lore: {match['entry'].strip()}"
tokens = count_tokens(line)
included = used + tokens <= card_budget
if included:
lore_lines.append(line)
used += tokens
card_records.append(
{"id": match["id"], "name": match["name"], "keyword": match["keyword"],
"included": included}
)
lore_section = (
Section("world_lore", "\n".join(lore_lines)) if lore_lines else None
)
# ----- Story history: newest first until the remaining budget is spent -----
history_budget = available_after_knowledge - used
+42 -2
View File
@@ -137,13 +137,53 @@ def index_line(heading_path: str, text_: str) -> str:
def add(db: Session, chunk_id: int, heading_path: str, text_: str) -> None:
"""Indexes one passage. The caller supplies the chunk's id as the rowid."""
"""Indexes one passage. The caller supplies the chunk's id as the rowid.
`OR REPLACE`, and the reason is a defect M9 found rather than a defensive
habit. The rowid is a chunk's primary key, so a row already sitting at it is
by definition stale: the chunk that owned it does not exist, or is being
rewritten by the reindex that called this. Either way the new passage is the
truth and the old row is not.
Without it, an orphaned index row makes an ordinary import fail. SQLite
reuses primary keys once the highest row is gone, so the next campaign to
import a source is handed rowid 1 again, collides with an orphan, and gets a
500 from `INSERT` — and `clear_index` cannot clear the orphan, because it
finds index rows *through* the chunks, and there are none. That made Reindex,
which is the documented repair, unable to repair this. `REPLACE` closes it
from both ends: a leaked row is overwritten the moment the id comes round
again, so an existing database repairs itself rather than needing a
migration, and Reindex is the repair it is described as.
The leak itself is closed separately, in `importer.clear_campaign_index`.
"""
db.execute(
sql(f"INSERT INTO {TABLE} (rowid, text) VALUES (:id, :text)"),
sql(f"INSERT OR REPLACE INTO {TABLE} (rowid, text) VALUES (:id, :text)"),
{"id": chunk_id, "text": index_line(heading_path, text_)},
)
def remove_adventure(db: Session, adventure_id: int) -> int:
"""Drops every index row belonging to one campaign. Returns how many.
Scoped through the chunks, which is the only place the campaign is
recorded — the index deliberately holds no copy of it
(see "The table" above). So this has to run **before** the chunk rows go,
which is what `importer.clear_campaign_index` is for.
"""
result = db.execute(
sql(
f"""
DELETE FROM {TABLE} WHERE rowid IN (
SELECT id FROM knowledge_chunks WHERE adventure_id = :adventure_id
)
"""
),
{"adventure_id": adventure_id},
)
return result.rowcount or 0
def remove_chunks(db: Session, chunk_ids: list[int]) -> None:
"""Drops passages from the index by id.
+22
View File
@@ -362,6 +362,28 @@ def clear_index(db: Session, source: models.KnowledgeSource) -> None:
db.expire(source, ["chunks"])
def clear_campaign_index(db: Session, adventure: models.Adventure) -> int:
"""Removes a whole campaign's lexical index rows. Returns how many.
Called before a campaign is deleted, and it has to be: the FTS index is a
virtual table, so no foreign key reaches it and no `ON DELETE CASCADE`
covers it. Deleting a campaign cascades `knowledge_sources` to
`knowledge_chunks` and stops there, leaving one index row per passage
belonging to a chunk that no longer exists.
Found in M9. The leak is not cosmetic. SQLite hands out the lowest free
primary key, so once the highest chunk is gone the *next* source imported
into *any* campaign is given a chunk id that an orphan already occupies, and
the import fails with an integrity error — a 500 on an ordinary upload, in a
campaign that has nothing to do with the deleted one. `fts.add` now repairs
such a collision when it meets one; this stops it happening.
Vectors and passages need no equivalent, because both are real tables whose
foreign keys cascade.
"""
return fts.remove_adventure(db, adventure.id)
def delete_source(db: Session, source: models.KnowledgeSource) -> None:
"""Removes a source and everything derived from it.
+11 -4
View File
@@ -50,10 +50,17 @@ from .records import Candidate, Result
#: Share of the non-protected budget that retrieved knowledge may spend.
#:
#: Story cards already take up to 40% (`CARD_BUDGET_SHARE`), and the history is
#: what is left. A third is enough for several passages at the chunker's
#: typical size and leaves the majority of the window to the story itself,
#: which is the thing the reader came for.
#: A third is enough for several passages at the chunker's typical size and
#: leaves the majority of the window to the story itself, which is the thing the
#: reader came for.
#:
#: This share was chosen when story cards could take up to 40% of the same
#: budget and the history took what was left. M9 removed that injection
#: (`IMPORTED-KNOWLEDGE-DESIGN.md` §73), so the history now gets that 40% back.
#: The number here is deliberately unchanged: a third of the budget was chosen
#: as the right amount of *imported material* to put in front of the narrator,
#: not as a leftover, and raising it because room appeared would be changing
#: retrieval behaviour under cover of a portability milestone.
KNOWLEDGE_SHARE = 0.33
#: What each class may take of the knowledge budget. Canon may take all of it;
+5 -1
View File
@@ -10,7 +10,9 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from .database import engine
from .limits import BodySizeLimitMiddleware
from .migrations import bootstrap
from .routers import adventures, chat, debug, scenarios, settings, story_cards
from .routers import (
adventures, backups, chat, debug, scenarios, settings, story_cards,
)
from .seed import seed_public_scenarios
bootstrap(engine)
@@ -112,6 +114,8 @@ app.include_router(scenarios.router)
app.include_router(adventures.router)
app.include_router(story_cards.router)
app.include_router(settings.router)
# M9: a verified copy of the whole database, taken while the app is running.
app.include_router(backups.router)
app.include_router(chat.router)
app.include_router(debug.router)
+47 -12
View File
@@ -1,7 +1,31 @@
"""Exporting an adventure to a bundle, and importing one back.
`app/bundle.py` owns the format and the version handling. These two endpoints
only check ownership and hand the work over.
only check ownership, apply the caps, and hand the work over.
## Why the import is one transaction and two phases
`bundle.plan` reads the whole file and returns a checked, normalised tree
without opening a session, touching a row or creating an adventure. Everything a
hand-edited file can get wrong about its own shape — a node on a branch that is
not listed, a fork from a branch listed after it, a head past the story, an
audit record naming a turn that is not there — is a 400 from a function with no
side effects.
Only then does `bundle.materialize` write, and it writes inside the single
transaction this endpoint commits at the end. So there are exactly two outcomes
a caller can see, and M9 requires them to be distinguishable:
the authoritative import failed 4xx, and no campaign exists
the authoritative import succeeded 201, and the campaign is complete
A third state — the campaign landed and a *rebuildable* index did not — is not a
failure of the import and does not roll it back. Passages, the lexical index and
vectors are all a deterministic function of content the file carries, so losing
them costs a rebuild rather than data. It is reported on the response as a
warning, it is visible per source in the Knowledge panel, and Reindex is the
repair. Refusing a whole campaign because a search index would not build would
trade the valuable thing for the cheap one.
"""
from fastapi import Body, Depends, Request
@@ -18,15 +42,15 @@ def export_adventure(
db: Session = Depends(get_db),
adv: models.Adventure = Depends(current_adventure),
):
"""Returns a full backup: plot components, story cards, scripts, state, and tree.
"""Returns a full backup: the story, the tree, the state, and the evidence.
`app/bundle.py` owns the format, in both of its versions. A backup outlives
the schema, so no call site decides anything about its shape.
`app/bundle.py` owns the format, in all three of its versions. A backup
outlives the schema, so no call site decides anything about its shape.
"""
return bundle.export(db, adv)
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
@router.post("/import", response_model=schemas.ImportedAdventureOut, status_code=201)
def import_adventure(
request: Request,
payload: dict = Body(...),
@@ -58,18 +82,29 @@ def import_adventure(
branches=story["branches"],
)
adventure = bundle.materialize(db, payload, story, user.id)
db.commit()
try:
adventure, report = bundle.materialize(db, payload, story, user.id)
db.commit()
except Exception:
# Explicit, rather than left to the session closing. The planner has
# already refused everything it can see, so anything raising here is a
# write that surprised us — the case where leaving a partial campaign
# behind would be worst, and the case a test can only assert on if the
# rollback is a statement rather than a side effect of teardown.
db.rollback()
raise
db.refresh(adventure)
# A campaign exported while undone imports undone (M3), so the history
# controls have to be right on the response that opens it — otherwise the
# first thing the reader sees about a story with a retained future is a
# greyed-out Redo.
out = schemas.AdventureOut.model_validate(adventure)
out = schemas.ImportedAdventureOut.model_validate(adventure)
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
# This is not a funnel step. A returning player imports a bundle, so it
# says nothing about how far a first-time visitor got. It is counted anyway,
# because it is the clearest evidence that anyone uses the export format.
out.import_warnings = [
f"The search index for “{failure['title']}” could not be rebuilt "
f"({failure['detail']}). The file itself imported intact — use Reindex "
f"in the Knowledge panel to try again."
for failure in report["knowledge_index_failures"]
]
return out
+8
View File
@@ -14,6 +14,8 @@ from ... import (
worldstate,
)
from ...database import get_db
from ...knowledge import embeddings as knowledge_embeddings
from ...knowledge import importer as knowledge_importer
from .deps import CurrentUser, current_adventure, router
from .paging import action_window, annotate_takes
@@ -348,8 +350,14 @@ def delete_adventure(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
# M9. The lexical index first, while the chunks that locate it still exist.
# It is a virtual table, so nothing cascades into it, and an orphaned index
# row makes the *next* import into *any* campaign fail — see
# `knowledge.importer.clear_campaign_index`.
knowledge_importer.clear_campaign_index(db, adventure)
db.delete(adventure)
db.commit()
# No later request reads this adventure's vectors, so drop them now. The
# cache would otherwise hold them until the process restarted.
memorybank.forget_cached_vectors(adventure_id)
knowledge_embeddings.forget_cached(adventure_id)
+75
View File
@@ -0,0 +1,75 @@
"""M9: taking a verified copy of the whole database, from the browser.
Two endpoints and no third. `app/backup.py` owns the procedure and every
guarantee it makes; these only decide who may ask.
## Why there is no restore endpoint, and no download
**Restore** means replacing the database file the running process has open.
Doing that from inside that process is how someone loses both copies at once:
the connection pool still holds handles on the old file, the WAL belongs to the
old file, and a half-swapped database is not something a running application can
notice. The supported procedure is in `DEVELOPMENT.md` — stop the application,
move the file into place, start it — and it is a procedure precisely because
each step needs the application not to be running. Campaign-level recovery, the
common case and the only one that crosses machines, is the export bundle.
**Download** is not offered either. The file is a copy of every campaign on the
machine, and streaming it through the browser would put it in the download
directory, in the browser's own cache, and in whatever the reader does with it
next — for a local single-user application whose whole premise is that the story
does not leave the machine, that is a worse default than a path the reader can
copy. So the response names the directory and the reader takes it from there.
## Where the file goes
Nowhere a request can name. The destination is derived from the database the
application is already using, and the filename is generated from the clock. No
part of either comes from the caller, so there is no traversal to attempt (H08),
and the endpoints below accept no body at all.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from .. import auth, backup, models
router = APIRouter(prefix="/api/backups", tags=["backups"])
log = logging.getLogger(__name__)
@router.get("")
def list_backups(_user: models.User = Depends(auth.get_current_user)):
"""The backups already on disk, newest first, and where they are.
The directory is reported once here rather than on every row, because it is
the same for all of them and it is what the reader needs in order to find
the files at all.
"""
return {
"directory": str(backup.directory()),
"backups": backup.existing(),
}
@router.post("", status_code=201)
def create_backup(_user: models.User = Depends(auth.get_current_user)):
"""Takes one verified backup, and reports what it wrote.
Synchronous. A backup of a local single-user database is a page copy that
finishes in well under a second, and a reader who pressed the button is
entitled to be told whether it worked rather than to be told it started.
A failure is a 500 carrying the reason. There is nothing for the caller to
fix by retrying differently — the request has no parameters — so the useful
thing is the message, and `backup.create` guarantees that the source database
is untouched and no partial file is left behind.
"""
try:
result = backup.create()
except backup.BackupError as exc:
log.error("Backup failed: %s", exc)
raise HTTPException(500, str(exc)) from exc
return {"directory": str(result.path.parent), **result.as_dict()}
+19
View File
@@ -497,6 +497,25 @@ class AdventureOut(ORMModel):
can_redo: bool = False
class ImportedAdventureOut(AdventureOut):
"""A campaign that has just been restored from a bundle (M9).
Exactly `AdventureOut` plus what could not be rebuilt. The extra field is on
a subclass rather than on the base, because "which of your search indexes
failed to rebuild" is a fact about one import and not a property of a
campaign — putting it on `AdventureOut` would attach it to every read of
every campaign forever.
An empty list is the ordinary answer and means the whole campaign, its
evidence and its derived indexes all landed. A non-empty one means the
authoritative import succeeded and a rebuildable index did not, which is a
distinction M9 requires a caller to be able to draw: the campaign is intact,
and Reindex is the repair.
"""
import_warnings: list[str] = []
class ActionPage(BaseModel):
"""A slice of the story, counted back from the newest action."""
+3 -1
View File
@@ -63,7 +63,9 @@ def give(db: Session, user: models.User) -> models.Adventure | None:
# flush whatever part of the adventure the session still held.
with db.begin_nested():
story = bundle.plan(payload, bundle.check_format(payload))
adventure = bundle.materialize(db, payload, story, user.id)
# The starter ships with no imported knowledge, so the derived
# report is always empty here and nothing reads it.
adventure, _ = bundle.materialize(db, payload, story, user.id)
_link_scenario(db, adventure, payload)
return adventure
except Exception: