Open an adventure on a window of the story, and page upward

Opening a finished adventure fetched every action in one response: 589.5 kB on
production's longest, and nothing about that curve bends on its own, because a
story only ever gets longer. The page load now brings the newest 60 actions
and the reader pages up from there. On the harness's 600-action fixture that
is 606.0 kB down to 62.6 kB, and -- the part that matters -- it no longer
depends on how long the story is.

Paged by anchor, not by offset. `before_id` is the oldest action the caller
holds; the server returns what precedes it. An offset counted back from the
newest would shift every older position the moment a turn lands, which is
exactly when someone is likely to be scrolling, and the reader would get one
action twice and never see another. It also keeps working when the story stops
being a flat list: comparing indices to order a branch survives the story tree,
treating them as positions does not.

`has_more` comes from fetching one row past the window rather than from
counting. A deleted anchor -- undo, mid-scroll -- reports the end rather than
guessing and serving a page the reader already has.

GET /{id}/actions and POST /{id}/undo now return {actions, total, has_more}
instead of a bare list. Undo is the action most likely to be repeated several
times running, so having it re-fetch the whole story would have undone the
paging on the worst case.

The adventure payload gets its window through set_committed_value rather than
by assignment: the actions relationship cascades delete-orphan, so assigning a
60-item list to it would delete everything outside the window on the next
flush.

In Play.jsx the prepend is followed by a useLayoutEffect that restores the
scroll position, before paint, so the story does not jump. Loading starts 400px
from the top rather than at it, guarded by a ref because scroll fires far
faster than React re-renders. There is a button as well as the scroll trigger:
on a short viewport the transcript may not be tall enough to scroll at all, and
a reader who cannot scroll must still be able to reach the beginning.

Verified against a running backend and a 220-action adventure: the page load
returns 60 of 220 ending on the newest, and walking back from an anchor returns
exactly the actions before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-17 14:23:03 +05:30
co-authored by Claude Opus 5
parent ae6e5af6c7
commit cf8ec22e8b
7 changed files with 519 additions and 18 deletions
+121 -11
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only, undefer
from sqlalchemy.orm.attributes import set_committed_value
from .. import auth, images, limits, memorybank, models, schemas, worldstate
from ..context import build_context
@@ -43,6 +44,76 @@ ACTION_LIST_COLUMNS = (
models.Action.created_at,
)
# How many actions an adventure opens with, and how many arrive per scroll.
#
# Opening a finished adventure used to fetch the whole story in one response —
# 589.5 kB on production's longest, and growing, because a story only ever gets
# longer. 60 is a few screens of reading: enough that the common case (open,
# read the end, take a turn) never pages at all, small enough that the worst
# case is bounded by the window rather than by the story.
ACTION_PAGE = 60
def action_window(
db: Session,
adventure_id: int,
before_id: int | None = None,
limit: int = ACTION_PAGE,
) -> tuple[list[models.Action], int, bool]:
"""The `limit` actions immediately older than `before_id`, oldest first.
Returns (actions, total, has_more). `before_id=None` is the newest window.
Anchored on an action, not on a count, and never on arithmetic over
`Action.index`. Two separate reasons, and both bite:
* **Appends.** Counting back from the newest means every older position
shifts when a turn lands. A reader who scrolls up while a turn is
generating would be handed a window one row out — re-sending one action
and silently skipping another. An anchor is fixed: "older than this one"
means the same thing before and after the story grows.
* **The story tree.** Index is a dense 0..n sequence today and branching
ends that. Comparing indices to order a branch survives; treating them as
positions does not.
`has_more` comes from asking for one row past the window rather than from
counting, so it costs a row and not a scan.
"""
total = (
db.query(func.count(models.Action.id))
.filter(models.Action.adventure_id == adventure_id)
.scalar()
)
if limit <= 0:
return [], total, total > 0
query = (
db.query(models.Action)
.options(load_only(*ACTION_LIST_COLUMNS))
.filter(models.Action.adventure_id == adventure_id)
)
if before_id is not None:
anchor = (
db.query(models.Action.index)
.filter(models.Action.id == before_id,
models.Action.adventure_id == adventure_id)
.scalar()
)
if anchor is None:
# The anchor was deleted (undo, or a turn edited away) while the
# reader was scrolling. Nothing older can be identified relative to
# a row that no longer exists, so report the end rather than
# guessing and handing back a duplicate page.
return [], total, False
query = query.filter(models.Action.index < anchor)
rows = query.order_by(models.Action.index.desc()).limit(limit + 1).all()
has_more = len(rows) > limit
rows = rows[:limit]
rows.reverse()
return rows, total, has_more
# Exactly what schemas.MemoryOut renders. `embedded` is a real column and is on
# the list; the vector it describes is not, and must never be.
MEMORY_LIST_COLUMNS = (
@@ -303,7 +374,25 @@ def create_adventure(
def get_adventure(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
return get_adventure_or_404(adventure_id, db, user)
"""The adventure, and the newest window of its story.
`actions` is the last ACTION_PAGE, not all of them; `action_count` says how
many there are so the reader knows there is more above. Older pages come
from GET /{id}/actions as they scroll up.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
actions, total, _ = action_window(db, adventure_id)
# Hand the response the window as if the relationship had loaded it.
# `set_committed_value` is the only way to do this safely: assigning
# `adventure.actions = [...]` marks the collection dirty, and the
# relationship cascades delete-orphan, so the actions left out of the
# window would be deleted on the next flush. This records them as the
# loaded, unmodified value instead, so serialising touches no lazy load
# and nothing is pending.
set_committed_value(adventure, "actions", actions)
out = schemas.AdventureOut.model_validate(adventure)
out.action_count = total
return out
@router.get("/{adventure_id}/script-state")
@@ -950,7 +1039,7 @@ def select_variant(
_active_turns.discard(adventure_id)
@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut])
@router.post("/{adventure_id}/undo", response_model=schemas.ActionPage)
def undo_turn(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
@@ -992,7 +1081,16 @@ def undo_turn(
memorybank.prune_dangling_memories(adventure, db)
db.commit()
db.refresh(adventure)
return adventure.actions
# The newest window, not the whole story: the client replaces its
# transcript with this, and the transcript is a window now. Returning
# everything here would undo the paging on the one action most likely
# to be repeated several times in a row.
actions, total, has_more = action_window(db, adventure_id)
return schemas.ActionPage(
actions=[schemas.ActionOut.model_validate(a) for a in actions],
total=total,
has_more=has_more,
)
finally:
_active_turns.discard(adventure_id)
@@ -1575,17 +1673,29 @@ def delete_memory(
# ---------- Actions (CRUD) ----------
@router.get("/{adventure_id}/actions", response_model=list[schemas.ActionOut])
@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage)
def list_actions(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
adventure_id: int,
before_id: int | None = None,
limit: int = ACTION_PAGE,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""A page of the story, walking backwards from the newest action.
`before_id` is the oldest action the caller already holds, so scrolling up
is "give me what comes before this". Omit it for the newest window. See
action_window for why this anchors on a row rather than an offset.
"""
get_adventure_or_404(adventure_id, db, user)
return (
db.query(models.Action)
.options(load_only(*ACTION_LIST_COLUMNS))
.filter(models.Action.adventure_id == adventure_id)
.order_by(models.Action.index)
.all()
limit = max(1, min(limit, ACTION_PAGE * 4))
actions, total, has_more = action_window(
db, adventure_id, before_id=before_id, limit=limit
)
return schemas.ActionPage(
actions=[schemas.ActionOut.model_validate(a) for a in actions],
total=total,
has_more=has_more,
)
+14
View File
@@ -225,7 +225,21 @@ class AdventureOut(ORMModel):
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
# The NEWEST window of the story, not all of it — older pages arrive from
# GET /{id}/actions as the reader scrolls up. `action_count` is the whole
# story's length, which is how the client knows there is more above.
actions: list[ActionOut] = []
action_count: int = 0
class ActionPage(BaseModel):
"""A slice of the story, counted back from the newest action."""
actions: list[ActionOut] = []
total: int = 0
# Whether anything older than this slice exists. Computed server-side so
# the client never has to do arithmetic on positions to find the end.
has_more: bool = False
# ---------- Memory bank (Phase 6) ----------