diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 009d316cc..ee713c6fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ name: CI permissions: contents: read packages: write + # The API Contract job posts its report as a pull-request comment. + pull-requests: write on: pull_request: @@ -90,6 +92,122 @@ jobs: - name: Clippy check run: cargo clippy --features rar -- -D warnings + # Guard the API contract: the committed spec must match the code, and any + # change to what clients depend on is surfaced on the run. + # + # The freshness check is not optional decoration. `web/openapi.json` is a + # committed artifact regenerated by a pre-commit hook, so a commit made with + # `--no-verify`, or from a clone where `make setup-hooks` was never run, + # leaves it stale. oasdiff would then compare two identical stale files, pass, + # and let the breaking change through: the gate would be theatre. + api-contract: + name: API Contract + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_VERSION: openapi + RUSTC_WRAPPER: sccache + steps: + - uses: actions/checkout@v4 + - name: Install mold linker + run: sudo apt-get update && sudo apt-get install -y mold + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.9 + + - name: Check the committed spec matches the code + run: | + cargo run -- openapi --output /tmp/openapi-fresh.json + if ! diff -q /tmp/openapi-fresh.json web/openapi.json >/dev/null; then + echo "::error::web/openapi.json is stale. Run 'make openapi-all' and commit the result." + diff -u web/openapi.json /tmp/openapi-fresh.json | head -60 || true + exit 1 + fi + if ! diff -q web/openapi.json docs/api/openapi.json >/dev/null; then + echo "::error::docs/api/openapi.json differs from web/openapi.json. Run 'make openapi'." + exit 1 + fi + echo "OpenAPI spec is in sync with the backend." + + - name: Fetch the base branch + run: git fetch --depth=1 origin ${{ github.base_ref }} + + # Reports rather than fails, and the distinction is deliberate. + # + # A red job means "fix this before merging". A contract change is not + # that: breaking changes are a normal mid-cycle event here, because the + # version is decided at tag time rather than per PR. Failing the job would + # mark a routine, intentional act as a failure, and a check that is + # routinely red is one people learn to skim past, which costs more than it + # catches. + # + # `continue-on-error` keeps the job green while still flagging the step, so + # the run page shows the change without claiming anything is broken. The + # decision this feeds is made by `make release-prepare`, which compares + # against the previous tag at the moment the version is actually chosen. + # + # The stale-spec check above stays fatal, because that one really is a + # mistake: the committed artifact does not match the code. + - name: Report API contract changes + id: contract + continue-on-error: true + uses: oasdiff/oasdiff-action/breaking@v0 + with: + base: origin/${{ github.base_ref }}:web/openapi.json + revision: HEAD:web/openapi.json + fail-on: ERR + # The spec is public, but uploading it to a third party should be a + # decision rather than a default. `github-token: ''` disables the + # action's own comment, which posts a link to that upload rather than + # the findings; the report is posted below instead. + review: false + github-token: "" + + # One comment, edited in place, so the PR always shows the current state + # rather than a thread of stale reports. It is rewritten to say the + # contract is unchanged once the findings are gone, because a lingering + # "3 breaking changes" on a PR that no longer has any is worse than no + # comment at all. + - name: Comment the contract report + if: always() && github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + REPORT: ${{ steps.contract.outputs.breaking }} + run: | + MARKER='' + + if [ -n "$REPORT" ]; then + BODY=$( + printf '%s\n### API contract changes\n\n' "$MARKER" + printf 'Compared against `%s`. These are changes a client generated from the\n' "${{ github.base_ref }}" + printf 'previous document would notice. Not a failure: breaking changes are a\n' + printf 'release-time decision, and `make release-prepare` checks the bump against\n' + printf 'them when the version is chosen.\n\n' + printf '
Report\n\n```\n' + printf '%s\n' "$REPORT" | head -c 55000 + printf '```\n\n
\n' + ) + else + BODY="${MARKER}"$'\n### API contract changes\n\nNone. Nothing a client generated from the previous document would notice.' + fi + + ID=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ + --jq "[.[] | select(.body | startswith(\"${MARKER}\")) | .id] | first // empty") + + if [ -n "$ID" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${ID}" -f body="$BODY" >/dev/null + echo "Updated comment ${ID}" + else + gh api -X POST "repos/${REPO}/issues/${PR}/comments" -f body="$BODY" >/dev/null + echo "Created comment" + fi + # Run frontend tests and build frontend: name: Frontend diff --git a/Makefile b/Makefile index d89e76590..929064ba4 100644 --- a/Makefile +++ b/Makefile @@ -597,6 +597,10 @@ release-prepare: ## Prepare a release (usage: make release-prepare VERSION=1.0.0 @$(MAKE) openapi-all @echo "$(GREEN)โœ“$(NC) Regenerated OpenAPI spec and TypeScript types" + @# Check the bump against what actually changed in the API + @echo "$(YELLOW)Checking the version bump against the API contract...$(NC)"; + @./scripts/check-release-bump.sh $(VERSION) || true + @# Generate changelog (skip if already modified) @echo "$(YELLOW)Generating CHANGELOG.md...$(NC)"; @if git diff --quiet CHANGELOG.md 2>/dev/null && git diff --cached --quiet CHANGELOG.md 2>/dev/null; then \ diff --git a/cliff.toml b/cliff.toml index 1b713122e..78cb53dba 100644 --- a/cliff.toml +++ b/cliff.toml @@ -10,6 +10,11 @@ header = """ All notable changes to Codex will be documented in this file. +**API compatibility.** API features ship in minor releases and are never backported into a patch +release. A patch release may fix an API bug; it may not add an operation, a parameter, a response +field, or a new behaviour on an existing route. A client may therefore treat the release a feature +first appears under in the ๐Ÿ”Œ API sections below as a floor it can rely on. + """ body = """ {% if version %}\ @@ -67,22 +72,34 @@ protect_breaking_commits = false # An array of regex based parsers for extracting data from the commit message. # Assigns commits to groups. # Optionally sets the commit's scope and can decide to exclude commits from further processing. +# Ordering keys are zero-padded because groups sort as strings: an unpadded +# "" sorts between 1 and 2, which is why "Other" used to appear third. +# +# The API parsers come first, and therefore win over the type-based groups below, +# because the audience for an API change is a client author regenerating against +# openapi.json, and they need it separated from web and config work rather than +# interleaved with it. Scoped to the types that can move the wire: a `test(api)` +# or `style(api)` commit changes nothing a client can observe. The scope pattern +# matches `api` as a whole element of a comma-separated scope list, so +# `feat(api, db)` counts and `feat(komga-api)` does not. commit_parsers = [ - { message = "^feat", group = "๐Ÿš€ Features" }, - { message = "^fix", group = "๐Ÿ› Bug Fixes" }, - { message = "^doc", group = "๐Ÿ“š Documentation" }, - { message = "^perf", group = "โšก Performance" }, - { message = "^refactor", group = "๐Ÿšœ Refactor" }, - { message = "^style", group = "๐ŸŽจ Styling" }, - { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^(feat|fix|docs?|perf|refactor)\\((?:[a-z0-9_-]+, *)*api(?:, *[a-z0-9_-]+)*\\)!", group = "๐Ÿ”Œ API (breaking)" }, + { message = "^(feat|fix|docs?|perf|refactor)\\((?:[a-z0-9_-]+, *)*api(?:, *[a-z0-9_-]+)*\\)", group = "๐Ÿ”Œ API" }, + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, { message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(deps.*\\)", skip = true }, { message = "^chore\\(pr\\)", skip = true }, { message = "^chore\\(pull\\)", skip = true }, - { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, - { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, - { message = "^revert", group = "โ—€๏ธ Revert" }, - { message = ".*", group = "๐Ÿ’ผ Other" }, + { message = "^chore|^ci|^build", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, + { message = ".*", group = "๐Ÿ’ผ Other" }, ] # Exclude commits that are not matched by any commit parser. filter_commits = false diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 000000000..2831d36c3 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,73 @@ +# Contracts + +Behavioural contracts that more than one Codex client has to implement identically. + +Unlike `docs/api/openapi.json`, nothing here is generated from the server. These files describe +behaviour that lives in the *clients* but whose output lands in shared server data, so if two +clients implement it differently the data they produce silently stops meaning one thing. + +Each file is hand-authored, versioned by a `version` field, and consumed as data by a test suite in +every repo that implements it. A change to the definition is therefore a reviewable diff, and a +client that has not adopted it fails its own suite. + +| File | Implemented by | Consumed by | +| --- | --- | --- | +| [`reading-sessions.json`](./reading-sessions.json) | web reader, codex-reader-ios | `web/src/lib/reading/ReadingSessionTracker.test.ts` | + +## `reading-sessions.json` + +The measurement rules behind `POST /api/v1/reading-sessions`. Aggregate reading time is summed +across every device a reader uses, so "active time" has to mean the same thing on all of them. If +one client idles at two minutes and another at five, the total is a blend of two metrics and means +nothing. + +### What it constrains, and what it does not + +**Constrained, exactly:** the state machine and its arithmetic. Given the event sequence in a case, +with those timestamps, a conformant tracker produces exactly the sessions the case lists: the same +count, the same kinds, the same order, the same `activeDurationMs`, `pagesRead`, and position. The +same holds for what survives a crash. + +**Not constrained:** which platform occurrence produces which event. The web reader decides that +`visibilitychange` means `pause`; the native client decides that a `scenePhase` transition does. +That mapping is each client's business, and a native client with real lifecycle callbacks is free to +be more precise about *when* it emits `pause` than the web reader can be. Also unconstrained: how a +checkpoint is persisted, and what an id looks like beyond being unique within a case. + +That line is where the earlier open question lands. Pause and resume *are* in the contract, because +they are modelled as events rather than as platform occurrences. What a client detects, and how +promptly, is latitude. What it does once it has detected it is not. + +### Schema + +- `version` โ€” bump on any change to a threshold, an event's meaning, or a case's expectation. +- `subject` โ€” the book, device, and device name every case runs against, unless a case overrides + `bookId`. +- `thresholds` โ€” `idleTimeoutMs` and `checkpointIntervalMs`, carried here rather than assumed, so a + change to the definition of "active" shows up in this file. +- `invariants` โ€” properties asserted on every case in addition to its own expectations. +- `cases[]`: + - `name`, `group`, optional `why`. + - `bookId` โ€” overrides `subject.bookId`. + - `persistence: "unavailable"` โ€” run this case with a store that cannot be written. How the runner + arranges that is platform-specific. + - `seedCheckpoints[]` โ€” `{bookId, raw}` written into the store before the case runs, to model a + checkpoint left by an older or broken write. + - `events[]` โ€” `atMs` is absolute from the start of the case, so a runner drives a fake clock to + each timestamp in turn. Kinds: `start`, `activity` (both take optional `page` or `percentage`), + `pause`, `resume`, `stop`, `complete` (optional position), `reset`, `checkpoint` (persist + without closing), `crash` (the process dies with no chance to close), `recover` (run orphan + recovery, appending its result to `recoveries`). + - `expect`: + - `sessions[]` โ€” emitted sessions in order, in the wire shape of the endpoint. A field set to + `null` must be **absent** from the payload. `spanMs`, where present, is + `clientEndedAt - clientStartedAt`. Fields not listed are not asserted. + - `recoveries[]` โ€” one entry per `recover` event, each an array of recovered sessions. + - `totalActiveDurationMs` โ€” summed across `sessions`, where the point of the case is the total. + - `tracking` โ€” whether a session is still open at the end. + - `checkpointedBookIds` โ€” book ids with a checkpoint waiting, sorted. + +### Adding a case + +Add it here first, then make both suites pass. A case whose events duplicate an existing case +belongs as extra expectations on that case rather than as a new row. diff --git a/contracts/reading-sessions.json b/contracts/reading-sessions.json new file mode 100644 index 000000000..59d45d37f --- /dev/null +++ b/contracts/reading-sessions.json @@ -0,0 +1,575 @@ +{ + "$comment": "The shared behavioural contract for reading-session measurement. See contracts/README.md.", + "version": 1, + "subject": { + "bookId": "book-1", + "deviceId": "device-1", + "deviceName": "Test Device" + }, + "thresholds": { + "idleTimeoutMs": 300000, + "checkpointIntervalMs": 30000 + }, + "invariants": [ + { + "name": "active time never exceeds the session span", + "rule": "For every session a case produces, activeDurationMs <= clientEndedAt - clientStartedAt." + }, + { + "name": "timestamps are ordered", + "rule": "For every session a case produces, clientEndedAt >= clientStartedAt." + }, + { + "name": "session ids are distinct", + "rule": "Within a single case, no two produced sessions share an id." + } + ], + "cases": [ + { + "name": "accumulates time between activity events", + "group": "measuring active time", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 60000, "page": 2 }, + { "kind": "activity", "atMs": 120000, "page": 3 }, + { "kind": "stop", "atMs": 120000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 120000, + "toPage": 3, + "pagesRead": 3 + } + ] + } + }, + { + "name": "a gap longer than the idle timeout contributes no active time", + "group": "measuring active time", + "why": "A book left open on the nightstand must not read as an hour of reading.", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 60000, "page": 2 }, + { "kind": "activity", "atMs": 3660000, "page": 3 }, + { "kind": "stop", "atMs": 3660000 } + ], + "expect": { + "totalActiveDurationMs": 60000, + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 2, + "pagesRead": 2 + }, + { + "kind": "progress", + "activeDurationMs": null, + "toPage": 3, + "pagesRead": 1 + } + ] + } + }, + { + "name": "an idle gap splits one sitting into separate sessions", + "group": "measuring active time", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 60000, "page": 2 }, + { "kind": "activity", "atMs": 420000, "page": 3 }, + { "kind": "activity", "atMs": 540000, "page": 4 }, + { "kind": "stop", "atMs": 540000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 2, + "pagesRead": 2 + }, + { + "kind": "progress", + "activeDurationMs": 120000, + "toPage": 4, + "pagesRead": 2 + } + ] + } + }, + { + "name": "a long but sub-timeout dwell on one page is reading", + "group": "measuring active time", + "why": "Four minutes on a dense page is reading, not idling.", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 240000, "page": 2 }, + { "kind": "stop", "atMs": 240000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 240000, + "toPage": 2, + "pagesRead": 2 + } + ] + } + }, + { + "name": "the clock stops while paused", + "group": "measuring active time", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "pause", "atMs": 60000 }, + { "kind": "resume", "atMs": 240000 }, + { "kind": "activity", "atMs": 300000, "page": 2 }, + { "kind": "stop", "atMs": 300000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 120000, + "toPage": 2, + "pagesRead": 2 + } + ] + } + }, + { + "name": "a pause outlasting the idle timeout closes the session", + "group": "measuring active time", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "pause", "atMs": 60000 }, + { "kind": "resume", "atMs": 420000 } + ], + "expect": { + "tracking": false, + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1 + } + ] + } + }, + { + "name": "a stop after the session already closed is a no-op", + "group": "measuring active time", + "why": "Pins the span invariant against the longest gap a session can carry.", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "pause", "atMs": 60000 }, + { "kind": "resume", "atMs": 660000 }, + { "kind": "stop", "atMs": 720000 } + ], + "expect": { + "tracking": false, + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1 + } + ] + } + }, + { + "name": "the last position reached is reported", + "group": "position and pages", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 60000, "page": 40 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 40, + "pagesRead": 2 + } + ] + } + }, + { + "name": "a deliberate rewind is the final position", + "group": "position and pages", + "events": [ + { "kind": "start", "atMs": 0, "page": 50 }, + { "kind": "activity", "atMs": 60000, "page": 49 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 49, + "pagesRead": 2 + } + ] + } + }, + { + "name": "distinct pages are counted, not page events", + "group": "position and pages", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 60000, "page": 2 }, + { "kind": "activity", "atMs": 60000, "page": 3 }, + { "kind": "activity", "atMs": 60000, "page": 2 }, + { "kind": "activity", "atMs": 60000, "page": 3 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 3, + "pagesRead": 3 + } + ] + } + }, + { + "name": "a reflowable position is a percentage rather than a page", + "group": "position and pages", + "events": [ + { "kind": "start", "atMs": 0, "percentage": 0.1 }, + { "kind": "activity", "atMs": 60000, "percentage": 0.42 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPercentage": 0.42, + "toPage": null, + "pagesRead": null + } + ] + } + }, + { + "name": "completion is its own session kind and closes tracking", + "group": "completion and reset", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "complete", "atMs": 60000, "page": 100 } + ], + "expect": { + "tracking": false, + "sessions": [ + { + "kind": "completed", + "activeDurationMs": 60000, + "toPage": 100, + "pagesRead": 1 + } + ] + } + }, + { + "name": "a reset is emitted as its own event after the running session", + "group": "completion and reset", + "why": "Sent as an event rather than a deletion so the server can order it against a completion made on another device.", + "events": [ + { "kind": "start", "atMs": 0, "page": 20 }, + { "kind": "reset", "atMs": 60000 } + ], + "expect": { + "tracking": false, + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 20, + "pagesRead": 1 + }, + { + "kind": "reset", + "activeDurationMs": null, + "toPage": null, + "pagesRead": null + } + ] + } + }, + { + "name": "a session with nothing to report is not emitted", + "group": "session hygiene", + "events": [ + { "kind": "start", "atMs": 0 }, + { "kind": "stop", "atMs": 0 } + ], + "expect": { + "tracking": false, + "sessions": [] + } + }, + { + "name": "a repeated start does not open a second session", + "group": "session hygiene", + "why": "A re-render must not fragment a sitting.", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1 + } + ] + } + }, + { + "name": "a resume without a preceding pause is ignored", + "group": "session hygiene", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "resume", "atMs": 60000 }, + { "kind": "stop", "atMs": 120000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 120000, + "toPage": 1, + "pagesRead": 1 + } + ] + } + }, + { + "name": "consecutive sessions get distinct ids", + "group": "session hygiene", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "stop", "atMs": 60000 }, + { "kind": "start", "atMs": 60000, "page": 2 }, + { "kind": "stop", "atMs": 120000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1 + }, + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 2, + "pagesRead": 1 + } + ] + } + }, + { + "name": "a plain session is bracketed by its own timestamps", + "group": "session hygiene", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1, + "spanMs": 60000 + } + ] + } + }, + { + "name": "a checkpoint exists once an interval of active time has passed", + "group": "checkpointing and crash recovery", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 31000, "page": 2 } + ], + "expect": { + "tracking": true, + "checkpointedBookIds": ["book-1"], + "sessions": [] + } + }, + { + "name": "a session left behind by a crash is recovered", + "group": "checkpointing and crash recovery", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 120000, "page": 12 }, + { "kind": "checkpoint", "atMs": 120000 }, + { "kind": "crash", "atMs": 120000 }, + { "kind": "recover", "atMs": 900000 } + ], + "expect": { + "sessions": [], + "recoveries": [ + [ + { + "kind": "progress", + "activeDurationMs": 120000, + "toPage": 12, + "pagesRead": 2 + } + ] + ] + } + }, + { + "name": "a recovered session ends at its checkpoint, not at recovery time", + "group": "checkpointing and crash recovery", + "why": "Counting the time the app was shut would inflate reading time by however long that was.", + "events": [ + { "kind": "start", "atMs": 0, "page": 5 }, + { "kind": "activity", "atMs": 60000, "page": 5 }, + { "kind": "checkpoint", "atMs": 60000 }, + { "kind": "crash", "atMs": 60000 }, + { "kind": "recover", "atMs": 86400000 } + ], + "expect": { + "sessions": [], + "recoveries": [ + [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 5, + "pagesRead": 1, + "spanMs": 60000 + } + ] + ] + } + }, + { + "name": "a crash loses at most one checkpoint interval of active time", + "group": "checkpointing and crash recovery", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "activity", "atMs": 30000, "page": 2 }, + { "kind": "crash", "atMs": 40000 }, + { "kind": "recover", "atMs": 40000 } + ], + "expect": { + "sessions": [], + "recoveries": [ + [ + { + "kind": "progress", + "activeDurationMs": 30000, + "toPage": 2, + "pagesRead": 2 + } + ] + ] + } + }, + { + "name": "a clean close clears the checkpoint so nothing is double-counted", + "group": "checkpointing and crash recovery", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "stop", "atMs": 60000 }, + { "kind": "recover", "atMs": 60000 } + ], + "expect": { + "checkpointedBookIds": [], + "recoveries": [[]], + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1 + } + ] + } + }, + { + "name": "a recovered checkpoint is consumed so it cannot be recovered twice", + "group": "checkpointing and crash recovery", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "checkpoint", "atMs": 60000 }, + { "kind": "crash", "atMs": 60000 }, + { "kind": "recover", "atMs": 60000 }, + { "kind": "recover", "atMs": 60000 } + ], + "expect": { + "sessions": [], + "checkpointedBookIds": [], + "recoveries": [ + [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1, + "spanMs": 60000 + } + ], + [] + ] + } + }, + { + "name": "an unreadable checkpoint is discarded rather than retried forever", + "group": "checkpointing and crash recovery", + "bookId": "book-9", + "seedCheckpoints": [{ "bookId": "book-9", "raw": "{not json" }], + "events": [{ "kind": "recover", "atMs": 0 }], + "expect": { + "sessions": [], + "checkpointedBookIds": [], + "recoveries": [[]] + } + }, + { + "name": "measurement is unaffected when persistence is unavailable", + "group": "degraded environments", + "why": "Losing crash recovery is acceptable. Losing the session in front of the reader is not.", + "persistence": "unavailable", + "events": [ + { "kind": "start", "atMs": 0, "page": 1 }, + { "kind": "stop", "atMs": 60000 } + ], + "expect": { + "checkpointedBookIds": [], + "sessions": [ + { + "kind": "progress", + "activeDurationMs": 60000, + "toPage": 1, + "pagesRead": 1 + } + ] + } + } + ] +} diff --git a/crates/codex-api/src/routes/v1/dto/user_preferences.rs b/crates/codex-api/src/routes/v1/dto/user_preferences.rs index f5ee50a1b..f328943f9 100644 --- a/crates/codex-api/src/routes/v1/dto/user_preferences.rs +++ b/crates/codex-api/src/routes/v1/dto/user_preferences.rs @@ -12,7 +12,7 @@ use codex_db::repositories::UserPreferencesRepository; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserPreferenceDto { - /// The preference key (e.g., "ui.theme", "reader.zoom") + /// The preference key (e.g., "ui.theme", "library.show_deleted_books") #[schema(example = "ui.theme")] pub key: String, @@ -65,7 +65,7 @@ pub struct SetPreferenceRequest { #[serde(rename_all = "camelCase")] pub struct BulkSetPreferencesRequest { /// Map of preference keys to values - #[schema(example = json!({"ui.theme": "dark", "reader.zoom": 150}))] + #[schema(example = json!({"ui.theme": "dark", "library.show_deleted_books": true}))] pub preferences: HashMap, } diff --git a/crates/codex-api/src/routes/v1/handlers/user_preferences.rs b/crates/codex-api/src/routes/v1/handlers/user_preferences.rs index 04194a3ea..b45880fb8 100644 --- a/crates/codex-api/src/routes/v1/handlers/user_preferences.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_preferences.rs @@ -38,6 +38,28 @@ use utoipa::OpenApi; pub struct UserPreferencesApi; /// Get all preferences for the authenticated user +/// +/// The store is an open `key -> JSON` map: any syntactically valid key is +/// accepted, and this endpoint returns whatever the user has set. The list +/// below is not a whitelist, it is the set Codex's own clients read and write. +/// A client that wants a user's settings to follow them between devices has to +/// use these exact keys and value shapes. +/// +/// | Key | Value | +/// | --- | --- | +/// | `ui.theme` | `"light"`, `"dark"`, or `"system"` (default `"system"`) | +/// | `library.show_deleted_books` | boolean (default `false`) | +/// | `want_to_read.sort` | `"newest"`, `"oldest"`, or `"custom"` (default `"newest"`) | +/// | `release_tracking.muted_series_ids` | array of series id strings (default `[]`) | +/// +/// Keys are `snake_case`, matching the server settings store rather than the +/// camelCase of the JSON fields around them: a key is a value in a database +/// column, not a field name. +/// +/// Reader settings are deliberately not here. Fit mode, reading direction, +/// zoom, and per-series reader overrides are device-local state, held by each +/// client and never synced, because a phone and a desktop legitimately want +/// different ones. #[utoipa::path( get, path = "/api/v1/user/preferences", @@ -236,7 +258,7 @@ pub async fn delete_preference( } /// Validate a preference key format -/// Valid: "ui.theme", "reader.default_zoom", "library.view_mode" +/// Valid: "ui.theme", "library.show_deleted_books", "release_tracking.muted_series_ids" /// Invalid: ".theme", "ui.", "ui..theme", "ui/theme", "ui theme" fn is_valid_preference_key(key: &str) -> bool { if key.is_empty() || key.len() > 255 { @@ -270,8 +292,8 @@ mod tests { #[test] fn test_valid_preference_keys() { assert!(is_valid_preference_key("ui.theme")); - assert!(is_valid_preference_key("reader.default_zoom")); - assert!(is_valid_preference_key("library.view_mode")); + assert!(is_valid_preference_key("library.show_deleted_books")); + assert!(is_valid_preference_key("release_tracking.muted_series_ids")); assert!(is_valid_preference_key("single_key")); assert!(is_valid_preference_key("deep.nested.key.value")); assert!(is_valid_preference_key("with_underscore.another_one")); diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 55aa7d199..86366514c 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -17853,6 +17853,7 @@ "User Preferences" ], "summary": "Get all preferences for the authenticated user", + "description": "The store is an open `key -> JSON` map: any syntactically valid key is\naccepted, and this endpoint returns whatever the user has set. The list\nbelow is not a whitelist, it is the set Codex's own clients read and write.\nA client that wants a user's settings to follow them between devices has to\nuse these exact keys and value shapes.\n\n| Key | Value |\n| --- | --- |\n| `ui.theme` | `\"light\"`, `\"dark\"`, or `\"system\"` (default `\"system\"`) |\n| `library.show_deleted_books` | boolean (default `false`) |\n| `want_to_read.sort` | `\"newest\"`, `\"oldest\"`, or `\"custom\"` (default `\"newest\"`) |\n| `release_tracking.muted_series_ids` | array of series id strings (default `[]`) |\n\nKeys are `snake_case`, matching the server settings store rather than the\ncamelCase of the JSON fields around them: a key is a value in a database\ncolumn, not a field name.\n\nReader settings are deliberately not here. Fit mode, reading direction,\nzoom, and per-series reader overrides are device-local state, held by each\nclient and never synced, because a phone and a desktop legitimately want\ndifferent ones.", "operationId": "get_all_preferences", "responses": { "200": { @@ -26985,7 +26986,7 @@ "type": "string" }, "example": { - "reader.zoom": 150, + "library.show_deleted_books": true, "ui.theme": "dark" } } @@ -45777,7 +45778,7 @@ "properties": { "key": { "type": "string", - "description": "The preference key (e.g., \"ui.theme\", \"reader.zoom\")", + "description": "The preference key (e.g., \"ui.theme\", \"library.show_deleted_books\")", "example": "ui.theme" }, "updatedAt": { diff --git a/scripts/check-release-bump.sh b/scripts/check-release-bump.sh new file mode 100755 index 000000000..ca6a969cc --- /dev/null +++ b/scripts/check-release-bump.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Advisory: does this version bump match what actually changed in the API? +# +# Compares the freshly regenerated spec against the previous tag's and reports +# whether the bump is consistent with the changes found. +# +# Deliberately never fails. By the time this runs the work is merged, and a +# breaking change is sometimes exactly what was intended. The point is that the +# number on the tin is a decision rather than a habit. +# +# Note that oasdiff compares the *document*, not the server. Correcting a spec +# that described an endpoint wrongly reads as a breaking change here even though +# no running client changes behaviour, because a client generated from the old +# document really would see something different. + +set -uo pipefail + +VERSION="${1:-}" +# Optional: compare against this ref instead of the latest tag. Only needed to +# re-check a past release, or to test this script. +BASE_REF="${2:-}" +SPEC="web/openapi.json" + +if [ -z "$VERSION" ]; then + echo "usage: $0 [base-ref]" >&2 + exit 0 +fi + +if ! command -v oasdiff >/dev/null 2>&1; then + echo " oasdiff not installed, skipping the API bump check (brew install oasdiff)" + exit 0 +fi + +PREV_TAG="${BASE_REF:-$(git describe --tags --abbrev=0 2>/dev/null)}" +if [ -z "$PREV_TAG" ]; then + echo " No previous tag, skipping the API bump check" + exit 0 +fi + +if ! git cat-file -e "${PREV_TAG}:${SPEC}" 2>/dev/null; then + echo " ${PREV_TAG} has no ${SPEC}, skipping the API bump check" + exit 0 +fi + +# What kind of bump is this? +PREV_VERSION="${PREV_TAG#v}" +IFS=. read -r prev_major prev_minor prev_patch </dev/null) +ERRORS=$(printf '%s' "$REPORT" | grep -c '^error') +WARNINGS=$(printf '%s' "$REPORT" | grep -c '^warning') + +echo " ${PREV_TAG} -> v${VERSION} is a ${BUMP} bump" +echo " API contract: ${ERRORS} breaking, ${WARNINGS} warnings" + +if [ "$ERRORS" -gt 0 ] && [ "$BUMP" != "major" ]; then + echo "" + echo " โš  ${ERRORS} breaking changes in a ${BUMP} release." + echo " A client generated against ${PREV_TAG} may stop working." + printf '%s' "$REPORT" | grep -A1 '^error' | grep 'in API' | sed 's/^[[:space:]]*/ /' | sort -u + echo "" + echo " Intentional? Nothing to do, but say so in the release notes." + echo " Unintentional? Consider a major bump, or revert the change." +elif [ "$ERRORS" -gt 0 ]; then + echo " โœ“ Breaking changes present, and this is a major bump" +elif [ "$WARNINGS" -gt 0 ] && [ "$BUMP" = "patch" ]; then + echo "" + echo " โš  ${WARNINGS} contract changes in a patch release." + echo " Patches should not move the API: clients decide what a server" + echo " supports from the release a feature first appeared in." +else + echo " โœ“ Bump is consistent with the API changes" +fi + +echo "" +echo " Full report: oasdiff breaking ${PREV_TAG}:${SPEC} ${SPEC}" +exit 0 diff --git a/web/openapi.json b/web/openapi.json index 55aa7d199..86366514c 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -17853,6 +17853,7 @@ "User Preferences" ], "summary": "Get all preferences for the authenticated user", + "description": "The store is an open `key -> JSON` map: any syntactically valid key is\naccepted, and this endpoint returns whatever the user has set. The list\nbelow is not a whitelist, it is the set Codex's own clients read and write.\nA client that wants a user's settings to follow them between devices has to\nuse these exact keys and value shapes.\n\n| Key | Value |\n| --- | --- |\n| `ui.theme` | `\"light\"`, `\"dark\"`, or `\"system\"` (default `\"system\"`) |\n| `library.show_deleted_books` | boolean (default `false`) |\n| `want_to_read.sort` | `\"newest\"`, `\"oldest\"`, or `\"custom\"` (default `\"newest\"`) |\n| `release_tracking.muted_series_ids` | array of series id strings (default `[]`) |\n\nKeys are `snake_case`, matching the server settings store rather than the\ncamelCase of the JSON fields around them: a key is a value in a database\ncolumn, not a field name.\n\nReader settings are deliberately not here. Fit mode, reading direction,\nzoom, and per-series reader overrides are device-local state, held by each\nclient and never synced, because a phone and a desktop legitimately want\ndifferent ones.", "operationId": "get_all_preferences", "responses": { "200": { @@ -26985,7 +26986,7 @@ "type": "string" }, "example": { - "reader.zoom": 150, + "library.show_deleted_books": true, "ui.theme": "dark" } } @@ -45777,7 +45778,7 @@ "properties": { "key": { "type": "string", - "description": "The preference key (e.g., \"ui.theme\", \"reader.zoom\")", + "description": "The preference key (e.g., \"ui.theme\", \"library.show_deleted_books\")", "example": "ui.theme" }, "updatedAt": { diff --git a/web/src/lib/reading/ReadingSessionTracker.test.ts b/web/src/lib/reading/ReadingSessionTracker.test.ts index f22de71e0..52ef3f064 100644 --- a/web/src/lib/reading/ReadingSessionTracker.test.ts +++ b/web/src/lib/reading/ReadingSessionTracker.test.ts @@ -1,24 +1,73 @@ /** - * The shared case table for reading-session measurement. + * Drives the tracker through the shared reading-session case table. * - * The iOS client implements the same state machine and is tested against this - * same list of cases. If a case changes here it has to change there too, or - * aggregate reading time silently becomes a blend of two different metrics. + * The cases are not written here. They live in `contracts/reading-sessions.json` + * at the repository root, and the iOS client is tested against that same file. + * If a case changes it changes for both, or aggregate reading time silently + * becomes a blend of two different metrics. + * + * Only the mapping from a case's events onto this tracker's API belongs in this + * file, along with the browser-specific storage failures the contract + * deliberately leaves to each platform. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import contractDocument from "../../../../contracts/reading-sessions.json"; import { DEFAULT_CHECKPOINT_INTERVAL_MS, DEFAULT_IDLE_TIMEOUT_MS, listCheckpointedBookIds, + type ReadingPosition, type ReadingSessionPayload, ReadingSessionTracker, recoverOrphanedSessions, } from "./ReadingSessionTracker"; -const BOOK = "book-1"; -const DEVICE = "device-1"; -const MINUTE = 60 * 1000; +const CHECKPOINT_KEY_PREFIX = "codex.reading.session."; + +interface ContractEvent extends ReadingPosition { + kind: + | "start" + | "activity" + | "pause" + | "resume" + | "stop" + | "complete" + | "reset" + | "checkpoint" + | "crash" + | "recover"; + atMs: number; +} + +/** A `null` means the field must be absent; `spanMs` is derived from the timestamps. */ +type ExpectedSession = Record; + +interface ContractCase { + name: string; + group: string; + why?: string; + bookId?: string; + persistence?: "unavailable"; + seedCheckpoints?: { bookId: string; raw: string }[]; + events: ContractEvent[]; + expect: { + sessions?: ExpectedSession[]; + recoveries?: ExpectedSession[][]; + totalActiveDurationMs?: number; + tracking?: boolean; + checkpointedBookIds?: string[]; + }; +} + +interface Contract { + version: number; + subject: { bookId: string; deviceId: string; deviceName: string }; + thresholds: { idleTimeoutMs: number; checkpointIntervalMs: number }; + cases: ContractCase[]; +} + +const contract = contractDocument as unknown as Contract; /** An in-memory Storage, so tests never depend on jsdom's localStorage state. */ function memoryStorage(): Storage { @@ -39,434 +88,261 @@ function memoryStorage(): Storage { } as Storage; } -function setup(overrides: { idleTimeoutMs?: number } = {}) { +interface CaseResult { + emitted: ReadingSessionPayload[]; + recoveries: ReadingSessionPayload[][]; + tracking: boolean; + checkpointedBookIds: string[]; +} + +function runCase(testCase: ContractCase): CaseResult { + const bookId = testCase.bookId ?? contract.subject.bookId; + const storage = + testCase.persistence === "unavailable" ? null : memoryStorage(); + + for (const seed of testCase.seedCheckpoints ?? []) { + storage?.setItem(`${CHECKPOINT_KEY_PREFIX}${seed.bookId}`, seed.raw); + } + let clock = 0; let counter = 0; const emitted: ReadingSessionPayload[] = []; - const storage = memoryStorage(); + const recoveries: ReadingSessionPayload[][] = []; - const tracker = new ReadingSessionTracker({ - bookId: BOOK, - deviceId: DEVICE, - deviceName: "Test Device", + let tracker: ReadingSessionTracker | null = new ReadingSessionTracker({ + bookId, + deviceId: contract.subject.deviceId, + deviceName: contract.subject.deviceName, emit: (sessions) => emitted.push(...sessions), now: () => clock, newId: () => `session-${++counter}`, storage, - ...overrides, + idleTimeoutMs: contract.thresholds.idleTimeoutMs, + checkpointIntervalMs: contract.thresholds.checkpointIntervalMs, }); + for (const event of testCase.events) { + clock = event.atMs; + const position: ReadingPosition = {}; + if (typeof event.page === "number") position.page = event.page; + if (typeof event.percentage === "number") { + position.percentage = event.percentage; + } + + switch (event.kind) { + case "start": + tracker?.start(position); + break; + case "activity": + tracker?.recordActivity(position); + break; + case "pause": + tracker?.pause(); + break; + case "resume": + tracker?.resume(); + break; + case "stop": + tracker?.stop(); + break; + case "complete": + tracker?.markCompleted(position); + break; + case "reset": + tracker?.markReset(); + break; + case "checkpoint": + tracker?.checkpointNow(); + break; + case "crash": + // The process dies with no chance to close: drop the tracker with the + // store exactly as its last checkpoint left it. + tracker = null; + break; + case "recover": + recoveries.push(recoverOrphanedSessions([bookId], storage)); + break; + } + } + return { - tracker, emitted, - storage, - advance: (ms: number) => { - clock += ms; - }, - at: () => clock, + recoveries, + tracking: tracker?.isTracking ?? false, + checkpointedBookIds: listCheckpointedBookIds(storage).sort(), }; } -describe("ReadingSessionTracker", () => { - beforeEach(() => { - vi.restoreAllMocks(); +function assertSessions( + actual: ReadingSessionPayload[], + expected: ExpectedSession[], + label: string, +): void { + expect(actual, `${label}: session count`).toHaveLength(expected.length); + + expected.forEach((expectation, index) => { + const session = actual[index] as unknown as Record; + const where = `${label}[${index}]`; + + for (const [field, value] of Object.entries(expectation)) { + if (field === "spanMs") { + const span = + new Date(actual[index].clientEndedAt).getTime() - + new Date(actual[index].clientStartedAt).getTime(); + expect(span, `${where}.spanMs`).toBe(value); + } else if (value === null) { + expect( + session[field], + `${where}.${field} must be absent`, + ).toBeUndefined(); + } else if (typeof value === "number" && !Number.isInteger(value)) { + expect(session[field] as number, `${where}.${field}`).toBeCloseTo( + value, + ); + } else { + expect(session[field], `${where}.${field}`).toBe(value); + } + } }); +} - describe("measuring active time", () => { - it("accumulates time between activity events", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.recordActivity({ page: 2 }); - advance(MINUTE); - tracker.recordActivity({ page: 3 }); - tracker.stop(); - - expect(emitted).toHaveLength(1); - expect(emitted[0].activeDurationMs).toBe(2 * MINUTE); - }); - - it("does not count a gap longer than the idle timeout", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.recordActivity({ page: 2 }); - - // Away for an hour, then back. The gap closes the session. - advance(60 * MINUTE); - tracker.recordActivity({ page: 3 }); - tracker.stop(); - - const total = emitted.reduce( - (sum, s) => sum + (s.activeDurationMs ?? 0), - 0, - ); - expect(total).toBe(MINUTE); - }); - - it("splits a sitting into separate sessions across an idle gap", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.recordActivity({ page: 2 }); - - advance(DEFAULT_IDLE_TIMEOUT_MS + MINUTE); - tracker.recordActivity({ page: 3 }); - advance(2 * MINUTE); - tracker.recordActivity({ page: 4 }); - tracker.stop(); - - expect(emitted).toHaveLength(2); - expect(emitted[0].activeDurationMs).toBe(MINUTE); - expect(emitted[1].activeDurationMs).toBe(2 * MINUTE); - }); - - it("counts a long but sub-timeout pause on one page as reading", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - // Four minutes on a dense page is reading, not idling. - advance(4 * MINUTE); - tracker.recordActivity({ page: 2 }); - tracker.stop(); - - expect(emitted[0].activeDurationMs).toBe(4 * MINUTE); - }); - - it("stops the clock while paused", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.pause(); - - advance(3 * MINUTE); // backgrounded - tracker.resume(); - - advance(MINUTE); - tracker.recordActivity({ page: 2 }); - tracker.stop(); - - expect(emitted[0].activeDurationMs).toBe(2 * MINUTE); - }); - - it("closes the session when a pause outlasts the idle timeout", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.pause(); - - advance(DEFAULT_IDLE_TIMEOUT_MS + MINUTE); - tracker.resume(); - - expect(emitted).toHaveLength(1); - expect(emitted[0].activeDurationMs).toBe(MINUTE); - expect(tracker.isTracking).toBe(false); - }); - - it("never reports more active time than the session's own span", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.pause(); - advance(10 * MINUTE); - tracker.resume(); - advance(MINUTE); - tracker.stop(); - - const session = emitted[0]; - const span = - new Date(session.clientEndedAt).getTime() - - new Date(session.clientStartedAt).getTime(); - expect(session.activeDurationMs ?? 0).toBeLessThanOrEqual(span); - }); - }); - - describe("position and pages", () => { - it("reports the last position reached", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.recordActivity({ page: 40 }); - tracker.stop(); - - expect(emitted[0].toPage).toBe(40); - }); - - it("reports a deliberate rewind as the final position", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 50 }); - advance(MINUTE); - tracker.recordActivity({ page: 49 }); - tracker.stop(); - - expect(emitted[0].toPage).toBe(49); - }); - - it("counts distinct pages rather than page events", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.recordActivity({ page: 2 }); - tracker.recordActivity({ page: 3 }); - tracker.recordActivity({ page: 2 }); // back - tracker.recordActivity({ page: 3 }); // forward again - tracker.stop(); - - expect(emitted[0].pagesRead).toBe(3); - }); - - it("carries an EPUB percentage instead of a page", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ percentage: 0.1 }); - advance(MINUTE); - tracker.recordActivity({ percentage: 0.42 }); - tracker.stop(); +function assertInvariants(sessions: ReadingSessionPayload[]): void { + const ids = new Set(); + + for (const session of sessions) { + const startedAt = new Date(session.clientStartedAt).getTime(); + const endedAt = new Date(session.clientEndedAt).getTime(); + + expect(endedAt, "clientEndedAt >= clientStartedAt").toBeGreaterThanOrEqual( + startedAt, + ); + expect( + session.activeDurationMs ?? 0, + "activeDurationMs <= session span", + ).toBeLessThanOrEqual(endedAt - startedAt); + + expect(ids.has(session.id), `duplicate session id ${session.id}`).toBe( + false, + ); + ids.add(session.id); + } +} - expect(emitted[0].toPercentage).toBeCloseTo(0.42); - expect(emitted[0].toPage).toBeUndefined(); - }); +describe("reading-session contract", () => { + beforeEach(() => { + vi.restoreAllMocks(); }); - describe("completion and reset", () => { - it("emits a completed session", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.markCompleted({ page: 100 }); - - expect(emitted).toHaveLength(1); - expect(emitted[0].kind).toBe("completed"); - expect(emitted[0].toPage).toBe(100); - }); - - it("emits a reset as its own event", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 20 }); - advance(MINUTE); - tracker.markReset(); - - expect(emitted).toHaveLength(2); - expect(emitted[0].kind).toBe("progress"); - expect(emitted[1].kind).toBe("reset"); - }); - - it("closes tracking after completing", () => { - const { tracker, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.markCompleted({ page: 100 }); - - expect(tracker.isTracking).toBe(false); - }); + it("is the version the tracker's defaults were written for", () => { + expect(contract.version).toBe(1); + expect(contract.thresholds.idleTimeoutMs).toBe(DEFAULT_IDLE_TIMEOUT_MS); + expect(contract.thresholds.checkpointIntervalMs).toBe( + DEFAULT_CHECKPOINT_INTERVAL_MS, + ); }); - describe("session hygiene", () => { - it("does not emit a session with nothing to report", () => { - const { tracker, emitted } = setup(); - - tracker.start(); - tracker.stop(); - - expect(emitted).toHaveLength(0); - }); - - it("does not start a second session on a repeated start", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.stop(); - - expect(emitted).toHaveLength(1); - }); - - it("ignores resume without a preceding pause", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.resume(); - advance(MINUTE); - tracker.stop(); - - expect(emitted[0].activeDurationMs).toBe(2 * MINUTE); + const groups = [...new Set(contract.cases.map((c) => c.group))]; + + for (const group of groups) { + describe(group, () => { + for (const testCase of contract.cases.filter((c) => c.group === group)) { + it(testCase.name, () => { + const result = runCase(testCase); + + if (testCase.expect.sessions) { + assertSessions( + result.emitted, + testCase.expect.sessions, + "sessions", + ); + } + + if (testCase.expect.recoveries) { + expect(result.recoveries, "recovery count").toHaveLength( + testCase.expect.recoveries.length, + ); + testCase.expect.recoveries.forEach((expected, index) => { + assertSessions( + result.recoveries[index], + expected, + `recoveries[${index}]`, + ); + }); + } + + if (testCase.expect.totalActiveDurationMs !== undefined) { + const total = result.emitted.reduce( + (sum, session) => sum + (session.activeDurationMs ?? 0), + 0, + ); + expect(total, "totalActiveDurationMs").toBe( + testCase.expect.totalActiveDurationMs, + ); + } + + if (testCase.expect.tracking !== undefined) { + expect(result.tracking, "tracking").toBe(testCase.expect.tracking); + } + + if (testCase.expect.checkpointedBookIds) { + expect(result.checkpointedBookIds, "checkpointedBookIds").toEqual( + testCase.expect.checkpointedBookIds, + ); + } + + assertInvariants([...result.emitted, ...result.recoveries.flat()]); + }); + } }); + } +}); - it("gives each session a distinct id", () => { - const { tracker, emitted, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.stop(); - - tracker.start({ page: 2 }); - advance(MINUTE); - tracker.stop(); - - expect(emitted[0].id).not.toBe(emitted[1].id); +/** + * The contract says measurement must survive a store it cannot write to, and + * leaves it to each platform to say how that happens. On the web there are two + * routes into it, and both have to land on the same behaviour. + */ +describe("browser storage failures", () => { + const MINUTE = 60 * 1000; + + function trackerWith(storage: Storage | null) { + const emitted: ReadingSessionPayload[] = []; + let clock = 0; + const tracker = new ReadingSessionTracker({ + bookId: contract.subject.bookId, + deviceId: contract.subject.deviceId, + emit: (sessions) => emitted.push(...sessions), + now: () => clock, + newId: () => "session-1", + storage, }); + return { tracker, emitted, advance: (ms: number) => (clock += ms) }; + } - it("emits ISO timestamps that bracket the session", () => { - const { tracker, emitted, advance } = setup(); + it("keeps measuring when storage is unavailable", () => { + const { tracker, emitted, advance } = trackerWith(null); - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.stop(); + tracker.start({ page: 1 }); + advance(MINUTE); + tracker.stop(); - const { clientStartedAt, clientEndedAt } = emitted[0]; - expect(new Date(clientEndedAt).getTime()).toBeGreaterThanOrEqual( - new Date(clientStartedAt).getTime(), - ); - }); + expect(emitted[0].activeDurationMs).toBe(MINUTE); }); - describe("checkpointing and crash recovery", () => { - it("checkpoints after an interval of active time", () => { - const { tracker, storage, advance } = setup(); - - tracker.start({ page: 1 }); - advance(DEFAULT_CHECKPOINT_INTERVAL_MS + 1000); - tracker.recordActivity({ page: 2 }); - - expect(listCheckpointedBookIds(storage)).toEqual([BOOK]); - }); - - it("recovers a session left by a tab that died", () => { - const { tracker, storage, advance } = setup(); - - tracker.start({ page: 1 }); - advance(2 * MINUTE); - tracker.recordActivity({ page: 12 }); - tracker.checkpointNow(); - - // The tab dies here: no stop(), no pagehide. - const recovered = recoverOrphanedSessions([BOOK], storage); - - expect(recovered).toHaveLength(1); - expect(recovered[0].toPage).toBe(12); - expect(recovered[0].activeDurationMs).toBe(2 * MINUTE); - }); - - it("closes a recovered session at its checkpoint, not at recovery time", () => { - const { tracker, storage, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.recordActivity({ page: 5 }); - tracker.checkpointNow(); - - const recovered = recoverOrphanedSessions([BOOK], storage); - const span = - new Date(recovered[0].clientEndedAt).getTime() - - new Date(recovered[0].clientStartedAt).getTime(); - - expect(span).toBe(MINUTE); - }); - - it("loses at most the time since the last checkpoint", () => { - const { tracker, storage, advance } = setup(); - - tracker.start({ page: 1 }); - advance(DEFAULT_CHECKPOINT_INTERVAL_MS); - tracker.recordActivity({ page: 2 }); // triggers a checkpoint - advance(10 * 1000); // unrecorded when the tab dies - - const recovered = recoverOrphanedSessions([BOOK], storage); - expect(recovered[0].activeDurationMs).toBe( - DEFAULT_CHECKPOINT_INTERVAL_MS, - ); - }); - - it("clears the checkpoint on a clean close so nothing is double-counted", () => { - const { tracker, storage, advance } = setup(); - - tracker.start({ page: 1 }); - advance(MINUTE); - tracker.stop(); - - expect(listCheckpointedBookIds(storage)).toEqual([]); - expect(recoverOrphanedSessions([BOOK], storage)).toEqual([]); - }); - - it("consumes a recovered checkpoint so it is not recovered twice", () => { - const { tracker, storage, advance } = setup(); + it("survives a storage that throws on write", () => { + const throwing = { + ...memoryStorage(), + setItem: () => { + throw new Error("QuotaExceededError"); + }, + } as unknown as Storage; + const { tracker, emitted, advance } = trackerWith(throwing); + expect(() => { tracker.start({ page: 1 }); advance(MINUTE); - tracker.checkpointNow(); - - expect(recoverOrphanedSessions([BOOK], storage)).toHaveLength(1); - expect(recoverOrphanedSessions([BOOK], storage)).toHaveLength(0); - }); - - it("discards an unparseable checkpoint rather than retrying forever", () => { - const storage = memoryStorage(); - storage.setItem("codex.reading.session.book-9", "{not json"); - - expect(recoverOrphanedSessions(["book-9"], storage)).toEqual([]); - expect(listCheckpointedBookIds(storage)).toEqual([]); - }); - }); - - describe("degraded environments", () => { - it("keeps measuring when storage is unavailable", () => { - const emitted: ReadingSessionPayload[] = []; - let clock = 0; - const tracker = new ReadingSessionTracker({ - bookId: BOOK, - deviceId: DEVICE, - emit: (s) => emitted.push(...s), - now: () => clock, - newId: () => "session-1", - storage: null, - }); - - tracker.start({ page: 1 }); - clock += MINUTE; tracker.stop(); - - expect(emitted[0].activeDurationMs).toBe(MINUTE); - }); - - it("survives a storage that throws on write", () => { - const throwing = { - ...memoryStorage(), - setItem: () => { - throw new Error("QuotaExceededError"); - }, - } as unknown as Storage; - - const emitted: ReadingSessionPayload[] = []; - let clock = 0; - const tracker = new ReadingSessionTracker({ - bookId: BOOK, - deviceId: DEVICE, - emit: (s) => emitted.push(...s), - now: () => clock, - newId: () => "session-1", - storage: throwing, - }); - - expect(() => { - tracker.start({ page: 1 }); - clock += MINUTE; - tracker.stop(); - }).not.toThrow(); - expect(emitted).toHaveLength(1); - }); + }).not.toThrow(); + expect(emitted).toHaveLength(1); }); }); diff --git a/web/src/lib/reading/ReadingSessionTracker.ts b/web/src/lib/reading/ReadingSessionTracker.ts index a33e31ae5..dbc918105 100644 --- a/web/src/lib/reading/ReadingSessionTracker.ts +++ b/web/src/lib/reading/ReadingSessionTracker.ts @@ -436,8 +436,12 @@ function toPayload( deviceId: checkpoint.deviceId, kind, clientStartedAt: new Date(checkpoint.startedAt).toISOString(), + // A session cannot end before the time it accrued. Recovery ends a session + // at its last recorded activity, and `checkpointNow` writes time that no + // activity followed, so without this floor a session rescued from a crash + // can report minutes of reading inside a zero-length span. clientEndedAt: new Date( - Math.max(endedAt, checkpoint.startedAt), + Math.max(endedAt, checkpoint.startedAt + checkpoint.activeMs), ).toISOString(), }; if (checkpoint.deviceName) payload.deviceName = checkpoint.deviceName; diff --git a/web/src/mocks/handlers/coverage.test.ts b/web/src/mocks/handlers/coverage.test.ts index db298a7b6..695ef366a 100644 --- a/web/src/mocks/handlers/coverage.test.ts +++ b/web/src/mocks/handlers/coverage.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { PREFERENCE_DEFAULTS } from "@/types/preferences"; import { handlers } from "./index"; +import { mockUserPreferences } from "./users"; /** * The mock handlers back `make frontend-mock`, the dev workflow that runs the @@ -43,4 +45,21 @@ describe("mock handler coverage", () => { expect(pattern).toBeGreaterThanOrEqual(0); expect(literal).toBeLessThan(pattern); }); + + /** + * The mocks used to serve `reader.fitMode`, `reader.readingDirection`, + * `library.defaultView`, `library.itemsPerPage`, `notifications.enabled`, and + * a bare `theme`. Not one of those keys exists. They read as documentation of + * what the preference store holds, and a server-side plan was written from + * them describing a reader-settings sync feature that does not exist. + * + * The preference keys are a cross-client contract: the PWA and the native + * client have to write the same ones or a user's settings stop following them + * between devices. `PREFERENCE_DEFAULTS` is where that set is declared, so the + * mocks answer to it rather than inventing their own. + */ + it("mocks exactly the preference keys the app actually uses", () => { + const mocked = mockUserPreferences.map((pref) => pref.key).sort(); + expect(mocked).toEqual(Object.keys(PREFERENCE_DEFAULTS).sort()); + }); }); diff --git a/web/src/mocks/handlers/users.ts b/web/src/mocks/handlers/users.ts index 2b33c09a1..5fa60a4d2 100644 --- a/web/src/mocks/handlers/users.ts +++ b/web/src/mocks/handlers/users.ts @@ -28,28 +28,40 @@ const mockUsers = [ ...createList(() => createUser(), 7), ]; -// Mock user preferences -const mockUserPreferences: Array<{ +/** + * Mock user preferences. + * + * The keys must be exactly those in `PREFERENCE_DEFAULTS`, and a test in + * `coverage.test.ts` enforces it. Preference keys are a cross-client contract: + * the PWA and the native client have to write the same ones or a user's + * settings stop following them between devices, and a fixture serving invented + * keys reads as documentation of a store that holds something it does not. + * + * Reader settings are deliberately absent. They are device-local state in + * `readerStore`, persisted to localStorage and never synced, so they are not + * preferences and never reach this endpoint. + * + * Values are chosen to exercise the UI rather than to match the defaults. + */ +export const mockUserPreferences: Array<{ key: string; value: unknown; updatedAt: string; }> = [ - { key: "theme", value: "system", updatedAt: "2024-01-01T00:00:00Z" }, + { key: "ui.theme", value: "dark", updatedAt: "2024-01-01T00:00:00Z" }, { - key: "library.defaultView", - value: "grid", + key: "library.show_deleted_books", + value: false, updatedAt: "2024-01-01T00:00:00Z", }, - { key: "library.itemsPerPage", value: 20, updatedAt: "2024-01-01T00:00:00Z" }, { - key: "reader.readingDirection", - value: "ltr", + key: "want_to_read.sort", + value: "custom", updatedAt: "2024-01-01T00:00:00Z", }, - { key: "reader.fitMode", value: "width", updatedAt: "2024-01-01T00:00:00Z" }, { - key: "notifications.enabled", - value: true, + key: "release_tracking.muted_series_ids", + value: ["series-3"], updatedAt: "2024-01-01T00:00:00Z", }, ];