WP-D and WP-E complete the planned v1.1 implementation packages. WP-D — recovery honesty: - backups verify the completed copy with PRAGMA integrity_check - corruption missed by quick_check is detected by the full check - existing good backups remain protected - oversized exports are still delivered but declare whether this version can import them, while the 20 MB import limit remains unchanged - backup was exercised through the real browser UI on both the normal campaign database and a campaign-shaped database over 100 MB WP-E — control-boundary contrast: - interactive control boundaries meet the WCAG 1.4.11 3:1 target - the contrast audit is now a failing gate rather than an advisory - rendered browser measurements pass for the composer, controls, tabs and nav - text contrast and focus visibility remain intact - owner reviewed and approved the before/after screenshots Reports: - planning/reports/v1.1/V1.1-WP-D-REPORT.md - planning/reports/v1.1/V1.1-WP-E-REPORT.md All planned v1.1 work packages A-E are now complete. Release validation has not yet begun.
143 lines
6.5 KiB
Python
143 lines
6.5 KiB
Python
"""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
|