"""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, 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. """ import json from fastapi import Body, Depends, Request, Response from sqlalchemy.orm import Session from ... import bundle, head, limits, models, schemas from ...database import get_db from .deps import CurrentUser, current_adventure, router @router.get("/{adventure_id}/export") def export_adventure( db: Session = Depends(get_db), adv: models.Adventure = Depends(current_adventure), ): """Returns a full backup: the story, the tree, the state, and the evidence. `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. **v1.1 WP-D: the export also says whether this version could import it back.** A campaign large enough to pass `limits.MAX_IMPORT_BODY_BYTES` still exports — the file is complete and not damaged, and refusing to write it would destroy the only copy the reader was trying to make. What it cannot do is come back in here, and the reader is told that at the moment they take it rather than at the moment they need it. It travels in headers, not in the body. The body is the bundle, the browser saves exactly those bytes as the file, and a warning inside it would become part of a portable story file and of every checksum taken over one. The size measured is the compact serialisation, because that is both what this response sends and what the browser POSTs back on import, which is what `BodySizeLimitMiddleware` weighs. The pretty-printed file the reader downloads is larger, and is not what import reads. """ payload = bundle.export(db, adv) # Serialised exactly as Starlette's JSONResponse would, so the bytes counted # are the bytes sent. body = json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") limit = limits.MAX_IMPORT_BODY_BYTES importable = len(body) <= limit headers = { "X-Export-Bytes": str(len(body)), "X-Import-Limit-Bytes": str(limit), "X-Importable-By-This-Version": "true" if importable else "false", } if not importable: headers["X-Export-Warning"] = limits.oversized_export_warning(len(body), limit) return Response(content=body, media_type="application/json", headers=headers) @router.post("/import", response_model=schemas.ImportedAdventureOut, status_code=201) def import_adventure( request: Request, payload: dict = Body(...), db: Session = Depends(get_db), user: models.User = CurrentUser, ): version = bundle.check_format(payload) limits.check_row_cap("adventures", db, user) limits.check_bundle_lists( story_cards=payload.get("storyCards"), memories=payload.get("memories"), actions=payload.get("actions"), branches=payload.get("branches"), ) # Check the tree before the adventure row exists, so that an inconsistent # file returns a 400 rather than leaving a half-imported adventure with a # gap in its story. story = bundle.plan(payload, version) # Count again, this time over what is written. The check above reads the # file's own lists, and in a v1 file one turn is one entry that carries its # retries in a `variants` array. `plan()` expands that into one row per # attempt, because SP4 made every attempt a node. A file of 5,000 turns with # ten attempts each therefore passes a 5,000-action cap and writes 50,000 # rows, well inside the 20 MB body limit. `plan()` has no side effects and # the adventure does not exist yet, so this check costs only the planning. limits.check_bundle_lists( actions=story["nodes"], memories=story["memories"], branches=story["branches"], ) 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.ImportedAdventureOut.model_validate(adventure) out.can_undo = head.can_undo(db, adventure) out.can_redo = head.can_redo(db, adventure) 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