From 6d3eded9426c453754a2a4fee5fb643c33b2df0e Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 18:14:55 +0200 Subject: [PATCH 01/18] Add ExecPlan for keyset pagination on GET /api/v1/users (4.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft the execution plan for backend roadmap task 4.2.1: replace the unpaginated `Vec` response on `GET /api/v1/users` with the workspace `pagination` crate's keyset envelope, ordered by `(created_at, id)` and backed by a new composite Diesel index. The plan splits the work into five gated milestones (crate dependency, schema migration, domain/port additions, Diesel adapter, handler plus behavioural tests), captures hexagonal-architecture constraints, records the design calls (additive `created_at` on `User`, new `UserCursorKey` in the domain layer, extended rather than replaced `UsersQuery` port), and signposts the relevant docs and skills. Status: DRAFT — implementation will not start until the plan is approved. --- ...-users-offset-pagination-with-new-crate.md | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md new file mode 100644 index 00000000..3ae415e1 --- /dev/null +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -0,0 +1,731 @@ +# Replace offset pagination on `GET /api/v1/users` with the keyset pagination crate + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: DRAFT + +## Purpose / big picture + +Roadmap task 4.2.1 directs us to retire the unpaginated `Vec` shape on +`GET /api/v1/users` and replace it with a keyset-paginated envelope built on +the workspace `pagination` crate (`backend/crates/pagination`). After this +change a session-authenticated client can issue +`GET /api/v1/users?limit=N` and follow opaque `next` and `prev` cursor links +through the entire ordered user set without the server ever performing a +`COUNT(*)` or `OFFSET` query. The page is ordered by `(created_at ASC, +id ASC)` so insertions during traversal cannot duplicate or skip records, and +the underlying SQL is index-assisted by a new composite index. Success is +observable in three ways: + +1. The handler returns the JSON envelope `{ "data": [...], "limit": N, + "links": { "self": "...", "next": "...", "prev": "..." } }` with omitted + keys when no further page exists. +2. Forward and backward cursor traversal returns every user exactly once + (BDD scenario passes against an embedded PostgreSQL fixture seeded with + more rows than fit on a single page). +3. `make check-fmt`, `make lint`, and `make test` all pass; the new BDD + feature exercises the full traversal path. + +The existing `DieselUsersQuery` only ever returns the caller's own row (it +delegates to `UserRepository::find_by_id`); after this work it must return a +true ordered slice of the users table. + +## Constraints + +These invariants come from `docs/wildside-backend-architecture.md`, +`docs/keyset-pagination-design.md`, and `AGENTS.md`. Violating any one of +them requires escalation, not a workaround. + +- The handler `backend/src/inbound/http/users.rs::list_users` must remain a + thin coordinator: parse query, call a domain port, map to response. It + must not import Diesel, bb8, or `crate::outbound::*`. The architecture + lint (`make lint-architecture`) must continue to pass. +- All persistence work stays in `backend/src/outbound/persistence/`. Diesel + query construction must not leak into the domain layer. +- The cursor remains opaque (base64url-encoded JSON via the pagination + crate's `Cursor::encode` / `Cursor::decode`); no new on-the-wire format. +- Default page size is `pagination::DEFAULT_LIMIT` (20); maximum is + `pagination::MAX_LIMIT` (100). Clients must not be able to request + larger pages. +- Ordering is `(created_at ASC, id ASC)` for every page, including the first + page (no cursor) and reverse traversals. +- `User` domain invariants must be preserved: identity through `UserId`, + validated `DisplayName`, `serde(deny_unknown_fields)`. If `created_at` + must be exposed on `User`, do so additively without breaking the existing + `UserDto` contract. +- Connection acquisition uses the existing `DbPool` (bb8 over + `AsyncPgConnection`). No new pool, no blocking calls inside async tasks. +- Documentation, comments, and any new copy use en-GB Oxford spelling per + `docs/documentation-style-guide.md`. + +## Tolerances (exception triggers) + +- Scope: stop and escalate if the diff exceeds roughly 800 net lines of + source (excluding generated migrations and feature files) or touches more + than 20 files. +- Interface: stop if changing `UserRepository` requires modifying any other + caller besides `DieselUsersQuery`, the new paginated query path, and the + startup-mode wiring in `backend/src/server/state_builders.rs`. +- Dependencies: no new crates. The pagination crate is added as a backend + dependency; that single Cargo edit is in scope. Anything beyond that + (e.g., adding `qs`, `urlencoding`, etc.) escalates. +- Iterations: if `make test` still fails after three good-faith attempts, + pause and document the failure mode in `Surprises & Discoveries` before + continuing. +- Time: if any single milestone (M0--M5 below) takes more than four hours of + active work, stop and re-evaluate the approach. +- Ambiguity: if the User domain entity needs a structural change beyond + adding `created_at` (for example, surfacing `updated_at` or relaxing + `deny_unknown_fields`), stop and request direction. + +## Risks + +- Risk: surfacing `created_at` on the `User` domain entity changes the + serialised JSON contract. + Severity: medium. Likelihood: high. + Mitigation: add the field with `#[serde(rename = "createdAt")]` and a + matching deserialise alias; assert the new shape with a snapshot or + explicit JSON round-trip test; cross-check the OpenAPI schema in + `frontend-pwa/openapi.json` does not need a parallel hand edit. + +- Risk: omitting the composite index leaves the new query doing a sort plus + filter scan on every request, which would silently regress production + latency. + Severity: high. Likelihood: medium. + Mitigation: ship the migration as the first commit; assert via `EXPLAIN` + in a one-off integration test that the planner uses + `idx_users_created_at_id` (or document that Postgres chose the primary + key index due to small fixture size and is acceptable in test). + +- Risk: forward/backward link generation has subtle off-by-one bugs around + page boundaries (the design doc explicitly flags this in the "Determine + Page Boundaries" section). + Severity: high. Likelihood: high. + Mitigation: derive `next`/`prev` from a single helper that always uses + `limit + 1` fetch semantics; cover with BDD scenarios for first page, + middle page, last page, single-item page, and exact-boundary page. + +- Risk: existing handler tests in `backend/src/inbound/http/users/tests.rs` + and `backend/tests/diesel_login_users_adapters.rs` assert the old + `Vec` shape and will break. + Severity: low. Likelihood: certain. + Mitigation: update assertions in the same commit that changes the + response; do not introduce a transitional dual-shape response. + +- Risk: the `FixtureUsersQuery` test double currently returns a single + static "Ada Lovelace" user; the new trait method must remain trivially + satisfiable for handler-only tests that do not need a real database. + Severity: low. Likelihood: high. + Mitigation: keep the fixture's behaviour minimal -- return the same row + wrapped in a one-page envelope with no cursors -- so existing handler + unit tests need only response-shape adjustments. + +- Risk: BDD scenarios using `pg-embedded-setup-unpriv` are slow to start + and can flake on the shared test cluster. + Severity: low. Likelihood: medium. + Mitigation: reuse the existing `TemporaryDatabase` and template helpers + in `backend/tests/support/embedded_postgres.rs`; do not provision a + fresh cluster per scenario. + +## Progress + +- [ ] M0: Branch created from `main`; pagination crate added to + `backend/Cargo.toml` and `backend` builds cleanly with the import in a + scratch module (no behaviour change). +- [ ] M1: Migration `add_users_created_at_id_index` added under + `backend/migrations/`; `make test` still passes after the migration runs + via the embedded-postgres fixtures. +- [ ] M2: Domain and port updates -- `User` exposes `created_at`, + `UserCursorKey` defined, `UsersQuery` and `UserRepository` extended with + paginated reads, `FixtureUsersQuery` updated. +- [ ] M3: Diesel adapter implements the keyset query (`limit + 1` fetch, + composite filter, asc ordering); covered by unit tests with a stubbed + `UserRepository` for error mapping and an integration test against + embedded Postgres. +- [ ] M4: `list_users` handler rewritten to consume `web::Query`, + decode cursor, call the port, build links from request URL, and return + `Paginated`; OpenAPI annotations updated; existing handler + tests adjusted to the new envelope. +- [ ] M5: BDD feature + `backend/tests/features/users_list_pagination.feature` and step + definitions cover happy and unhappy paths; full gate replay + (`make check-fmt`, `make lint`, `make test`) is green; roadmap entry + 4.2.1 marked done; PR opened. + +## Surprises & discoveries + +(none yet) + +## Decision log + +- Decision: place the new `UserCursorKey` struct in + `backend/src/domain/users_pagination.rs` (re-exported from + `backend/src/domain/mod.rs`) rather than in the pagination crate or the + outbound adapter. + Rationale: the key is a domain concept (the natural ordering of users) + and must be constructable from a `User` reference; placing it in the + domain keeps the inbound handler and outbound adapter both depending + inward, satisfying hexagonal layering. The pagination crate stays + generic. + Date/Author: 2026-04-28, drafting agent. + +- Decision: extend the existing `UsersQuery` driving port with a new + `list_users_page` method instead of replacing `list_users`. + Rationale: `list_users` is also called by `diesel_login_users_adapters` + startup-mode tests; keeping it allows the migration to land in a single + PR without rewriting startup-mode coverage. The handler switches over; + callers that genuinely want a single-user lookup keep working. We will + remove `list_users` in a follow-up once no caller remains. + Date/Author: 2026-04-28, drafting agent. + +- Decision: do not add HMAC-signed cursors in this task. + Rationale: the design doc explicitly defers signing to a future change; + introducing it here would expand scope past tolerance and is not + required by the roadmap. + Date/Author: 2026-04-28, drafting agent. + +## Outcomes & retrospective + +(to be filled in at completion) + +## Context and orientation + +The Wildside backend is a hexagonal modular monolith. The handler under +change is at `backend/src/inbound/http/users.rs:243-251`: + +```rust +#[get("/users")] +pub async fn list_users( + state: web::Data, + session: SessionContext, +) -> ApiResult>> { + let user_id = session.require_user_id()?; + let data = state.users.list_users(&user_id).await?; + Ok(web::Json(data)) +} +``` + +`HttpState::users` is `Arc` +(`backend/src/inbound/http/state.rs`). The driving port lives at +`backend/src/domain/ports/users_query.rs`: + +```rust +#[async_trait] +pub trait UsersQuery: Send + Sync { + async fn list_users(&self, authenticated_user: &UserId) -> Result, Error>; +} +``` + +Two implementations exist: + +- `FixtureUsersQuery` (same file) returns one static "Ada Lovelace" user. +- `DieselUsersQuery` (`backend/src/outbound/persistence/diesel_users_query.rs`) + delegates to `UserRepository::find_by_id` -- it does not actually list + rows today. It must learn how to do a real paginated read. + +The driven port `UserRepository` lives at +`backend/src/domain/ports/user_repository.rs` and has only `upsert` and +`find_by_id`. The Diesel adapter +`backend/src/outbound/persistence/diesel_user_repository.rs` runs through +`DbPool` (`backend/src/outbound/persistence/pool.rs`), a bb8 pool over +`diesel_async::AsyncPgConnection`. + +The schema is `backend/src/outbound/persistence/schema.rs`: + +```rust +diesel::table! { + users (id) { + id -> Uuid, + display_name -> Varchar, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} +``` + +The migration `backend/migrations/2025-12-10-000000_create_users/up.sql` +creates the table and an index on `display_name` only. There is **no** +composite index on `(created_at, id)` today. + +The pagination crate (`backend/crates/pagination`) provides: + +- `Direction` (`Next` | `Prev`), `Cursor::encode/decode`, + `PageParams { cursor, limit }` with `DEFAULT_LIMIT = 20` and + `MAX_LIMIT = 100`, +- `Paginated { data, limit, links }` and + `PaginationLinks::from_request(url, params, next, prev)` for link + generation. + +It is **not** yet declared in `backend/Cargo.toml`. Add it as +`pagination = { path = "crates/pagination" }`. + +User-visible response shape today is a raw JSON array. After the change it +becomes: + +```json +{ + "data": [{ "id": "...", "displayName": "..." }], + "limit": 20, + "links": { + "self": "/api/v1/users?limit=20", + "next": "/api/v1/users?cursor=eyJk...&limit=20" + } +} +``` + +`prev` is omitted on the first page; `next` is omitted on the last page. +Field names follow camelCase via the existing serde defaults on +`PaginationLinks` (`self_` serialises as `"self"`). + +### Signposts (read these before starting) + +- `docs/keyset-pagination-design.md` -- canonical design for the crate and + the integration pattern (especially section "Integrating Pagination in + Handlers"). +- `docs/wildside-backend-architecture.md` -- hexagonal layering rules and + inbound/outbound module map; consult before placing new types. +- `docs/backend-roadmap.md` section 4 -- task scope and downstream items + (4.2.2 onwards) that must remain implementable after this change. +- `docs/pg-embed-setup-unpriv-users-guide.md` -- how to spin up a temporary + PostgreSQL for integration tests. +- `docs/rstest-bdd-users-guide.md` -- BDD step authoring patterns used in + this repo. +- `docs/rust-testing-with-rstest-fixtures.md` -- shared fixture style. +- `docs/rust-doctest-dry-guide.md` -- doctest patterns; the handler's + existing example must continue to compile. +- `docs/complexity-antipatterns-and-refactoring-strategies.md` -- guidance + on extracting helpers when the new handler logic grows beyond a screen. +- `backend/crates/pagination/src/lib.rs` -- crate-level docs and public + API. + +### Skills (load when relevant) + +- `hexagonal-architecture` -- use throughout; verify each new type and + function lands in the correct ring (domain / port / adapter / inbound). +- `rust-router` -- entry point for the focused Rust skills below. +- `rust-types-and-apis` -- when extending `UsersQuery`, `UserRepository`, + and the `User` struct; helps shape trait bounds and conversions. +- `rust-async-and-concurrency` -- when adding the new async repository + method and ensuring no blocking work runs inside a Tokio task. +- `rust-errors` -- when mapping cursor decode failures and pagination + parameter errors onto domain `Error` variants and HTTP 400 responses. +- `nextest` -- for running `make test` and triaging individual test + failures during M3--M5. +- `en-gb-oxendict` -- for any documentation, comments, and feature-file + copy. +- `commit-message` -- to write the per-milestone commits. +- `pr-creation` -- to open the final PR. + +## Plan of work + +The work proceeds in five milestones. Each milestone ends with the same +gate: `make check-fmt`, `make lint`, and `make test` must succeed before +the next milestone begins, and a single focused commit captures the +change. + +### Stage A: prepare (M0 -- pagination crate available to backend) + +1. Branch from current `main`. Suggested name: + `backend-4-2-1-users-keyset-pagination`. Confirm with the user before + pushing if scope warrants. +2. Add the pagination crate to `backend/Cargo.toml`: + `pagination = { path = "crates/pagination" }`. Workspace already + contains the crate, so no workspace edit is needed. +3. Run `cargo check -p backend` to confirm the dep resolves. +4. Commit: `Add pagination crate dependency to backend`. + +Validation: `make check-fmt && make lint && make test` pass; the new +dependency is visible in `cargo tree -p backend | grep pagination`. + +### Stage B: schema (M1 -- composite index) + +1. Generate a new Diesel migration directory under `backend/migrations/`, + e.g., `2026-04-28-000000_add_users_created_at_id_index/`. +2. `up.sql`: `CREATE INDEX IF NOT EXISTS idx_users_created_at_id ON users + (created_at, id);`. `down.sql`: `DROP INDEX IF EXISTS + idx_users_created_at_id;`. +3. Confirm `EmbeddedMigrations` picks up the new directory automatically + (it uses `embed_migrations!` over the directory tree -- no Rust change + needed beyond running tests so the embedded fixtures re-run + migrations). +4. Commit: `Add composite (created_at, id) index for users keyset + pagination`. + +Validation: every existing test passes; `backend/tests/diesel_user_repository.rs` +runs the new migration without error. + +### Stage C: domain and ports (M2) + +This stage is dispatched to two parallel worker agents under the lead +agent's coordination, since the changes are mostly additive and touch +disjoint files: + +- Worker A (domain types): owns `backend/src/domain/user.rs` and a new + module `backend/src/domain/users_pagination.rs`. +- Worker B (ports): owns `backend/src/domain/ports/users_query.rs`, + `backend/src/domain/ports/user_repository.rs`, and the + `FixtureUsersQuery` impl. + +Worker A tasks: + +1. Add `created_at: chrono::DateTime` to the `User` struct; + thread it through the constructor (`User::new`) and the `UserDto` + serialisation form. Update existing factories + (`docs/backend-sample-data-design.md` describes the example-data crate; + confirm any factory-style helper continues to compile). +2. Add `User::created_at(&self) -> chrono::DateTime` + accessor. +3. Create `backend/src/domain/users_pagination.rs` defining: + + ```rust + pub struct UserCursorKey { + pub created_at: chrono::DateTime, + pub id: uuid::Uuid, + } + impl From<&User> for UserCursorKey { /* ... */ } + ``` + + Derive `Serialize`, `Deserialize`, `Debug`, `Clone`. Add a doctest + showing round-trip via `pagination::Cursor::encode/decode`. +4. Re-export `UserCursorKey` from `backend/src/domain/mod.rs`. + +Worker B tasks: + +1. Extend `UserRepository` with: + + ```rust + async fn list_page( + &self, + request: ListUsersPageRequest, + ) -> Result, UserPersistenceError>; + ``` + + where `ListUsersPageRequest { cursor: Option>, + limit: usize }` lives next to the trait. The method returns up to + `limit + 1` rows so callers can detect overflow. +2. Extend `UsersQuery` with: + + ```rust + async fn list_users_page( + &self, + authenticated_user: &UserId, + request: ListUsersPageRequest, + ) -> Result; + ``` + + where `UsersPage { rows: Vec, has_more: bool }` is a small + value type defined in the same file. The intent is to keep + "did we fetch one extra?" logic encapsulated, so the handler does + not need to peek. +3. Update `FixtureUsersQuery` so `list_users_page` returns the static row + on the first page (no cursor) and an empty page otherwise. +4. Keep the existing `list_users` method intact (decision-log entry + above). + +Lead agent reviews both worker patches together, resolves any naming +conflicts, and commits. + +Validation: `make check-fmt && make lint && make test` pass. Existing +handler tests still compile because the handler has not yet been +rewritten. + +### Stage D: persistence adapter (M3) + +In `backend/src/outbound/persistence/diesel_users_query.rs` and +`diesel_user_repository.rs`: + +1. Implement `UserRepository::list_page` in `DieselUserRepository`. Use + `users::table.into_boxed()`, apply the `(created_at, id)` lexicographic + filter for `Direction::Next` or `Direction::Prev`, order by + `created_at.asc()` then `id.asc()`, and limit to `limit + 1`. Map + `bb8` errors via the existing `map_pool_error`/`map_diesel_error` + helpers. +2. Implement `UsersQuery::list_users_page` in `DieselUsersQuery`. Decode + the boundary semantics: take the (up to) `limit + 1` rows from the + repository, set `has_more = rows.len() > limit`, truncate to `limit`, + and return `UsersPage`. +3. Unit tests in `diesel_users_query.rs` extend the existing + `StubUserRepository` to assert that pool/connection errors map to + `Error::ServiceUnavailable` and query errors map to + `Error::InternalError`, mirroring the existing pattern. +4. Add an integration test + `backend/tests/diesel_users_query_pagination.rs` that seeds at least + `MAX_LIMIT + 5` users with controlled `created_at` values, walks + forward to the end, and walks back to the start using the same cursor + strings the handler would emit. Use the existing + `TemporaryDatabase`/`with_context_async` machinery in + `backend/tests/diesel_user_repository.rs` as the model. + +Validation: the new integration test fails without the keyset filter and +passes with it; the unit tests pass; `make test` is green. + +Commit: `Implement keyset-paginated users listing in Diesel adapter`. + +### Stage E: handler, OpenAPI, and BDD (M4 + M5) + +Inbound handler changes (`backend/src/inbound/http/users.rs`): + +1. Replace the `list_users` body with: + + ```rust + pub async fn list_users( + state: web::Data, + session: SessionContext, + request: HttpRequest, + params: web::Query, + ) -> ApiResult>> { /* ... */ } + ``` + + Decode the cursor via `Cursor::::decode` and map errors + to `Error::invalid_request` with HTTP 400 and structured details + (`field: "cursor", code: "invalid_cursor"`). Map `PageParamsError` the + same way (`field: "limit"`, `code: "invalid_limit"`). +2. Build links via `PaginationLinks::from_request`, passing `request.uri()` + converted to `url::Url`. Extract a small helper + `current_request_url(req: &HttpRequest) -> url::Url` if it grows beyond + four lines. +3. Update the utoipa `#[utoipa::path]` annotations: declare `cursor` and + `limit` query parameters, and replace the `body = UsersListResponse` + response with `body = PaginatedUsersResponse`. Define + `PaginatedUsersResponse` as a thin schema token that mirrors + `Paginated` (use the same `PartialSchema`/`ToSchema` + pattern the existing `UsersListResponse` uses, so the generated + OpenAPI matches the design doc's `PaginatedUsers` example). +4. Delete `UsersListResponse` and the `USERS_LIST_MAX` constant; the + pagination crate's `MAX_LIMIT` is the single source of truth. +5. Update `backend/src/inbound/http/users/tests.rs` to assert the new + envelope shape (data length, presence/absence of `next`/`prev`). + +Behavioural tests (M5): + +1. Add `backend/tests/features/users_list_pagination.feature`. Scenarios: + - First page returns `limit` rows, includes `next`, omits `prev`. + - Following `next` reaches the final page, which includes `prev` and + omits `next`. + - Following `prev` from the final page returns the prior page intact. + - Requesting `limit=200` returns HTTP 400 with the + `invalid_limit` detail code. + - Requesting an unparseable `cursor` returns HTTP 400 with the + `invalid_cursor` detail code. + - Unauthenticated request returns HTTP 401 (regression for existing + session behaviour). +2. Add `backend/tests/users_list_pagination_bdd.rs` with step definitions. + Reuse `support::embedded_postgres` to seed users with deterministic + `created_at` values (e.g., one minute apart starting at a fixed UTC + instant) so cursor traversal is reproducible. +3. Update `backend/tests/diesel_login_users_adapters.rs` if it asserts + the old response shape. + +Documentation: + +1. Append a short note in `docs/wildside-backend-architecture.md` (in the + pagination or read-model section) recording: "User listing uses keyset + pagination on `(created_at, id)`; see + `docs/keyset-pagination-design.md`. The driving port `UsersQuery` + exposes `list_users_page` returning `UsersPage`; the legacy + `list_users` is retained until callers migrate." +2. Mark roadmap entry 4.2.1 as `[x]` with the date. + +Final commit + PR: + +1. Run the full gate (`make check-fmt`, `make lint`, `make test`), + piping each output through `tee /tmp/-backend-4-2-1.out` per + `AGENTS.md`. +2. Commit the handler/OpenAPI/BDD work as one atomic change: + `Adopt keyset pagination on GET /api/v1/users`. +3. Open a PR via the `pr-creation` skill referencing roadmap §4.2.1 and + this ExecPlan. + +## Concrete steps + +Run from the worktree root unless noted. + +```bash +git branch --show-current +# Confirm we are NOT on main; if we are, branch: +git switch -c backend-4-2-1-users-keyset-pagination +``` + +Add the dep: + +```bash +# Edit backend/Cargo.toml manually -- add: +# pagination = { path = "crates/pagination" } +cargo check -p backend +``` + +Generate the migration directory: + +```bash +mkdir -p backend/migrations/2026-04-28-000000_add_users_created_at_id_index +# Write up.sql and down.sql as described in Stage B. +``` + +Run gates after each milestone: + +```bash +make check-fmt 2>&1 | tee /tmp/check-fmt-backend-4-2-1-users-keyset-pagination.out +make lint 2>&1 | tee /tmp/lint-backend-4-2-1-users-keyset-pagination.out +make test 2>&1 | tee /tmp/test-backend-4-2-1-users-keyset-pagination.out +``` + +Expected: every command exits 0. The `test` invocation is the slow one; +do not run it in parallel with another test job per `AGENTS.md`. + +When the BDD scenarios are in place, exercise just the new feature +quickly while iterating: + +```bash +cargo nextest run -p backend --test users_list_pagination_bdd \ + --no-fail-fast 2>&1 | tee /tmp/nextest-users-pagination.out +``` + +## Validation and acceptance + +Quality criteria (what "done" means): + +- `make check-fmt`, `make lint`, and `make test` all pass on the final + commit, evidenced by the captured `/tmp/*.out` logs. +- A user with a session cookie can call `GET /api/v1/users` and receive the + envelope described in `Purpose / big picture`. Following `next` and + `prev` links recovers the same user set as a single un-paginated SQL + query (asserted in the BDD scenarios). +- `GET /api/v1/users?limit=200` returns HTTP 400 with body + `{"error":{...,"details":{"field":"limit","code":"invalid_limit", ...}}}`. +- `GET /api/v1/users?cursor=not-base64` returns HTTP 400 with + `code: "invalid_cursor"`. +- Unauthenticated requests still receive HTTP 401 (regression). +- `EXPLAIN (ANALYZE, BUFFERS)` on the keyset query (run manually once, + recorded in `Surprises & Discoveries`) shows an index scan on + `idx_users_created_at_id`, not a full table scan, when the table has + more than a few thousand rows. + +Quality method (how we check): + +- Integration tests in `backend/tests/diesel_users_query_pagination.rs` + execute the SQL path against an embedded PostgreSQL. +- BDD feature `backend/tests/features/users_list_pagination.feature` + exercises the HTTP path end-to-end via the Actix test server. +- The architecture lint (`make lint-architecture`) confirms the inbound + handler does not import outbound modules. + +## Idempotence and recovery + +- The new migration is gated by `IF NOT EXISTS` / `IF EXISTS`, so re-running + the embedded test cluster after a partial run is safe. +- Each milestone ends with a clean commit. If a milestone gate fails, + revert the milestone with `git restore --source=HEAD~1` rather than + amending; do not push partial milestones. +- The cursor format is fully recoverable: clients re-issuing a stale + cursor always either succeed or receive a deterministic HTTP 400; no + server-side state needs reconciliation. +- `pg-embedded-setup-unpriv` test clusters are auto-cleaned via the + existing `atexit_cleanup` machinery in `backend/tests/support`. + +## Artifacts and notes + +Expected JSON envelope (first page, default limit): + +```json +{ + "data": [ + { "id": "11111111-1111-1111-1111-111111111111", "displayName": "Ada" } + ], + "limit": 20, + "links": { + "self": "/api/v1/users?limit=20", + "next": "/api/v1/users?cursor=eyJkaXIiOiJOZXh0Iiwia2V5Ijp7ImNyZWF0ZWRfYXQiOiIyMDI2LTA0LTI4VDAwOjAwOjAwWiIsImlkIjoiMTExMTExMTEtMTExMS0xMTExLTExMTEtMTExMTExMTExMTExIn19&limit=20" + } +} +``` + +(`prev` is omitted when null per the crate's `skip_serializing_if` +configuration.) + +Expected error envelope (invalid cursor): + +```json +{ + "error": { + "code": "INVALID_REQUEST", + "message": "invalid pagination cursor", + "details": { "field": "cursor", "code": "invalid_cursor" } + } +} +``` + +## Interfaces and dependencies + +In `backend/src/domain/users_pagination.rs`, define: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserCursorKey { + pub created_at: chrono::DateTime, + pub id: uuid::Uuid, +} + +impl From<&crate::domain::User> for UserCursorKey { + fn from(user: &crate::domain::User) -> Self { + Self { created_at: user.created_at(), id: user.id().as_uuid() } + } +} +``` + +In `backend/src/domain/ports/users_query.rs`, extend the trait to: + +```rust +use pagination::Cursor; + +pub struct ListUsersPageRequest { + pub cursor: Option>, + pub limit: usize, +} + +pub struct UsersPage { + pub rows: Vec, + pub has_more: bool, +} + +#[async_trait] +pub trait UsersQuery: Send + Sync { + async fn list_users(&self, authenticated_user: &UserId) -> Result, Error>; + async fn list_users_page( + &self, + authenticated_user: &UserId, + request: ListUsersPageRequest, + ) -> Result; +} +``` + +In `backend/src/domain/ports/user_repository.rs`, extend the trait with: + +```rust +async fn list_page( + &self, + request: ListUsersPageRequest, +) -> Result, UserPersistenceError>; +``` + +In `backend/src/inbound/http/users.rs`, the rewritten handler signature: + +```rust +pub async fn list_users( + state: web::Data, + session: SessionContext, + request: HttpRequest, + params: web::Query, +) -> ApiResult>>; +``` + +External crate dependencies introduced: only the workspace-local +`pagination` crate. No new third-party crates. + +## Revision note + +(none yet) From f9087cf6878919849dae1f28d92d72e68570f4dc Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 18:56:00 +0200 Subject: [PATCH 02/18] Add pagination crate dependency to backend Make the backend crate depend on the workspace `pagination` crate so the users endpoint can adopt the shared keyset pagination types in the next implementation step. Record the M0 gate results and the transient embedded PostgreSQL fixture startup failure in the execplan before moving on to behavioural changes. --- Cargo.lock | 1 + backend/Cargo.toml | 1 + ...-users-offset-pagination-with-new-crate.md | 28 ++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84bf9513..d6162f1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,6 +688,7 @@ dependencies = [ "mockable", "mockall 0.13.1", "ortho_config 0.7.0", + "pagination", "paste", "pg-embed-setup-unpriv", "postgres", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ac34e8f1..51208501 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -28,6 +28,7 @@ postgres = { version = "0.19.12", features = ["with-uuid-1"] } paste = "1.0.15" thiserror = "2.0.17" bb8-redis = "0.26" +pagination = { path = "crates/pagination" } # Queue adapter (Apalis with PostgreSQL) apalis-core = "1.0.0-rc.7" diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 3ae415e1..9d87fd3a 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: DRAFT +Status: IN PROGRESS ## Purpose / big picture @@ -132,9 +132,15 @@ them requires escalation, not a workaround. ## Progress -- [ ] M0: Branch created from `main`; pagination crate added to +- [x] 2026-05-01: Implementation started on branch + `4-2-1-replace-users-offset-pagination-with-new-crate`. The existing + plan's worker-agent split will be executed locally because this session only + permits sub-agent delegation when explicitly requested by the user. +- [x] M0: Branch created from `main`; pagination crate added to `backend/Cargo.toml` and `backend` builds cleanly with the import in a - scratch module (no behaviour change). + scratch module (no behaviour change). `cargo check -p backend`, `make + check-fmt`, `make lint`, and a clean rerun of `make test` passed on + 2026-05-01. - [ ] M1: Migration `add_users_created_at_id_index` added under `backend/migrations/`; `make test` still passes after the migration runs via the embedded-postgres fixtures. @@ -157,7 +163,21 @@ them requires escalation, not a workaround. ## Surprises & discoveries -(none yet) +- 2026-05-01: `leta` was available, but Rust indexing initially failed because + `rust-analyzer` was missing from the active toolchain. Installing the rustup + component and restarting the `leta` daemon restored Rust symbol lookup. +- 2026-05-01: The execplan says `GET /api/v1/users?limit=200` should return + HTTP 400, while `backend/crates/pagination` and + `docs/keyset-pagination-design.md` currently cap oversized limits to + `MAX_LIMIT`. The implementation will follow the execplan's endpoint + acceptance criteria and document any required pagination-crate behaviour + change before it is made. +- 2026-05-01: The first full `make test` run after M0 failed in four + embedded-PostgreSQL-backed tests while bootstrapping `/var/tmp/pg-embed-1000` + (`pg_wal/... No such file or directory` and one `pg_ctl: another server might + be running` report). No active PostgreSQL worker was left behind, and an + immediate rerun passed all Rust and frontend tests without code changes, so + this was treated as a transient fixture startup failure. ## Decision log From 5f32ff11b843e2324322f5f26042fcd7fd358893 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 19:00:40 +0200 Subject: [PATCH 03/18] Add users keyset pagination index Create `idx_users_created_at_id` so the planned users pagination query can walk the `users` table in `(created_at, id)` order without relying on an offset scan. Record the completed M1 gate evidence in the execplan. --- .../down.sql | 3 +++ .../2026-05-01-000000_add_users_created_at_id_index/up.sql | 3 +++ ...-4-2-1-replace-users-offset-pagination-with-new-crate.md | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/2026-05-01-000000_add_users_created_at_id_index/down.sql create mode 100644 backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql diff --git a/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/down.sql b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/down.sql new file mode 100644 index 00000000..e1110116 --- /dev/null +++ b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/down.sql @@ -0,0 +1,3 @@ +-- Revert users keyset pagination index. + +DROP INDEX IF EXISTS idx_users_created_at_id; diff --git a/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql new file mode 100644 index 00000000..870244bc --- /dev/null +++ b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql @@ -0,0 +1,3 @@ +-- Support keyset pagination over users ordered by creation time. + +CREATE INDEX idx_users_created_at_id ON users (created_at, id); diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 9d87fd3a..8321cbb7 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -141,9 +141,13 @@ them requires escalation, not a workaround. scratch module (no behaviour change). `cargo check -p backend`, `make check-fmt`, `make lint`, and a clean rerun of `make test` passed on 2026-05-01. -- [ ] M1: Migration `add_users_created_at_id_index` added under +- [x] M1: Migration `add_users_created_at_id_index` added under `backend/migrations/`; `make test` still passes after the migration runs via the embedded-postgres fixtures. +- [x] 2026-05-01: M1 migration files added with + `idx_users_created_at_id` on `(created_at, id)` and a matching down + migration; `make fmt`, `make markdownlint`, `make check-fmt`, `make lint`, + and `make test` passed. - [ ] M2: Domain and port updates -- `User` exposes `created_at`, `UserCursorKey` defined, `UsersQuery` and `UserRepository` extended with paginated reads, `FixtureUsersQuery` updated. From d0c17f56340414e740ba2b9fd0d8c2265d7a5af4 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 19:24:08 +0200 Subject: [PATCH 04/18] Add users pagination domain types Expose `created_at` on `User` and normalise it to PostgreSQL microsecond precision so persisted values compare cleanly after a round trip. Add the users cursor key, paginated request/page port types, and a fixture implementation for the new query method. Keep the first port change additive with default errors until the Diesel keyset query lands in the next milestone. --- backend/src/domain/example_data.rs | 2 +- backend/src/domain/mod.rs | 3 + .../ports/example_data_seed_repository.rs | 5 +- backend/src/domain/ports/mod.rs | 4 +- .../src/domain/ports/user_profile_query.rs | 2 +- backend/src/domain/ports/user_repository.rs | 56 +++++++++- backend/src/domain/ports/users_query.rs | 103 ++++++++++++++++-- backend/src/domain/user.rs | 56 +++++++++- backend/src/domain/user/tests.rs | 46 +++++++- backend/src/domain/user_onboarding.rs | 2 +- backend/src/domain/users_pagination.rs | 57 ++++++++++ backend/src/inbound/ws/messages.rs | 4 + .../diesel_example_data_seed_repository.rs | 1 + .../persistence/diesel_login_service.rs | 2 +- .../persistence/diesel_user_repository.rs | 3 +- backend/src/outbound/persistence/models.rs | 2 +- .../adapter_guardrails/harness_defaults.rs | 6 + backend/tests/adapter_guardrails/steps.rs | 3 + backend/tests/diesel_user_repository.rs | 2 +- backend/tests/ports_behaviour.rs | 27 ++++- ...-users-offset-pagination-with-new-crate.md | 39 ++++++- 21 files changed, 389 insertions(+), 36 deletions(-) create mode 100644 backend/src/domain/users_pagination.rs diff --git a/backend/src/domain/example_data.rs b/backend/src/domain/example_data.rs index 5148a009..98bbc2a2 100644 --- a/backend/src/domain/example_data.rs +++ b/backend/src/domain/example_data.rs @@ -179,7 +179,7 @@ fn convert_seed_user( ) -> Result { let user_id = UserId::from_uuid(seed_user.id); let display_name = DisplayName::new(seed_user.display_name)?; - let user = User::new(user_id.clone(), display_name); + let user = User::new(user_id.clone(), display_name, *now); let preferences = UserPreferencesBuilder::new(user_id) .interest_theme_ids(seed_user.interest_theme_ids) .safety_toggle_ids(seed_user.safety_toggle_ids) diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 0b6959c2..bcfe9eac 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -10,6 +10,7 @@ //! - ErrorCode (alias to `error::ErrorCode`) — stable error identifier shared //! across adapters. //! - User (alias to `user::User`) — domain user identity and display name. +//! - UserCursorKey — stable `(created_at, id)` pagination boundary key. //! - InterestThemeId — validated identifier for interest themes. //! - UserInterests — selected interest themes for a user profile. //! - LoginCredentials — validated username/password inputs for authentication. @@ -85,6 +86,7 @@ pub mod user_events; pub mod user_interests; pub mod user_onboarding; pub mod user_state_schema_audit; +pub mod users_pagination; pub mod walk_session_service; pub mod walks; @@ -147,6 +149,7 @@ pub use self::user_state_schema_audit::{ EntitySchemaCoverage, InterestsStorageCoverage, LoginSchemaCoverage, MigrationDecision, UserStateSchemaAuditReport, UserStateSchemaAuditService, audit_user_state_schema_coverage, }; +pub use self::users_pagination::UserCursorKey; pub use self::walk_session_service::{WalkSessionCommandService, WalkSessionQueryService}; pub use self::walks::{ ParseWalkPrimaryStatKindError, ParseWalkSecondaryStatKindError, WalkCompletionSummary, diff --git a/backend/src/domain/ports/example_data_seed_repository.rs b/backend/src/domain/ports/example_data_seed_repository.rs index 9914af75..0ad140b3 100644 --- a/backend/src/domain/ports/example_data_seed_repository.rs +++ b/backend/src/domain/ports/example_data_seed_repository.rs @@ -85,7 +85,10 @@ pub trait ExampleDataSeedRepository: Send + Sync { /// /// # async fn run() -> Result<(), Box> { /// let user_id = UserId::from_uuid(Uuid::new_v4()); - /// let user = User::new(user_id.clone(), DisplayName::new("Demo user".to_string())?); + /// let user = User::with_current_timestamp( + /// user_id.clone(), + /// DisplayName::new("Demo user".to_string())?, + /// ); /// let preferences = UserPreferencesBuilder::new(user_id).revision(1).build(); /// let request = ExampleDataSeedRequest { /// seed_key: "mossy-owl".to_string(), diff --git a/backend/src/domain/ports/mod.rs b/backend/src/domain/ports/mod.rs index 22300b54..d69c3580 100644 --- a/backend/src/domain/ports/mod.rs +++ b/backend/src/domain/ports/mod.rs @@ -194,8 +194,8 @@ pub use user_preferences_repository::{ FixtureUserPreferencesRepository, UserPreferencesRepository, UserPreferencesRepositoryError, }; pub use user_profile_query::{FixtureUserProfileQuery, UserProfileQuery}; -pub use user_repository::{UserPersistenceError, UserRepository}; -pub use users_query::{FixtureUsersQuery, UsersQuery}; +pub use user_repository::{ListUsersPageRequest, UserPersistenceError, UserRepository}; +pub use users_query::{FixtureUsersQuery, UsersPage, UsersQuery}; #[cfg(test)] pub use walk_session_command::MockWalkSessionCommand; pub use walk_session_command::{ diff --git a/backend/src/domain/ports/user_profile_query.rs b/backend/src/domain/ports/user_profile_query.rs index 79da9a50..10d6cfae 100644 --- a/backend/src/domain/ports/user_profile_query.rs +++ b/backend/src/domain/ports/user_profile_query.rs @@ -24,7 +24,7 @@ impl UserProfileQuery for FixtureUserProfileQuery { async fn fetch_profile(&self, user_id: &UserId) -> Result { let display_name = DisplayName::new("Ada Lovelace") .map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?; - Ok(User::new(user_id.clone(), display_name)) + Ok(User::with_current_timestamp(user_id.clone(), display_name)) } } diff --git a/backend/src/domain/ports/user_repository.rs b/backend/src/domain/ports/user_repository.rs index 69f0a9cf..09a01547 100644 --- a/backend/src/domain/ports/user_repository.rs +++ b/backend/src/domain/ports/user_repository.rs @@ -1,7 +1,8 @@ //! Port abstraction for user persistence adapters and their errors. use async_trait::async_trait; +use pagination::Cursor; -use crate::domain::{User, UserId}; +use crate::domain::{User, UserCursorKey, UserId}; use super::define_port_error; @@ -15,6 +16,49 @@ define_port_error! { } } +/// Request for a keyset-ordered page from the users table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListUsersPageRequest { + cursor: Option>, + limit: usize, +} + +impl ListUsersPageRequest { + /// Build a users page request from a cursor and caller-normalized limit. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::ports::ListUsersPageRequest; + /// + /// let request = ListUsersPageRequest::new(None, 20); + /// assert_eq!(request.limit(), 20); + /// assert!(request.cursor().is_none()); + /// ``` + #[must_use] + pub const fn new(cursor: Option>, limit: usize) -> Self { + Self { cursor, limit } + } + + /// Borrow the optional page boundary cursor. + #[must_use] + pub const fn cursor(&self) -> Option<&Cursor> { + self.cursor.as_ref() + } + + /// Return the caller-normalized page size. + #[must_use] + pub const fn limit(&self) -> usize { + self.limit + } + + /// Consume the request into its cursor and limit components. + #[must_use] + pub fn into_parts(self) -> (Option>, usize) { + (self.cursor, self.limit) + } +} + #[async_trait] pub trait UserRepository: Send + Sync { /// Insert or update a user record. @@ -22,4 +66,14 @@ pub trait UserRepository: Send + Sync { /// Fetch a user by identifier. async fn find_by_id(&self, id: &UserId) -> Result, UserPersistenceError>; + + /// Fetch a keyset-ordered users page. + async fn list_page( + &self, + _request: ListUsersPageRequest, + ) -> Result, UserPersistenceError> { + Err(UserPersistenceError::query( + "paginated user listing is not implemented", + )) + } } diff --git a/backend/src/domain/ports/users_query.rs b/backend/src/domain/ports/users_query.rs index b333e8bd..9d01da03 100644 --- a/backend/src/domain/ports/users_query.rs +++ b/backend/src/domain/ports/users_query.rs @@ -7,13 +7,66 @@ use async_trait::async_trait; +use crate::domain::ports::ListUsersPageRequest; use crate::domain::{DisplayName, Error, User, UserId}; +/// Domain users page returned by the user-list query port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsersPage { + rows: Vec, + has_more: bool, +} + +impl UsersPage { + /// Build a users page from rows and an overflow flag. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::ports::UsersPage; + /// + /// let page = UsersPage::new(Vec::new(), false); + /// assert!(!page.has_more()); + /// assert!(page.rows().is_empty()); + /// ``` + #[must_use] + pub const fn new(rows: Vec, has_more: bool) -> Self { + Self { rows, has_more } + } + + /// Borrow the users in this page. + #[must_use] + pub fn rows(&self) -> &[User] { + &self.rows + } + + /// Consume the page and return its users. + #[must_use] + pub fn into_rows(self) -> Vec { + self.rows + } + + /// Whether another page exists in the requested direction. + #[must_use] + pub const fn has_more(&self) -> bool { + self.has_more + } +} + /// Domain use-case port for listing users. #[async_trait] pub trait UsersQuery: Send + Sync { /// Return the visible users list for the authenticated user. async fn list_users(&self, authenticated_user: &UserId) -> Result, Error>; + + /// Return one keyset-ordered users page for the authenticated user. + async fn list_users_page( + &self, + _authenticated_user: &UserId, + _request: ListUsersPageRequest, + ) -> Result { + Err(Error::internal("paginated users query is not implemented")) + } } /// Temporary fixture users query used until persistence is wired. @@ -23,19 +76,35 @@ pub struct FixtureUsersQuery; #[async_trait] impl UsersQuery for FixtureUsersQuery { async fn list_users(&self, _authenticated_user: &UserId) -> Result, Error> { - const FIXTURE_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; - const FIXTURE_DISPLAY_NAME: &str = "Ada Lovelace"; + Ok(vec![fixture_user()?]) + } + + async fn list_users_page( + &self, + _authenticated_user: &UserId, + request: ListUsersPageRequest, + ) -> Result { + if request.cursor().is_some() { + return Ok(UsersPage::new(Vec::new(), false)); + } - // These values are compile-time constants; surface invalid data as an - // internal error so automated checks catch accidental regressions. - let id = UserId::new(FIXTURE_ID) - .map_err(|err| Error::internal(format!("invalid fixture user id: {err}")))?; - let display_name = DisplayName::new(FIXTURE_DISPLAY_NAME) - .map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?; - Ok(vec![User::new(id, display_name)]) + Ok(UsersPage::new(vec![fixture_user()?], false)) } } +fn fixture_user() -> Result { + const FIXTURE_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + const FIXTURE_DISPLAY_NAME: &str = "Ada Lovelace"; + + // These values are compile-time constants; surface invalid data as an + // internal error so automated checks catch accidental regressions. + let id = UserId::new(FIXTURE_ID) + .map_err(|err| Error::internal(format!("invalid fixture user id: {err}")))?; + let display_name = DisplayName::new(FIXTURE_DISPLAY_NAME) + .map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?; + Ok(User::with_current_timestamp(id, display_name)) +} + #[cfg(test)] mod tests { //! Ensures the fixture users query returns the expected static user. @@ -50,4 +119,20 @@ mod tests { assert_eq!(users.len(), 1); assert_eq!(users[0].display_name().as_ref(), "Ada Lovelace"); } + + #[tokio::test] + async fn fixture_users_query_returns_first_paginated_page() { + let query = FixtureUsersQuery; + let user_id = UserId::new("11111111-1111-1111-1111-111111111111").expect("fixture user id"); + let request = ListUsersPageRequest::new(None, 20); + + let page = query + .list_users_page(&user_id, request) + .await + .expect("users page"); + + assert_eq!(page.rows().len(), 1); + assert!(!page.has_more()); + assert_eq!(page.rows()[0].display_name().as_ref(), "Ada Lovelace"); + } } diff --git a/backend/src/domain/user.rs b/backend/src/domain/user.rs index c834f70d..0947442f 100644 --- a/backend/src/domain/user.rs +++ b/backend/src/domain/user.rs @@ -2,6 +2,7 @@ use std::fmt; +use chrono::{DateTime, Timelike, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -228,6 +229,7 @@ impl TryFrom for DisplayName { /// ## Invariants /// - `id` must be a valid UUID string. /// - `display_name` must be non-empty once trimmed of whitespace. +/// - `created_at` records when the user was first created. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -236,12 +238,23 @@ pub struct User { id: UserId, #[serde(alias = "display_name")] display_name: DisplayName, + #[serde(alias = "created_at")] + created_at: DateTime, } impl User { /// Build a new [`User`] from validated components. - pub fn new(id: UserId, display_name: DisplayName) -> Self { - Self { id, display_name } + pub fn new(id: UserId, display_name: DisplayName, created_at: DateTime) -> Self { + Self { + id, + display_name, + created_at: truncate_to_microseconds(created_at), + } + } + + /// Build a new [`User`] from validated components with the current time. + pub fn with_current_timestamp(id: UserId, display_name: DisplayName) -> Self { + Self::new(id, display_name, Utc::now()) } /// Build a new [`User`] from string inputs, panicking if validation fails. @@ -260,11 +273,22 @@ impl User { pub fn try_from_strings( id: impl AsRef, display_name: impl Into, + ) -> Result { + Self::try_from_strings_at(id, display_name, Utc::now()) + } + + /// Fallible constructor enforcing invariants with an explicit timestamp. + /// + /// Prefer [`User::new`] when components are already validated. + pub fn try_from_strings_at( + id: impl AsRef, + display_name: impl Into, + created_at: DateTime, ) -> Result { let id = UserId::new(id)?; let display_name = DisplayName::new(display_name)?; - Ok(Self::new(id, display_name)) + Ok(Self::new(id, display_name, created_at)) } /// Stable user identifier. @@ -276,6 +300,18 @@ impl User { pub fn display_name(&self) -> &DisplayName { &self.display_name } + + /// Timestamp when the user was first created. + pub fn created_at(&self) -> DateTime { + self.created_at + } +} + +fn truncate_to_microseconds(value: DateTime) -> DateTime { + match value.with_nanosecond(value.timestamp_subsec_micros() * 1_000) { + Some(truncated) => truncated, + None => value, + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -284,14 +320,21 @@ struct UserDto { id: String, #[serde(alias = "display_name")] display_name: String, + #[serde(default, alias = "created_at")] + created_at: Option>, } impl From for UserDto { fn from(value: User) -> Self { - let User { id, display_name } = value; + let User { + id, + display_name, + created_at, + } = value; Self { id: id.to_string(), display_name: display_name.into(), + created_at: Some(created_at), } } } @@ -300,7 +343,10 @@ impl TryFrom for User { type Error = UserValidationError; fn try_from(value: UserDto) -> Result { - User::try_from_strings(value.id, value.display_name) + match value.created_at { + Some(created_at) => User::try_from_strings_at(value.id, value.display_name, created_at), + None => User::try_from_strings(value.id, value.display_name), + } } } diff --git a/backend/src/domain/user/tests.rs b/backend/src/domain/user/tests.rs index 1986cda9..07531660 100644 --- a/backend/src/domain/user/tests.rs +++ b/backend/src/domain/user/tests.rs @@ -10,6 +10,7 @@ use serde_json::json; type TestResult = Result>; const VALID_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; +const VALID_CREATED_AT: &str = "2026-05-01T12:00:00Z"; #[derive(Debug, Clone)] struct TestUserId(String); @@ -91,6 +92,11 @@ fn valid_display_name() -> TestDisplayName { TestDisplayName::valid() } +fn valid_created_at() -> Result, chrono::ParseError> { + chrono::DateTime::parse_from_rfc3339(VALID_CREATED_AT) + .map(|value| value.with_timezone(&chrono::Utc)) +} + #[rstest] fn accepts_minimum_length(valid_id: TestUserId) { let name = "a".repeat(DISPLAY_NAME_MIN); @@ -166,11 +172,18 @@ fn try_new_rejects_too_long_display_name(valid_id: TestUserId) { } #[rstest] -fn try_new_accepts_valid_inputs(valid_id: TestUserId, valid_display_name: TestDisplayName) { - let user = User::try_from_strings(valid_id.as_ref(), valid_display_name.as_ref()) - .expect("valid inputs"); +fn try_new_accepts_valid_inputs( + valid_id: TestUserId, + valid_display_name: TestDisplayName, +) -> TestResult { + let created_at = valid_created_at()?; + let user = + User::try_from_strings_at(valid_id.as_ref(), valid_display_name.as_ref(), created_at) + .expect("valid inputs"); assert_eq!(user.id().as_ref(), valid_id.as_ref()); assert_eq!(user.display_name().as_ref(), valid_display_name.as_ref()); + assert_eq!(user.created_at(), created_at); + Ok(()) } #[rstest] @@ -203,11 +216,13 @@ fn display_name_rejects_forbidden_characters(valid_id: TestUserId) { fn serde_round_trips_alias(valid_id: TestUserId, valid_display_name: TestDisplayName) { let camel = json!({ "id": valid_id.as_ref(), - "displayName": valid_display_name.as_ref() + "displayName": valid_display_name.as_ref(), + "createdAt": VALID_CREATED_AT }); let snake = json!({ "id": valid_id.as_ref(), - "display_name": valid_display_name.as_ref() + "display_name": valid_display_name.as_ref(), + "created_at": VALID_CREATED_AT }); let from_camel: User = serde_json::from_value(camel).expect("camelCase"); let from_snake: User = serde_json::from_value(snake).expect("snake_case"); @@ -218,7 +233,28 @@ fn serde_round_trips_alias(valid_id: TestUserId, valid_display_name: TestDisplay value.get("displayName").and_then(|v| v.as_str()), Some(valid_display_name.as_ref()) ); + assert_eq!( + value.get("createdAt").and_then(|v| v.as_str()), + Some(VALID_CREATED_AT) + ); assert!(value.get("display_name").is_none()); + assert!(value.get("created_at").is_none()); +} + +#[rstest] +fn serde_accepts_legacy_payload_without_created_at( + valid_id: TestUserId, + valid_display_name: TestDisplayName, +) { + let value = json!({ + "id": valid_id.as_ref(), + "displayName": valid_display_name.as_ref() + }); + + let user: User = serde_json::from_value(value).expect("legacy user payload"); + + assert_eq!(user.id().as_ref(), valid_id.as_ref()); + assert_eq!(user.display_name().as_ref(), valid_display_name.as_ref()); } #[given("a valid user payload")] diff --git a/backend/src/domain/user_onboarding.rs b/backend/src/domain/user_onboarding.rs index 2bf17ee1..519c4918 100644 --- a/backend/src/domain/user_onboarding.rs +++ b/backend/src/domain/user_onboarding.rs @@ -19,7 +19,7 @@ impl UserOnboardingService { let display_name = display_name.into(); match DisplayName::new(display_name.clone()) { Ok(display_name) => { - let user = User::new(UserId::random(), display_name); + let user = User::with_current_timestamp(UserId::random(), display_name); UserEvent::UserCreated(UserCreatedEvent { trace_id, user }) } Err(error) => { diff --git a/backend/src/domain/users_pagination.rs b/backend/src/domain/users_pagination.rs new file mode 100644 index 00000000..27571a52 --- /dev/null +++ b/backend/src/domain/users_pagination.rs @@ -0,0 +1,57 @@ +//! User pagination ordering keys. +//! +//! The users list is ordered by creation time and then identifier. This module +//! keeps that ordering key in the domain so inbound handlers and outbound +//! persistence adapters can share cursor semantics without coupling to each +//! other. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::User; + +/// Stable key encoded into user-list pagination cursors. +/// +/// # Examples +/// +/// ``` +/// use backend::domain::UserCursorKey; +/// use chrono::{DateTime, Utc}; +/// use pagination::{Cursor, Direction}; +/// use uuid::Uuid; +/// +/// let created_at = DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") +/// .expect("valid timestamp") +/// .with_timezone(&Utc); +/// let id = Uuid::parse_str("11111111-1111-1111-1111-111111111111") +/// .expect("valid UUID"); +/// let key = UserCursorKey::new(created_at, id); +/// +/// let encoded = Cursor::new(key.clone()).encode().expect("encode cursor"); +/// let decoded = Cursor::::decode(&encoded).expect("decode cursor"); +/// +/// assert_eq!(decoded.key(), &key); +/// assert_eq!(decoded.direction(), Direction::Next); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserCursorKey { + /// Creation timestamp for the row at the cursor boundary. + pub created_at: DateTime, + /// User identifier used to break timestamp ties deterministically. + pub id: Uuid, +} + +impl UserCursorKey { + /// Build a user cursor key from explicit ordering components. + #[must_use] + pub const fn new(created_at: DateTime, id: Uuid) -> Self { + Self { created_at, id } + } +} + +impl From<&User> for UserCursorKey { + fn from(value: &User) -> Self { + Self::new(value.created_at(), *value.id().as_uuid()) + } +} diff --git a/backend/src/inbound/ws/messages.rs b/backend/src/inbound/ws/messages.rs index 5be17e57..10a4aa42 100644 --- a/backend/src/inbound/ws/messages.rs +++ b/backend/src/inbound/ws/messages.rs @@ -110,10 +110,14 @@ mod tests { #[rstest] fn serialises_user_created_event() { + let created_at = chrono::DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") + .expect("static timestamp must be valid") + .with_timezone(&chrono::Utc); let user = User::new( UserId::new("3fa85f64-5717-4562-b3fc-2c963f66afa6") .expect("static test UUID must be valid"), DisplayName::new("Alice").expect("static test display name must be valid"), + created_at, ); let event = UserCreatedEvent { trace_id: TraceId::from_uuid(Uuid::nil()), diff --git a/backend/src/outbound/persistence/diesel_example_data_seed_repository.rs b/backend/src/outbound/persistence/diesel_example_data_seed_repository.rs index e75371ba..1cbfd8ad 100644 --- a/backend/src/outbound/persistence/diesel_example_data_seed_repository.rs +++ b/backend/src/outbound/persistence/diesel_example_data_seed_repository.rs @@ -103,6 +103,7 @@ fn map_seed_users( user_rows.push(NewUserRow { id: *user.id().as_uuid(), display_name: user.display_name().as_ref(), + created_at: user.created_at(), }); let revision = i32::try_from(preferences.revision) diff --git a/backend/src/outbound/persistence/diesel_login_service.rs b/backend/src/outbound/persistence/diesel_login_service.rs index 37c40614..6f30a5fa 100644 --- a/backend/src/outbound/persistence/diesel_login_service.rs +++ b/backend/src/outbound/persistence/diesel_login_service.rs @@ -52,7 +52,7 @@ impl DieselLoginService { let display_name = DisplayName::new(FIXTURE_DISPLAY_NAME) .map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?; - let user = User::new(user_id.clone(), display_name); + let user = User::with_current_timestamp(user_id.clone(), display_name); self.user_repository .upsert(&user) diff --git a/backend/src/outbound/persistence/diesel_user_repository.rs b/backend/src/outbound/persistence/diesel_user_repository.rs index fb6b6a19..0d8de896 100644 --- a/backend/src/outbound/persistence/diesel_user_repository.rs +++ b/backend/src/outbound/persistence/diesel_user_repository.rs @@ -115,7 +115,7 @@ fn row_to_user(row: UserRow) -> Result { debug!(?err, "invalid display name loaded from database"); UserPersistenceError::query("invalid user record") })?; - Ok(User::new(user_id, display_name)) + Ok(User::new(user_id, display_name, row.created_at)) } #[async_trait] @@ -126,6 +126,7 @@ impl UserRepository for DieselUserRepository { let new_user = NewUserRow { id: *user.id().as_uuid(), display_name: user.display_name().as_ref(), + created_at: user.created_at(), }; diesel::insert_into(users::table) diff --git a/backend/src/outbound/persistence/models.rs b/backend/src/outbound/persistence/models.rs index e5cd9f44..9a047f7a 100644 --- a/backend/src/outbound/persistence/models.rs +++ b/backend/src/outbound/persistence/models.rs @@ -20,7 +20,6 @@ use super::schema::{ pub(crate) struct UserRow { pub id: Uuid, pub display_name: String, - #[expect(dead_code, reason = "schema field for future audit trail support")] pub created_at: DateTime, #[expect(dead_code, reason = "schema field for future audit trail support")] pub updated_at: DateTime, @@ -32,6 +31,7 @@ pub(crate) struct UserRow { pub(crate) struct NewUserRow<'a> { pub id: Uuid, pub display_name: &'a str, + pub created_at: DateTime, } /// Changeset struct for updating existing user records. diff --git a/backend/tests/adapter_guardrails/harness_defaults.rs b/backend/tests/adapter_guardrails/harness_defaults.rs index 8c81d89e..f46c09b7 100644 --- a/backend/tests/adapter_guardrails/harness_defaults.rs +++ b/backend/tests/adapter_guardrails/harness_defaults.rs @@ -87,10 +87,16 @@ pub(super) fn create_user_doubles( let users = RecordingUsersQuery::new(UsersResponse::Ok(vec![User::new( UserId::new("22222222-2222-2222-2222-222222222222").expect("fixture user id"), DisplayName::new("Ada Lovelace").expect("fixture display name"), + chrono::DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") + .expect("fixture timestamp") + .with_timezone(&chrono::Utc), )])); let profile = RecordingUserProfileQuery::new(UserProfileResponse::Ok(User::new( user_id.clone(), DisplayName::new("Ada Lovelace").expect("fixture display name"), + chrono::DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") + .expect("fixture timestamp") + .with_timezone(&chrono::Utc), ))); (login, users, profile) diff --git a/backend/tests/adapter_guardrails/steps.rs b/backend/tests/adapter_guardrails/steps.rs index 6e04c300..b29ab50c 100644 --- a/backend/tests/adapter_guardrails/steps.rs +++ b/backend/tests/adapter_guardrails/steps.rs @@ -221,6 +221,9 @@ pub(crate) fn the_client_connects_to_the_websocket_and_submits_a_display_name(wo user: User::new( UserId::new("33333333-3333-3333-3333-333333333333").expect("fixture user id"), DisplayName::new("Bob").expect("fixture display name"), + chrono::DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") + .expect("fixture timestamp") + .with_timezone(&chrono::Utc), ), }); diff --git a/backend/tests/diesel_user_repository.rs b/backend/tests/diesel_user_repository.rs index f3fe00e5..10ba2b78 100644 --- a/backend/tests/diesel_user_repository.rs +++ b/backend/tests/diesel_user_repository.rs @@ -43,7 +43,7 @@ fn sample_display_name() -> DisplayName { #[fixture] fn sample_user(sample_user_id: UserId, sample_display_name: DisplayName) -> User { - User::new(sample_user_id, sample_display_name) + User::with_current_timestamp(sample_user_id, sample_display_name) } // ----------------------------------------------------------------------------- diff --git a/backend/tests/ports_behaviour.rs b/backend/tests/ports_behaviour.rs index fd1c1373..157d184c 100644 --- a/backend/tests/ports_behaviour.rs +++ b/backend/tests/ports_behaviour.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use backend::domain::ports::{UserPersistenceError, UserRepository}; use backend::domain::{DisplayName, User, UserId}; +use chrono::{DateTime, Utc}; use futures::executor::block_on; use pg_embedded_setup_unpriv::TemporaryDatabase; use postgres::{Client, NoTls}; @@ -53,11 +54,15 @@ impl UserRepository for PgUserRepository { let mut guard = self.client.lock().expect("pg client poisoned"); let id = user.id().as_uuid(); let display = user.display_name().as_ref(); + let created_at = user.created_at().to_rfc3339(); guard .execute( - "INSERT INTO users (id, display_name) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET display_name = excluded.display_name", - &[id, &display], + "INSERT INTO users (id, display_name, created_at) + VALUES ($1, $2, ($3::text)::timestamptz) + ON CONFLICT (id) DO UPDATE + SET display_name = excluded.display_name, + created_at = excluded.created_at", + &[id, &display, &created_at], ) .map(|_| ()) .map_err(|err| UserPersistenceError::query(format_postgres_error(&err))) @@ -67,7 +72,9 @@ impl UserRepository for PgUserRepository { let mut guard = self.client.lock().expect("pg client poisoned"); let result = guard .query_opt( - "SELECT id, display_name FROM users WHERE id = $1", + "SELECT id, display_name, created_at::text AS created_at + FROM users + WHERE id = $1", &[id.as_uuid()], ) .map_err(|err| UserPersistenceError::query(format_postgres_error(&err)))?; @@ -75,7 +82,8 @@ impl UserRepository for PgUserRepository { if let Some(row) = result { let id: Uuid = row.get(0); let display: String = row.get(1); - let user = User::try_from_strings(id.to_string(), display) + let created_at = parse_timestamptz(row.get(2)).map_err(UserPersistenceError::query)?; + let user = User::try_from_strings_at(id.to_string(), display, created_at) .map_err(|err| UserPersistenceError::query(err.to_string()))?; Ok(Some(user)) } else { @@ -84,6 +92,15 @@ impl UserRepository for PgUserRepository { } } +fn parse_timestamptz(value: String) -> Result, String> { + DateTime::parse_from_rfc3339(value.as_str()) + .or_else(|_| DateTime::parse_from_str(value.as_str(), "%Y-%m-%d %H:%M:%S%.f%#z")) + .or_else(|_| DateTime::parse_from_str(value.as_str(), "%Y-%m-%d %H:%M:%S%.f%:z")) + .or_else(|_| DateTime::parse_from_str(value.as_str(), "%Y-%m-%d %H:%M:%S%.f%z")) + .map(|parsed| parsed.with_timezone(&Utc)) + .map_err(|err| format!("invalid timestamptz '{value}': {err}")) +} + struct RepoContext { repository: PgUserRepository, database_url: String, diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 8321cbb7..3b898d02 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -148,9 +148,18 @@ them requires escalation, not a workaround. `idx_users_created_at_id` on `(created_at, id)` and a matching down migration; `make fmt`, `make markdownlint`, `make check-fmt`, `make lint`, and `make test` passed. -- [ ] M2: Domain and port updates -- `User` exposes `created_at`, +- [x] M2: Domain and port updates -- `User` exposes `created_at`, `UserCursorKey` defined, `UsersQuery` and `UserRepository` extended with paginated reads, `FixtureUsersQuery` updated. +- [x] 2026-05-01: M2 kept the first port change additive by defining + default paginated trait methods that return a stable internal/query error + until the Diesel adapter is implemented in M3. `FixtureUsersQuery` overrides + the new query method immediately so handler-only tests have a deterministic + fallback path. +- [x] 2026-05-01: M2 completed with `UserCursorKey`, + `ListUsersPageRequest`, and `UsersPage`; `UserDto` accepts legacy payloads + without `createdAt` but serialises the new field as `createdAt`. `make fmt`, + `make markdownlint`, `make check-fmt`, `make lint`, and `make test` passed. - [ ] M3: Diesel adapter implements the keyset query (`limit + 1` fetch, composite filter, asc ordering); covered by unit tests with a stubbed `UserRepository` for error mapping and an integration test against @@ -182,6 +191,16 @@ them requires escalation, not a workaround. be running` report). No active PostgreSQL worker was left behind, and an immediate rerun passed all Rust and frontend tests without code changes, so this was treated as a transient fixture startup failure. +- 2026-05-01: Adding `created_at` to `User` exposed PostgreSQL's timestamp + precision boundary: Diesel round-trips `timestamptz` values at microsecond + precision, while `Utc::now()` supplies nanoseconds. `User::new` now + normalises the domain timestamp to microsecond precision so persisted users, + cursor keys, and test equality all use the same precision. +- 2026-05-01: `backend/tests/ports_behaviour.rs` had an independent + PostgreSQL test adapter that still inserted only `id` and `display_name`. + It now persists and reads `created_at`, using text casts because the direct + `postgres` test client in this repository is not compiled with chrono + `ToSql` / `FromSql` support. ## Decision log @@ -211,6 +230,24 @@ them requires escalation, not a workaround. required by the roadmap. Date/Author: 2026-04-28, drafting agent. +- Decision: make M2's port additions additive by giving + `UserRepository::list_page` and `UsersQuery::list_users_page` default + implementations that return stable query/internal errors until the Diesel + adapter is implemented. + Rationale: this keeps the M2 commit focused on domain and port shape while + avoiding a half-implemented persistence path. The fixture query overrides + the method immediately, so handler-only tests still have a deterministic + no-database path. + Date/Author: 2026-05-01, implementation agent. + +- Decision: normalise `User::created_at` to microsecond precision in the + domain constructor. + Rationale: users are persisted to PostgreSQL `timestamptz`, which stores + microsecond precision. Normalising once at the domain boundary avoids + adapter-specific timestamp drift and keeps cursor keys based on the same + values that will be read back from storage. + Date/Author: 2026-05-01, implementation agent. + ## Outcomes & retrospective (to be filled in at completion) From e99d1df9d87843f70eadde0fc279e138d8cc8fe1 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 19:34:13 +0200 Subject: [PATCH 05/18] Implement Diesel users keyset pagination Add the Diesel `list_page` implementation over `(created_at, id)` with `limit + 1` fetch semantics for next and previous cursors. Trim the overflow row in `DieselUsersQuery` so callers receive a stable `UsersPage` with `has_more` while repository rows stay in ascending order. Cover forward and reverse page boundaries with unit and embedded PostgreSQL tests. --- backend/src/domain/ports/user_repository.rs | 4 + .../persistence/diesel_user_repository.rs | 86 +++++++- .../persistence/diesel_users_query.rs | 189 +++++++++++++++++- backend/tests/diesel_user_repository.rs | 80 +++++++- ...-users-offset-pagination-with-new-crate.md | 29 ++- 5 files changed, 381 insertions(+), 7 deletions(-) diff --git a/backend/src/domain/ports/user_repository.rs b/backend/src/domain/ports/user_repository.rs index 09a01547..f28c6194 100644 --- a/backend/src/domain/ports/user_repository.rs +++ b/backend/src/domain/ports/user_repository.rs @@ -68,6 +68,10 @@ pub trait UserRepository: Send + Sync { async fn find_by_id(&self, id: &UserId) -> Result, UserPersistenceError>; /// Fetch a keyset-ordered users page. + /// + /// Implementations should fetch one more row than `request.limit()` when + /// possible so the caller can detect whether another page exists. Returned + /// rows remain in `(created_at ASC, id ASC)` order for both directions. async fn list_page( &self, _request: ListUsersPageRequest, diff --git a/backend/src/outbound/persistence/diesel_user_repository.rs b/backend/src/outbound/persistence/diesel_user_repository.rs index 0d8de896..04607df6 100644 --- a/backend/src/outbound/persistence/diesel_user_repository.rs +++ b/backend/src/outbound/persistence/diesel_user_repository.rs @@ -15,8 +15,10 @@ use diesel::upsert::excluded; use diesel_async::RunQueryDsl; use tracing::debug; -use crate::domain::ports::{UserPersistenceError, UserRepository}; -use crate::domain::{DisplayName, User, UserId}; +use pagination::Direction; + +use crate::domain::ports::{ListUsersPageRequest, UserPersistenceError, UserRepository}; +use crate::domain::{DisplayName, User, UserCursorKey, UserId}; use super::models::{NewUserRow, UserRow}; use super::pool::{DbPool, PoolError}; @@ -118,6 +120,18 @@ fn row_to_user(row: UserRow) -> Result { Ok(User::new(user_id, display_name, row.created_at)) } +fn row_to_users(rows: Vec) -> Result, UserPersistenceError> { + rows.into_iter().map(row_to_user).collect() +} + +fn fetch_limit(limit: usize) -> Result { + let with_overflow_row = limit + .checked_add(1) + .ok_or_else(|| UserPersistenceError::query("page limit overflow"))?; + i64::try_from(with_overflow_row) + .map_err(|_| UserPersistenceError::query("page limit exceeds database range")) +} + #[async_trait] impl UserRepository for DieselUserRepository { async fn upsert(&self, user: &User) -> Result<(), UserPersistenceError> { @@ -156,6 +170,74 @@ impl UserRepository for DieselUserRepository { None => Ok(None), } } + + async fn list_page( + &self, + request: ListUsersPageRequest, + ) -> Result, UserPersistenceError> { + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + let (cursor, limit) = request.into_parts(); + let fetch_limit = fetch_limit(limit)?; + + let rows = match cursor { + None => users::table + .order((users::created_at.asc(), users::id.asc())) + .limit(fetch_limit) + .select(UserRow::as_select()) + .load(&mut conn) + .await + .map_err(map_diesel_error)?, + Some(cursor) => { + let (key, direction) = cursor.into_parts(); + match direction { + Direction::Next => list_page_after(&mut conn, key, fetch_limit).await?, + Direction::Prev => list_page_before(&mut conn, key, fetch_limit).await?, + } + } + }; + + row_to_users(rows) + } +} + +async fn list_page_after( + conn: &mut diesel_async::AsyncPgConnection, + key: UserCursorKey, + fetch_limit: i64, +) -> Result, UserPersistenceError> { + users::table + .filter( + users::created_at.gt(key.created_at).or(users::created_at + .eq(key.created_at) + .and(users::id.gt(key.id))), + ) + .order((users::created_at.asc(), users::id.asc())) + .limit(fetch_limit) + .select(UserRow::as_select()) + .load(conn) + .await + .map_err(map_diesel_error) +} + +async fn list_page_before( + conn: &mut diesel_async::AsyncPgConnection, + key: UserCursorKey, + fetch_limit: i64, +) -> Result, UserPersistenceError> { + let mut rows = users::table + .filter( + users::created_at.lt(key.created_at).or(users::created_at + .eq(key.created_at) + .and(users::id.lt(key.id))), + ) + .order((users::created_at.desc(), users::id.desc())) + .limit(fetch_limit) + .select(UserRow::as_select()) + .load(conn) + .await + .map_err(map_diesel_error)?; + rows.reverse(); + Ok(rows) } #[cfg(test)] diff --git a/backend/src/outbound/persistence/diesel_users_query.rs b/backend/src/outbound/persistence/diesel_users_query.rs index e7cc0e0c..0d93a595 100644 --- a/backend/src/outbound/persistence/diesel_users_query.rs +++ b/backend/src/outbound/persistence/diesel_users_query.rs @@ -6,9 +6,10 @@ use std::sync::Arc; use async_trait::async_trait; +use pagination::Direction; -use crate::domain::ports::{UserRepository, UsersQuery}; -use crate::domain::{Error, User, UserId}; +use crate::domain::ports::{ListUsersPageRequest, UserRepository, UsersPage, UsersQuery}; +use crate::domain::{Error, User, UserCursorKey, UserId}; use super::diesel_user_repository::DieselUserRepository; use super::user_persistence_error_mapping::map_user_persistence_error; @@ -49,6 +50,44 @@ impl UsersQuery for DieselUsersQuery { None => Ok(Vec::new()), } } + + async fn list_users_page( + &self, + _authenticated_user: &UserId, + request: ListUsersPageRequest, + ) -> Result { + let direction = page_direction(&request); + let limit = request.limit(); + let mut rows = self + .user_repository + .list_page(request) + .await + .map_err(map_user_persistence_error)?; + + let has_more = rows.len() > limit; + if has_more { + trim_overflow_row(&mut rows, limit, direction); + } + + Ok(UsersPage::new(rows, has_more)) + } +} + +fn page_direction(request: &ListUsersPageRequest) -> Direction { + request.cursor().map_or( + Direction::Next, + pagination::Cursor::::direction, + ) +} + +fn trim_overflow_row(rows: &mut Vec, limit: usize, direction: Direction) { + match direction { + Direction::Next => rows.truncate(limit), + Direction::Prev => { + let overflow = rows.len().saturating_sub(limit); + rows.drain(0..overflow); + } + } } #[cfg(test)] @@ -58,6 +97,8 @@ mod tests { use super::*; use crate::domain::ErrorCode; + use chrono::{DateTime, Utc}; + use pagination::Cursor; use rstest::rstest; type TestResult = Result>; @@ -81,6 +122,8 @@ mod tests { struct StubState { stored_user: Option, find_failure: Option, + page_rows: Vec, + list_failure: Option, } #[derive(Default)] @@ -98,6 +141,15 @@ mod tests { } } + fn with_page_rows(page_rows: Vec) -> Self { + Self { + state: Mutex::new(StubState { + page_rows, + ..StubState::default() + }), + } + } + fn set_find_failure(&self, failure: StubFailure) -> Result<(), UserPersistenceError> { self.state .lock() @@ -105,6 +157,14 @@ mod tests { .find_failure = Some(failure); Ok(()) } + + fn set_list_failure(&self, failure: StubFailure) -> Result<(), UserPersistenceError> { + self.state + .lock() + .map_err(|_| UserPersistenceError::query("state lock"))? + .list_failure = Some(failure); + Ok(()) + } } #[async_trait] @@ -127,6 +187,20 @@ mod tests { .filter(|user| user.id() == id) .cloned()) } + + async fn list_page( + &self, + _request: ListUsersPageRequest, + ) -> Result, UserPersistenceError> { + let state = self + .state + .lock() + .map_err(|_| UserPersistenceError::query("state lock"))?; + if let Some(failure) = state.list_failure { + return Err(failure.to_error()); + } + Ok(state.page_rows.clone()) + } } fn user_id(id: &str) -> TestResult { @@ -137,6 +211,29 @@ mod tests { Ok(User::try_from_strings(id, display_name)?) } + fn timestamp(value: &str) -> TestResult> { + Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) + } + + fn user_at(id: &str, display_name: &str, created_at: &str) -> TestResult { + Ok(User::try_from_strings_at( + id, + display_name, + timestamp(created_at)?, + )?) + } + + fn request_with_cursor( + user: &User, + direction: Direction, + limit: usize, + ) -> ListUsersPageRequest { + ListUsersPageRequest::new( + Some(Cursor::with_direction(UserCursorKey::from(user), direction)), + limit, + ) + } + #[tokio::test] async fn list_users_returns_authenticated_user_when_present() -> TestResult { let auth_user = user("11111111-1111-1111-1111-111111111111", "Ada Lovelace")?; @@ -183,4 +280,92 @@ mod tests { assert_eq!(err.code(), expected_code); Ok(()) } + + #[tokio::test] + async fn list_users_page_trims_forward_overflow_row() -> TestResult { + let rows = vec![ + user_at( + "11111111-1111-1111-1111-111111111111", + "Ada One", + "2026-01-01T00:00:00Z", + )?, + user_at( + "22222222-2222-2222-2222-222222222222", + "Ada Two", + "2026-01-02T00:00:00Z", + )?, + user_at( + "33333333-3333-3333-3333-333333333333", + "Ada Three", + "2026-01-03T00:00:00Z", + )?, + ]; + let repository = Arc::new(StubUserRepository::with_page_rows(rows.clone())); + let query = DieselUsersQuery::from_repository(repository); + + let page = query + .list_users_page( + rows[0].id(), + ListUsersPageRequest::new(None, rows.len() - 1), + ) + .await?; + + assert_eq!(page.rows(), &rows[0..2]); + assert!(page.has_more()); + Ok(()) + } + + #[tokio::test] + async fn list_users_page_trims_reverse_overflow_row() -> TestResult { + let rows = vec![ + user_at( + "11111111-1111-1111-1111-111111111111", + "Overflow", + "2026-01-01T00:00:00Z", + )?, + user_at( + "22222222-2222-2222-2222-222222222222", + "Ada Two", + "2026-01-02T00:00:00Z", + )?, + user_at( + "33333333-3333-3333-3333-333333333333", + "Ada Three", + "2026-01-03T00:00:00Z", + )?, + ]; + let repository = Arc::new(StubUserRepository::with_page_rows(rows.clone())); + let query = DieselUsersQuery::from_repository(repository); + let request = request_with_cursor(&rows[2], Direction::Prev, rows.len() - 1); + + let page = query.list_users_page(rows[2].id(), request).await?; + + assert_eq!(page.rows(), &rows[1..3]); + assert!(page.has_more()); + Ok(()) + } + + #[rstest] + #[case(StubFailure::Connection, ErrorCode::ServiceUnavailable)] + #[case(StubFailure::Query, ErrorCode::InternalError)] + #[tokio::test] + async fn list_users_page_maps_persistence_failures( + #[case] failure: StubFailure, + #[case] expected_code: ErrorCode, + ) -> TestResult { + let repository = Arc::new(StubUserRepository::default()); + repository.set_list_failure(failure)?; + let query = DieselUsersQuery::from_repository(repository); + + let err = query + .list_users_page( + &user_id("11111111-1111-1111-1111-111111111111")?, + ListUsersPageRequest::new(None, 20), + ) + .await + .expect_err("repository failures should map to domain errors"); + + assert_eq!(err.code(), expected_code); + Ok(()) + } } diff --git a/backend/tests/diesel_user_repository.rs b/backend/tests/diesel_user_repository.rs index 10ba2b78..35f2cfb1 100644 --- a/backend/tests/diesel_user_repository.rs +++ b/backend/tests/diesel_user_repository.rs @@ -13,9 +13,11 @@ //! for each step. use std::sync::{Arc, Mutex}; -use backend::domain::ports::{UserPersistenceError, UserRepository}; -use backend::domain::{DisplayName, User, UserId}; +use backend::domain::ports::{ListUsersPageRequest, UserPersistenceError, UserRepository}; +use backend::domain::{DisplayName, User, UserCursorKey, UserId}; use backend::outbound::persistence::{DbPool, DieselUserRepository, PoolConfig}; +use chrono::{DateTime, Utc}; +use pagination::{Cursor, Direction}; use pg_embedded_setup_unpriv::TemporaryDatabase; use rstest::{fixture, rstest}; use rstest_bdd_macros::{given, then, when}; @@ -46,6 +48,17 @@ fn sample_user(sample_user_id: UserId, sample_display_name: DisplayName) -> User User::with_current_timestamp(sample_user_id, sample_display_name) } +fn fixture_timestamp(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .expect("fixture timestamp is valid") + .with_timezone(&Utc) +} + +fn paginated_user(id: &str, display_name: &str, created_at: &str) -> User { + User::try_from_strings_at(id, display_name, fixture_timestamp(created_at)) + .expect("fixture user is valid") +} + // ----------------------------------------------------------------------------- // Test Context // ----------------------------------------------------------------------------- @@ -285,6 +298,69 @@ fn diesel_find_nonexistent_returns_none(diesel_world: Option) { ); } +#[rstest] +fn diesel_list_page_uses_created_at_id_keyset_order(diesel_world: Option) { + let Some(world) = diesel_world else { + eprintln!("SKIP-TEST-CLUSTER: diesel_list_page_uses_created_at_id_keyset_order skipped"); + return; + }; + + let users = vec![ + paginated_user( + "11111111-1111-1111-1111-111111111111", + "Ada One", + "2026-01-01T00:00:00Z", + ), + paginated_user( + "22222222-2222-2222-2222-222222222222", + "Ada Two", + "2026-01-02T00:00:00Z", + ), + paginated_user( + "33333333-3333-3333-3333-333333333333", + "Ada Three", + "2026-01-03T00:00:00Z", + ), + paginated_user( + "44444444-4444-4444-4444-444444444444", + "Ada Four", + "2026-01-04T00:00:00Z", + ), + ]; + + with_context_async( + &world, + |_| users, + |repo, users| async move { + for user in users.iter().rev() { + repo.upsert(user).await?; + } + + let first_page = repo.list_page(ListUsersPageRequest::new(None, 2)).await?; + assert_eq!(first_page.as_slice(), &users[0..3]); + + let next_cursor = + Cursor::with_direction(UserCursorKey::from(&users[0]), Direction::Next); + let next_page = repo + .list_page(ListUsersPageRequest::new(Some(next_cursor), 2)) + .await?; + assert_eq!(next_page.as_slice(), &users[1..4]); + + let prev_cursor = + Cursor::with_direction(UserCursorKey::from(&users[3]), Direction::Prev); + let prev_page = repo + .list_page(ListUsersPageRequest::new(Some(prev_cursor), 2)) + .await?; + assert_eq!(prev_page.as_slice(), &users[0..3]); + + Ok::<(), UserPersistenceError>(()) + }, + |_, result| { + result.expect("paginated list succeeds"); + }, + ); +} + #[rstest] fn diesel_reports_errors_when_schema_missing( diesel_world: Option, diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 3b898d02..083b295b 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -160,10 +160,23 @@ them requires escalation, not a workaround. `ListUsersPageRequest`, and `UsersPage`; `UserDto` accepts legacy payloads without `createdAt` but serialises the new field as `createdAt`. `make fmt`, `make markdownlint`, `make check-fmt`, `make lint`, and `make test` passed. -- [ ] M3: Diesel adapter implements the keyset query (`limit + 1` fetch, +- [x] M3: Diesel adapter implements the keyset query (`limit + 1` fetch, composite filter, asc ordering); covered by unit tests with a stubbed `UserRepository` for error mapping and an integration test against embedded Postgres. +- [x] 2026-05-01: M3 implemented `DieselUserRepository::list_page` using + `(created_at, id)` keyset predicates, one-row overflow fetches, and stable + ascending return order for both forward and reverse pages. Reverse pages + query descending for index-friendly "before cursor" access, then reverse + rows before returning to the query port. +- [x] 2026-05-01: M3 implemented `DieselUsersQuery::list_users_page` overflow + trimming and error mapping. Forward pages trim the trailing overflow row; + reverse pages trim the leading overflow row because the repository has + already restored ascending order. `cargo check -p backend`, focused + `diesel_users_query` and `diesel_user_repository` tests, `make fmt`, `make + check-fmt`, `make lint`, and `make test` passed. The full test gate ran + 1202 Rust tests successfully before the frontend and token workspace tests + also passed. - [ ] M4: `list_users` handler rewritten to consume `web::Query`, decode cursor, call the port, build links from request URL, and return `Paginated`; OpenAPI annotations updated; existing handler @@ -201,6 +214,11 @@ them requires escalation, not a workaround. It now persists and reads `created_at`, using text casts because the direct `postgres` test client in this repository is not compiled with chrono `ToSql` / `FromSql` support. +- 2026-05-01: Diesel's reverse keyset query is clearest and cheapest when it + asks PostgreSQL for rows before the cursor in descending order, applies the + same `limit + 1` cap, and reverses the in-memory page. That leaves a reverse + overflow row at the front of the returned ascending slice, so the query port + must trim from the leading edge for `Direction::Prev`. ## Decision log @@ -248,6 +266,15 @@ them requires escalation, not a workaround. values that will be read back from storage. Date/Author: 2026-05-01, implementation agent. +- Decision: keep `UserRepository::list_page` rows in `(created_at ASC, + id ASC)` order for both cursor directions. + Rationale: stable repository ordering keeps response assembly simple and + prevents inbound code from needing to know whether the page was fetched + forwards or backwards. For reverse pages, the Diesel adapter performs the + efficient descending SQL query internally, reverses the short page in + memory, and lets `DieselUsersQuery` trim the leading overflow row. + Date/Author: 2026-05-01, implementation agent. + ## Outcomes & retrospective (to be filled in at completion) From b145c837c59a8c9f2f4a3de392d2c7371974fc53 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 19:48:43 +0200 Subject: [PATCH 06/18] Return paginated users from HTTP handler Switch `GET /api/v1/users` to decode opaque users cursors, call the paginated users query port, and return the shared pagination envelope. Add endpoint-local limit validation so oversized users pages return the project error schema while the generic pagination crate keeps its existing normalisation contract. Update OpenAPI schema tokens and existing handler, startup, and guardrail tests for the new `data` envelope. --- backend/src/doc.rs | 3 + backend/src/inbound/http/mod.rs | 1 + backend/src/inbound/http/schemas.rs | 10 +- backend/src/inbound/http/users.rs | 57 ++--- backend/src/inbound/http/users/tests.rs | 78 ++++++- backend/src/inbound/http/users_pagination.rs | 210 ++++++++++++++++++ backend/tests/adapter_guardrails/steps.rs | 5 +- backend/tests/diesel_login_users_adapters.rs | 10 +- backend/tests/user_state_startup_modes_bdd.rs | 5 +- ...-users-offset-pagination-with-new-crate.md | 32 ++- 10 files changed, 363 insertions(+), 48 deletions(-) create mode 100644 backend/src/inbound/http/users_pagination.rs diff --git a/backend/src/doc.rs b/backend/src/doc.rs index 18182bcd..09db03dc 100644 --- a/backend/src/doc.rs +++ b/backend/src/doc.rs @@ -25,6 +25,7 @@ use crate::inbound::http::offline::{ use crate::inbound::http::schemas::{ ErrorCodeSchema, ErrorSchema, InterestThemeIdSchema, UserInterestsSchema, UserSchema, }; +use crate::inbound::http::users_pagination::{PaginatedUsersResponse, PaginationLinksSchema}; use crate::inbound::http::walk_sessions::{ CreateWalkSessionRequestBody, CreateWalkSessionResponseBody, WalkCompletionSummaryResponseBody, WalkPrimaryStatBody, WalkSecondaryStatBody, @@ -92,6 +93,8 @@ impl Modify for SecurityAddon { UserSchema, UserInterestsSchema, InterestThemeIdSchema, + PaginationLinksSchema, + PaginatedUsersResponse, ErrorSchema, ErrorCodeSchema, ExploreCatalogueResponse, diff --git a/backend/src/inbound/http/mod.rs b/backend/src/inbound/http/mod.rs index 17b83b9f..79461852 100644 --- a/backend/src/inbound/http/mod.rs +++ b/backend/src/inbound/http/mod.rs @@ -17,6 +17,7 @@ pub mod state; #[cfg(test)] pub mod test_utils; pub mod users; +pub mod users_pagination; pub mod validation; pub mod walk_sessions; diff --git a/backend/src/inbound/http/schemas.rs b/backend/src/inbound/http/schemas.rs index ff0f422c..c1b5f3f9 100644 --- a/backend/src/inbound/http/schemas.rs +++ b/backend/src/inbound/http/schemas.rs @@ -95,6 +95,14 @@ pub struct UserSchema { example = "Ada Lovelace" )] display_name: String, + /// Creation timestamp used as the first users pagination key. + #[schema( + rename = "createdAt", + value_type = String, + format = "date-time", + example = "2026-01-01T00:00:00Z" + )] + created_at: String, } /// OpenAPI schema for [`crate::domain::InterestThemeId`]. @@ -180,7 +188,7 @@ mod tests { #[test] fn user_schema_has_expected_name() -> TestResult { - assert_schema_contains::("crate.domain.User", &["displayName"]) + assert_schema_contains::("crate.domain.User", &["displayName", "createdAt"]) } #[test] diff --git a/backend/src/inbound/http/users.rs b/backend/src/inbound/http/users.rs index eb36a61a..1400c089 100644 --- a/backend/src/inbound/http/users.rs +++ b/backend/src/inbound/http/users.rs @@ -16,10 +16,14 @@ use crate::inbound::http::ApiResult; use crate::inbound::http::schemas::{ErrorSchema, UserInterestsSchema, UserSchema}; use crate::inbound::http::session::SessionContext; use crate::inbound::http::state::HttpState; -use actix_web::{HttpResponse, get, post, put, web}; +use crate::inbound::http::users_pagination::{ + PaginatedUsersResponse, UsersListQueryParams, build_users_page_response, + parse_users_page_params, +}; +use actix_web::{HttpRequest, HttpResponse, get, post, put, web}; +use pagination::Paginated; use serde::{Deserialize, Serialize}; use serde_json::json; -use utoipa::{PartialSchema, ToSchema}; /// Login request body for `POST /api/v1/login`. /// @@ -48,39 +52,6 @@ pub struct InterestsRequest { /// Maximum interest theme IDs per user; prevents payload bloat and ensures /// reasonable UI rendering. const INTEREST_THEME_IDS_MAX: usize = 100; -/// Maximum users returned by the list_users endpoint; limits response size for -/// PWA clients. -const USERS_LIST_MAX: usize = 100; - -// OpenAPI helper: UsersListResponse exists to provide PartialSchema and ToSchema -// impls that describe a bounded array response and register UserSchema for -// OpenAPI generation. -/// Schema token for utoipa representing an array of `UserSchema` with a max -/// items constraint. -struct UsersListResponse; - -impl PartialSchema for UsersListResponse { - fn schema() -> utoipa::openapi::RefOr { - utoipa::openapi::schema::ArrayBuilder::new() - .items(utoipa::openapi::RefOr::Ref( - utoipa::openapi::Ref::from_schema_name(UserSchema::name()), - )) - .max_items(Some(USERS_LIST_MAX)) - .into() - } -} - -impl ToSchema for UsersListResponse { - fn schemas( - schemas: &mut Vec<( - String, - utoipa::openapi::RefOr, - )>, - ) { - ::schemas(schemas); - } -} - #[derive(Debug)] enum InterestsRequestError { TooManyInterestThemeIds { @@ -228,8 +199,12 @@ fn map_interests_request_error(err: InterestsRequestError) -> Error { #[utoipa::path( get, path = "/api/v1/users", + params( + ("cursor" = Option, Query, description = "Opaque users pagination cursor"), + ("limit" = Option, Query, description = "Number of users to return, default 20, max 100") + ), responses( - (status = 200, description = "Users", body = UsersListResponse), + (status = 200, description = "Users", body = PaginatedUsersResponse), (status = 400, description = "Invalid request", body = ErrorSchema), (status = 401, description = "Unauthorised", body = ErrorSchema), (status = 403, description = "Forbidden", body = ErrorSchema), @@ -244,10 +219,14 @@ fn map_interests_request_error(err: InterestsRequestError) -> Error { pub async fn list_users( state: web::Data, session: SessionContext, -) -> ApiResult>> { + request: HttpRequest, + params: web::Query, +) -> ApiResult>> { let user_id = session.require_user_id()?; - let data = state.users.list_users(&user_id).await?; - Ok(web::Json(data)) + let (page_params, page_request, direction) = parse_users_page_params(params.into_inner())?; + let page = state.users.list_users_page(&user_id, page_request).await?; + let response = build_users_page_response(&request, &page_params, page, direction)?; + Ok(web::Json(response)) } /// Fetch the authenticated user's profile. diff --git a/backend/src/inbound/http/users/tests.rs b/backend/src/inbound/http/users/tests.rs index 750f0ed3..28cf90b4 100644 --- a/backend/src/inbound/http/users/tests.rs +++ b/backend/src/inbound/http/users/tests.rs @@ -198,19 +198,93 @@ async fn list_users_returns_camel_case_json() -> TestResult { assert!(users_res.status().is_success()); let body = actix_test::read_body(users_res).await; let value: Value = serde_json::from_slice(&body)?; + assert_eq!(value.get("limit").and_then(Value::as_u64), Some(20)); + let links = value + .get("links") + .and_then(Value::as_object) + .ok_or_else(|| io::Error::other("expected links object"))?; + assert!( + links + .get("self") + .and_then(Value::as_str) + .is_some_and(|link| link.ends_with("/api/v1/users?limit=20")) + ); + assert!(links.get("next").is_none()); + assert!(links.get("prev").is_none()); + let first = value - .as_array() - .ok_or_else(|| io::Error::other("expected users response array"))? + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| io::Error::other("expected users response data array"))? .first() .ok_or_else(|| io::Error::other("expected at least one user in response"))?; assert_eq!( first.get("displayName").and_then(Value::as_str), Some("Ada Lovelace") ); + assert!(first.get("createdAt").is_some()); assert!(first.get("display_name").is_none()); Ok(()) } +#[rstest] +#[case("/api/v1/users?limit=0")] +#[case("/api/v1/users?limit=200")] +#[case("/api/v1/users?limit=not-a-number")] +#[actix_web::test] +async fn list_users_rejects_invalid_limits(#[case] path: &str) -> TestResult { + let app = actix_test::init_service(test_app()).await; + let cookie = login_and_get_cookie(&app).await?; + + let users_req = actix_test::TestRequest::get() + .uri(path) + .cookie(cookie) + .to_request(); + let users_res = actix_test::call_service(&app, users_req).await; + + assert_eq!(users_res.status(), actix_web::http::StatusCode::BAD_REQUEST); + let body = actix_test::read_body(users_res).await; + let value: Value = serde_json::from_slice(&body)?; + assert_eq!( + value.get("message").and_then(Value::as_str), + Some("limit must be between 1 and 100") + ); + let details = get_details_object(&value)?; + assert_eq!(details.get("field").and_then(Value::as_str), Some("limit")); + assert_eq!( + details.get("code").and_then(Value::as_str), + Some("invalid_limit") + ); + Ok(()) +} + +#[actix_web::test] +async fn list_users_rejects_invalid_cursor() -> TestResult { + let app = actix_test::init_service(test_app()).await; + let cookie = login_and_get_cookie(&app).await?; + + let users_req = actix_test::TestRequest::get() + .uri("/api/v1/users?cursor=not-a-cursor") + .cookie(cookie) + .to_request(); + let users_res = actix_test::call_service(&app, users_req).await; + + assert_eq!(users_res.status(), actix_web::http::StatusCode::BAD_REQUEST); + let body = actix_test::read_body(users_res).await; + let value: Value = serde_json::from_slice(&body)?; + assert_eq!( + value.get("message").and_then(Value::as_str), + Some("cursor is invalid") + ); + let details = get_details_object(&value)?; + assert_eq!(details.get("field").and_then(Value::as_str), Some("cursor")); + assert_eq!( + details.get("code").and_then(Value::as_str), + Some("invalid_cursor") + ); + Ok(()) +} + #[actix_web::test] async fn list_users_rejects_without_session() { let app = actix_test::init_service(test_app()).await; diff --git a/backend/src/inbound/http/users_pagination.rs b/backend/src/inbound/http/users_pagination.rs new file mode 100644 index 00000000..98ec2dea --- /dev/null +++ b/backend/src/inbound/http/users_pagination.rs @@ -0,0 +1,210 @@ +//! Pagination helpers for the users HTTP adapter. +//! +//! The users endpoint owns HTTP query parsing, cursor decoding, link +//! construction, and OpenAPI response tokens. Domain ports receive decoded, +//! transport-neutral pagination requests. + +use actix_web::HttpRequest; +use pagination::{Cursor, Direction, MAX_LIMIT, PageParams, Paginated, PaginationLinks}; +use serde::Deserialize; +use serde_json::json; +use url::Url; +use utoipa::ToSchema; + +use crate::domain::ports::{ListUsersPageRequest, UsersPage}; +use crate::domain::{Error, User, UserCursorKey}; +use crate::inbound::http::schemas::UserSchema; + +/// Raw users list query parameters. +/// +/// Keeping `limit` as a string lets the handler return the shared API error +/// envelope for malformed or oversized values instead of Actix's extractor +/// error body. +#[derive(Debug, Clone, Deserialize)] +pub struct UsersListQueryParams { + cursor: Option, + limit: Option, +} + +/// OpenAPI schema for pagination links in `GET /api/v1/users` responses. +#[derive(ToSchema)] +#[expect( + dead_code, + reason = "Used only for OpenAPI schema generation via utoipa" +)] +pub struct PaginationLinksSchema { + /// Canonical URL for the current page. + #[schema( + rename = "self", + example = "https://example.test/api/v1/users?limit=20" + )] + self_: String, + /// URL for the next page, when a following page exists. + #[schema(example = "https://example.test/api/v1/users?limit=20&cursor=opaque")] + next: Option, + /// URL for the previous page, when an earlier page exists. + #[schema(example = "https://example.test/api/v1/users?limit=20&cursor=opaque")] + prev: Option, +} + +/// OpenAPI schema for the paginated users response envelope. +#[derive(ToSchema)] +#[expect( + dead_code, + reason = "Used only for OpenAPI schema generation via utoipa" +)] +pub struct PaginatedUsersResponse { + /// Users in stable `(createdAt, id)` order. + data: Vec, + /// Effective page size for this response. + #[schema(minimum = 1, maximum = 100, example = 20)] + limit: usize, + /// Hypermedia links for page navigation. + links: PaginationLinksSchema, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsersPageDirection { + First, + Next, + Prev, +} + +/// Convert raw HTTP query parameters into pagination request objects. +/// +/// # Errors +/// +/// Returns an invalid request error when `limit` is malformed, zero, above +/// [`MAX_LIMIT`], or when `cursor` is not an opaque users cursor. +pub fn parse_users_page_params( + params: UsersListQueryParams, +) -> Result<(PageParams, ListUsersPageRequest, UsersPageDirection), Error> { + let limit = parse_limit(params.limit.as_deref())?; + let page_params = PageParams::new(params.cursor.clone(), limit) + .map_err(|_| invalid_limit_error(params.limit.as_deref()))?; + let cursor = params + .cursor + .as_deref() + .map(Cursor::::decode) + .transpose() + .map_err(|_| invalid_cursor_error())?; + let direction = cursor + .as_ref() + .map_or(UsersPageDirection::First, cursor_direction); + let request = ListUsersPageRequest::new(cursor, page_params.limit()); + Ok((page_params, request, direction)) +} + +/// Build the paginated HTTP response envelope for one users page. +/// +/// # Errors +/// +/// Returns an internal error if cursor encoding or request URL reconstruction +/// fails. +pub fn build_users_page_response( + request: &HttpRequest, + params: &PageParams, + page: UsersPage, + direction: UsersPageDirection, +) -> Result, Error> { + let has_more = page.has_more(); + let rows = page.into_rows(); + let next_cursor = next_cursor(&rows, has_more, direction)?; + let prev_cursor = prev_cursor(&rows, has_more, direction)?; + let request_url = current_request_url(request)?; + let links = PaginationLinks::from_request( + &request_url, + params, + next_cursor.as_deref(), + prev_cursor.as_deref(), + ); + + Ok(Paginated::new(rows, params.limit(), links)) +} + +fn parse_limit(raw: Option<&str>) -> Result, Error> { + let Some(raw) = raw else { + return Ok(None); + }; + let value = raw + .parse::() + .map_err(|_| invalid_limit_error(Some(raw)))?; + if value == 0 || value > MAX_LIMIT { + return Err(invalid_limit_error(Some(raw))); + } + Ok(Some(value)) +} + +fn cursor_direction(cursor: &Cursor) -> UsersPageDirection { + match cursor.direction() { + Direction::Next => UsersPageDirection::Next, + Direction::Prev => UsersPageDirection::Prev, + } +} + +fn next_cursor( + rows: &[User], + has_more: bool, + direction: UsersPageDirection, +) -> Result, Error> { + let should_emit = match direction { + UsersPageDirection::First | UsersPageDirection::Next => has_more, + UsersPageDirection::Prev => true, + }; + encode_boundary_cursor(rows.last(), Direction::Next, should_emit) +} + +fn prev_cursor( + rows: &[User], + has_more: bool, + direction: UsersPageDirection, +) -> Result, Error> { + let should_emit = match direction { + UsersPageDirection::First => false, + UsersPageDirection::Next => true, + UsersPageDirection::Prev => has_more, + }; + encode_boundary_cursor(rows.first(), Direction::Prev, should_emit) +} + +fn encode_boundary_cursor( + user: Option<&User>, + direction: Direction, + should_emit: bool, +) -> Result, Error> { + if !should_emit { + return Ok(None); + } + let Some(user) = user else { + return Ok(None); + }; + Cursor::with_direction(UserCursorKey::from(user), direction) + .encode() + .map(Some) + .map_err(|err| Error::internal(format!("failed to encode users cursor: {err}"))) +} + +fn current_request_url(request: &HttpRequest) -> Result { + let connection = request.connection_info(); + let url = format!( + "{}://{}{}", + connection.scheme(), + connection.host(), + request.uri() + ); + Url::parse(&url).map_err(|err| Error::internal(format!("failed to build request URL: {err}"))) +} + +fn invalid_cursor_error() -> Error { + Error::invalid_request("cursor is invalid") + .with_details(json!({ "field": "cursor", "code": "invalid_cursor" })) +} + +fn invalid_limit_error(value: Option<&str>) -> Error { + Error::invalid_request(format!("limit must be between 1 and {MAX_LIMIT}")).with_details(json!({ + "field": "limit", + "code": "invalid_limit", + "value": value, + "max": MAX_LIMIT, + })) +} diff --git a/backend/tests/adapter_guardrails/steps.rs b/backend/tests/adapter_guardrails/steps.rs index b29ab50c..a3b5a353 100644 --- a/backend/tests/adapter_guardrails/steps.rs +++ b/backend/tests/adapter_guardrails/steps.rs @@ -204,8 +204,9 @@ pub(crate) fn the_users_response_includes_the_expected_display_name(world: Share let ctx = world.borrow(); let body = ctx.last_body.as_ref().expect("users body present"); let first = body - .as_array() - .expect("users array") + .get("data") + .and_then(Value::as_array) + .expect("users data array") .first() .expect("user row"); assert_eq!( diff --git a/backend/tests/diesel_login_users_adapters.rs b/backend/tests/diesel_login_users_adapters.rs index c54c16d4..7a7591c4 100644 --- a/backend/tests/diesel_login_users_adapters.rs +++ b/backend/tests/diesel_login_users_adapters.rs @@ -111,8 +111,14 @@ fn parse_body(bytes: &[u8]) -> Option { } } +fn users_data(body: &Value) -> &[Value] { + body.get("data") + .and_then(Value::as_array) + .expect("users data array") +} + fn classify_users(body: &Value) -> UsersMode { - let users = body.as_array().expect("users array"); + let users = users_data(body); if users.iter().any(|user| { user.get("id").and_then(Value::as_str) == Some(FIXTURE_USERS_ID) && user.get("displayName").and_then(Value::as_str) == Some(FIXTURE_USERS_NAME) @@ -305,7 +311,7 @@ fn db_present_mode_supports_login_and_users_with_stable_contracts() { let users_body = users_snapshot.body.as_ref().expect("users body"); let mode = classify_users(users_body); assert_eq!(mode, UsersMode::Db); - let users = users_body.as_array().expect("users array"); + let users = users_data(users_body); assert!(users.iter().any(|user| { user.get("id").and_then(Value::as_str) == Some(FIXTURE_AUTH_ID) && user.get("displayName").and_then(Value::as_str) == Some(DB_AUTH_DISPLAY_NAME) diff --git a/backend/tests/user_state_startup_modes_bdd.rs b/backend/tests/user_state_startup_modes_bdd.rs index db40399f..596c65ac 100644 --- a/backend/tests/user_state_startup_modes_bdd.rs +++ b/backend/tests/user_state_startup_modes_bdd.rs @@ -77,7 +77,10 @@ fn build_http_state_for_tests( } fn is_fixture_users(body: &Value) -> bool { - let users = body.as_array().expect("users array"); + let users = body + .get("data") + .and_then(Value::as_array) + .expect("users data array"); users.iter().any(|user| { user.get("id").and_then(Value::as_str) == Some(FIXTURE_USERS_ID) && user.get("displayName").and_then(Value::as_str) == Some(FIXTURE_USERS_NAME) diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 083b295b..57066d2a 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -177,10 +177,25 @@ them requires escalation, not a workaround. check-fmt`, `make lint`, and `make test` passed. The full test gate ran 1202 Rust tests successfully before the frontend and token workspace tests also passed. -- [ ] M4: `list_users` handler rewritten to consume `web::Query`, +- [x] M4: `list_users` handler rewritten to consume pagination query params, decode cursor, call the port, build links from request URL, and return `Paginated`; OpenAPI annotations updated; existing handler tests adjusted to the new envelope. +- [x] 2026-05-01: M4 moved users pagination HTTP concerns into + `backend/src/inbound/http/users_pagination.rs`. The handler now decodes + users cursors, rejects malformed or oversized limits with structured + `ErrorSchema` responses, calls `UsersQuery::list_users_page`, and returns + the `Paginated` envelope with `self`, `next`, and `prev` links. +- [x] 2026-05-01: M4 updated OpenAPI schema coverage for `createdAt`, + `PaginatedUsersResponse`, and `PaginationLinksSchema`; updated handler, + startup-mode, and adapter-guardrail tests to assert the new `data` envelope + shape. `cargo check -p backend`, users handler tests, affected startup and + guardrail tests, and OpenAPI/schema-focused tests passed before the full + commit gates. +- [x] 2026-05-01: M4 full gates passed: `make fmt`, `make markdownlint`, + `make check-fmt`, `make lint`, and `make test`. The final `make test` run + completed 1206 Rust tests successfully with 4 skipped, then passed the root, + frontend, and token workspace tests. - [ ] M5: BDD feature `backend/tests/features/users_list_pagination.feature` and step definitions cover happy and unhappy paths; full gate replay @@ -219,6 +234,11 @@ them requires escalation, not a workaround. same `limit + 1` cap, and reverses the in-memory page. That leaves a reverse overflow row at the front of the returned ascending slice, so the query port must trim from the leading edge for `Direction::Prev`. +- 2026-05-01: Direct `web::Query` extraction would let Actix + produce its default extractor body for malformed limits. The users endpoint + needs the project `ErrorSchema` with `invalid_limit` details, so M4 parses a + raw string limit in the inbound adapter and converts to `PageParams` after + endpoint-specific validation. ## Decision log @@ -275,6 +295,16 @@ them requires escalation, not a workaround. memory, and lets `DieselUsersQuery` trim the leading overflow row. Date/Author: 2026-05-01, implementation agent. +- Decision: reject `GET /api/v1/users` limits above + `pagination::MAX_LIMIT` in the users inbound adapter rather than changing + the shared pagination crate. + Rationale: the pagination crate deliberately normalises generic page params + and existing documentation describes that behaviour. The users endpoint has + a stricter acceptance criterion (`limit=200` returns HTTP 400 with + structured details), so adapter-local validation satisfies the endpoint + contract while preserving the crate's reusable default. + Date/Author: 2026-05-01, implementation agent. + ## Outcomes & retrospective (to be filled in at completion) From c4915625186936301a7f4592279a8979b05e4a9c Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 20:01:43 +0200 Subject: [PATCH 07/18] Add users pagination BDD coverage Add embedded-PostgreSQL BDD coverage for the paginated users list endpoint. Cover first-page links, forward traversal, reverse traversal, oversized limits, invalid cursors, and unauthenticated requests. Mark roadmap task 4.2.1 complete and record the final gate evidence in the execplan. --- .../features/users_list_pagination.feature | 36 ++ backend/tests/users_list_pagination_bdd.rs | 162 ++++++++ .../users_list_pagination_bdd/flow_support.rs | 359 ++++++++++++++++++ docs/backend-roadmap.md | 2 +- ...-users-offset-pagination-with-new-crate.md | 47 ++- 5 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 backend/tests/features/users_list_pagination.feature create mode 100644 backend/tests/users_list_pagination_bdd.rs create mode 100644 backend/tests/users_list_pagination_bdd/flow_support.rs diff --git a/backend/tests/features/users_list_pagination.feature b/backend/tests/features/users_list_pagination.feature new file mode 100644 index 00000000..42fa76e0 --- /dev/null +++ b/backend/tests/features/users_list_pagination.feature @@ -0,0 +1,36 @@ +Feature: Users list pagination + Scenario: First users page exposes the next link only + Given db-present startup mode with five ordered users + When the client requests the first users page with limit 2 + Then the users response is ok + And the users page contains users 1 through 2 + And the users page includes a next link and omits the prev link + + Scenario: Following next reaches the final users page + Given db-present startup mode with five ordered users + When the client follows users next links with limit 2 until the final page + Then the users response is ok + And the users page contains user 5 only + And the users page includes a prev link and omits the next link + And forward traversal returned every seeded user once + + Scenario: Following prev from the final users page returns the prior page + Given db-present startup mode with five ordered users + When the client follows next then prev users links with limit 2 + Then the users response is ok + And the users page contains users 3 through 4 + + Scenario: Oversized users page limit is rejected + Given db-present startup mode with five ordered users + When the client requests the users list with limit 200 + Then the users response is bad request with invalid_limit details + + Scenario: Invalid users cursor is rejected + Given db-present startup mode with five ordered users + When the client requests the users list with an invalid cursor + Then the users response is bad request with invalid_cursor details + + Scenario: Users list requires a session + Given db-present startup mode with five ordered users + When the client requests the users list without a session + Then the users response is unauthorised diff --git a/backend/tests/users_list_pagination_bdd.rs b/backend/tests/users_list_pagination_bdd.rs new file mode 100644 index 00000000..bff8a2ed --- /dev/null +++ b/backend/tests/users_list_pagination_bdd.rs @@ -0,0 +1,162 @@ +//! Behavioural coverage for keyset-paginated users listing. + +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; + +mod support; + +use support::handle_cluster_setup_failure; + +#[path = "users_list_pagination_bdd/flow_support.rs"] +mod flow_support; + +use flow_support::{ + ORDERED_USER_IDS, World, assert_error, assert_full_traversal, assert_next_only, + assert_prev_only, assert_status, assert_users, run_authenticated_request, run_first_page, + run_follow_next_to_final, run_next_then_prev, run_unauthenticated_request, seed_users, + setup_db_context, skip, store_db, +}; + +#[fixture] +fn world() -> World { + World::default() +} + +#[given("db-present startup mode with five ordered users")] +fn db_present_startup_mode_with_five_ordered_users(world: &mut World) { + match setup_db_context().and_then(seed_users) { + Ok(db) => store_db(world, db), + Err(error) => { + let _ = handle_cluster_setup_failure::<()>(error.as_str()); + skip(world, error); + } + } +} + +#[when("the client requests the first users page with limit 2")] +fn the_client_requests_the_first_users_page_with_limit_2(world: &mut World) { + run_first_page(world); +} + +#[when("the client follows users next links with limit 2 until the final page")] +fn the_client_follows_users_next_links_with_limit_2_until_the_final_page(world: &mut World) { + run_follow_next_to_final(world); +} + +#[when("the client follows next then prev users links with limit 2")] +fn the_client_follows_next_then_prev_users_links_with_limit_2(world: &mut World) { + run_next_then_prev(world); +} + +#[when("the client requests the users list with limit 200")] +fn the_client_requests_the_users_list_with_limit_200(world: &mut World) { + run_authenticated_request(world, "/api/v1/users?limit=200"); +} + +#[when("the client requests the users list with an invalid cursor")] +fn the_client_requests_the_users_list_with_an_invalid_cursor(world: &mut World) { + run_authenticated_request(world, "/api/v1/users?cursor=not-a-cursor"); +} + +#[when("the client requests the users list without a session")] +fn the_client_requests_the_users_list_without_a_session(world: &mut World) { + run_unauthenticated_request(world); +} + +#[then("the users response is ok")] +fn the_users_response_is_ok(world: &mut World) { + assert_status(world, 200); +} + +#[then("the users page contains users 1 through 2")] +fn the_users_page_contains_users_1_through_2(world: &mut World) { + assert_users(world, &ORDERED_USER_IDS[0..2]); +} + +#[then("the users page contains users 3 through 4")] +fn the_users_page_contains_users_3_through_4(world: &mut World) { + assert_users(world, &ORDERED_USER_IDS[2..4]); +} + +#[then("the users page contains user 5 only")] +fn the_users_page_contains_user_5_only(world: &mut World) { + assert_users(world, &ORDERED_USER_IDS[4..5]); +} + +#[then("the users page includes a next link and omits the prev link")] +fn the_users_page_includes_a_next_link_and_omits_the_prev_link(world: &mut World) { + assert_next_only(world); +} + +#[then("the users page includes a prev link and omits the next link")] +fn the_users_page_includes_a_prev_link_and_omits_the_next_link(world: &mut World) { + assert_prev_only(world); +} + +#[then("forward traversal returned every seeded user once")] +fn forward_traversal_returned_every_seeded_user_once(world: &mut World) { + assert_full_traversal(world); +} + +#[then("the users response is bad request with invalid_limit details")] +fn the_users_response_is_bad_request_with_invalid_limit_details(world: &mut World) { + assert_error(world, 400, "invalid_limit"); +} + +#[then("the users response is bad request with invalid_cursor details")] +fn the_users_response_is_bad_request_with_invalid_cursor_details(world: &mut World) { + assert_error(world, 400, "invalid_cursor"); +} + +#[then("the users response is unauthorised")] +fn the_users_response_is_unauthorised(world: &mut World) { + assert_status(world, 401); +} + +#[scenario( + path = "tests/features/users_list_pagination.feature", + name = "First users page exposes the next link only" +)] +fn first_users_page_exposes_the_next_link_only(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/users_list_pagination.feature", + name = "Following next reaches the final users page" +)] +fn following_next_reaches_the_final_users_page(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/users_list_pagination.feature", + name = "Following prev from the final users page returns the prior page" +)] +fn following_prev_from_the_final_users_page_returns_the_prior_page(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/users_list_pagination.feature", + name = "Oversized users page limit is rejected" +)] +fn oversized_users_page_limit_is_rejected(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/users_list_pagination.feature", + name = "Invalid users cursor is rejected" +)] +fn invalid_users_cursor_is_rejected(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/users_list_pagination.feature", + name = "Users list requires a session" +)] +fn users_list_requires_a_session(world: World) { + drop(world); +} diff --git a/backend/tests/users_list_pagination_bdd/flow_support.rs b/backend/tests/users_list_pagination_bdd/flow_support.rs new file mode 100644 index 00000000..298a370a --- /dev/null +++ b/backend/tests/users_list_pagination_bdd/flow_support.rs @@ -0,0 +1,359 @@ +//! Flow helpers for users list pagination BDD coverage. + +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; + +use actix_web::body::BoxBody; +use actix_web::cookie::{Cookie, Key, SameSite}; +use actix_web::dev::{Service, ServiceResponse}; +use actix_web::{App, test as actix_test, web}; +use backend::domain::ports::{FixtureRouteSubmissionService, RouteSubmissionService}; +use backend::inbound::http::state::HttpState; +use backend::inbound::http::users::{LoginRequest, list_users, login}; +use backend::outbound::persistence::{DbPool, PoolConfig}; +use backend::test_support::server::{ServerConfig, build_http_state}; +use pg_embedded_setup_unpriv::TemporaryDatabase; +use postgres::{Client, NoTls}; +use serde_json::Value; +use url::Url; +use uuid::Uuid; + +use super::support::atexit_cleanup::shared_cluster_handle; +use super::support::profile_interests::build_session_middleware; +use super::support::{format_postgres_error, provision_template_database}; + +const ADMIN_USER_ID: &str = "123e4567-e89b-12d3-a456-426614174000"; +pub(crate) const ORDERED_USER_IDS: [&str; 5] = [ + ADMIN_USER_ID, + "123e4567-e89b-12d3-a456-426614174001", + "123e4567-e89b-12d3-a456-426614174002", + "123e4567-e89b-12d3-a456-426614174003", + "123e4567-e89b-12d3-a456-426614174004", +]; + +pub(crate) struct DbContext { + database_url: String, + pool: DbPool, + _database: TemporaryDatabase, +} + +#[derive(Default)] +pub(crate) struct World { + db: Option, + last_response: Option, + traversal_ids: Vec, + skip_reason: Option, +} + +#[derive(Clone, Debug)] +struct Snapshot { + status: u16, + body: Option, +} + +pub(crate) fn run_async(future: impl Future) -> T { + tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(future) +} + +pub(crate) fn is_skipped(world: &World) -> bool { + if let Some(reason) = world.skip_reason.as_deref() { + eprintln!("SKIP-TEST-CLUSTER: users list pagination scenario skipped ({reason})"); + true + } else { + false + } +} + +fn with_world(world: &mut World, f: F) { + if !is_skipped(world) { + f(world); + } +} + +pub(crate) fn setup_db_context() -> Result { + let cluster = shared_cluster_handle().map_err(|error| error.to_string())?; + let database = provision_template_database(cluster).map_err(|error| error.to_string())?; + let database_url = database.url().to_owned(); + let pool = run_async(DbPool::new( + PoolConfig::new(database_url.as_str()) + .with_max_size(2) + .with_min_idle(Some(1)), + )) + .map_err(|error| error.to_string())?; + Ok(DbContext { + database_url, + pool, + _database: database, + }) +} + +pub(crate) fn seed_users(db: DbContext) -> Result { + let mut client = Client::connect(db.database_url.as_str(), NoTls) + .map_err(|error| format_postgres_error(&error))?; + for (index, id) in ORDERED_USER_IDS.iter().enumerate() { + let user_id = Uuid::parse_str(id).expect("fixture user id"); + let display_name = format!("Page User {}", index + 1); + let created_at = format!("2026-01-01T00:0{index}:00Z"); + client + .execute( + "INSERT INTO users (id, display_name, created_at) + VALUES ($1, $2, ($3::text)::timestamptz)", + &[&user_id, &display_name, &created_at], + ) + .map_err(|error| format_postgres_error(&error))?; + } + Ok(db) +} + +pub(crate) fn store_db(world: &mut World, db: DbContext) { + world.db = Some(db); + world.skip_reason = None; +} + +pub(crate) fn skip(world: &mut World, reason: String) { + world.skip_reason = Some(reason); +} + +fn build_state(pool: DbPool) -> web::Data { + let bind_addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let config = + ServerConfig::new(Key::generate(), false, SameSite::Lax, bind_addr).with_db_pool(pool); + build_http_state( + &config, + Arc::new(FixtureRouteSubmissionService) as Arc, + ) +} + +async fn build_app( + state: web::Data, +) -> impl Service, Error = actix_web::Error> +{ + actix_test::init_service( + App::new().app_data(state).wrap(backend::Trace).service( + web::scope("/api/v1") + .wrap(build_session_middleware()) + .service(login) + .service(list_users), + ), + ) + .await +} + +async fn login_cookie(app: &S) -> Cookie<'static> +where + S: Service, Error = actix_web::Error>, +{ + let request = actix_test::TestRequest::post() + .uri("/api/v1/login") + .set_json(&LoginRequest { + username: "admin".to_owned(), + password: "password".to_owned(), + }) + .to_request(); + let response = actix_test::call_service(app, request).await; + assert_eq!(response.status().as_u16(), 200); + response + .response() + .cookies() + .find(|cookie| cookie.name() == "session") + .expect("session cookie") + .into_owned() +} + +async fn get_users(app: &S, path: &str, cookie: Option>) -> Snapshot +where + S: Service, Error = actix_web::Error>, +{ + let mut request = actix_test::TestRequest::get().uri(path); + if let Some(cookie) = cookie { + request = request.cookie(cookie); + } + let response = actix_test::call_service(app, request.to_request()).await; + Snapshot { + status: response.status().as_u16(), + body: parse_json_body(actix_test::read_body(response).await.as_ref()), + } +} + +async fn collect_pages_until_final(app: &S, cookie: Cookie<'static>) -> (Snapshot, Vec) +where + S: Service, Error = actix_web::Error>, +{ + let mut path = "/api/v1/users?limit=2".to_owned(); + let mut traversal_ids = Vec::new(); + for _ in 0..10 { + let snapshot = get_users(app, &path, Some(cookie.clone())).await; + traversal_ids.extend(user_ids(&snapshot)); + match next_path(&snapshot) { + Some(page_path) => path = page_path, + None => return (snapshot, traversal_ids), + } + } + panic!("pagination traversal did not terminate"); +} + +fn parse_json_body(bytes: &[u8]) -> Option { + (!bytes.is_empty()).then(|| serde_json::from_slice(bytes).expect("json body")) +} + +fn build_path_from_link(link: &str) -> String { + let url = Url::parse(link).expect("pagination link should be absolute URL"); + match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_owned(), + } +} + +fn link(snapshot: &Snapshot, rel: &str) -> Option { + snapshot + .body + .as_ref() + .and_then(|body| body.get("links")) + .and_then(|links| links.get(rel)) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn user_ids(snapshot: &Snapshot) -> Vec { + snapshot + .body + .as_ref() + .and_then(|body| body.get("data")) + .and_then(Value::as_array) + .expect("users data array") + .iter() + .map(|user| { + user.get("id") + .and_then(Value::as_str) + .expect("user id") + .to_owned() + }) + .collect() +} + +fn error_detail_code(snapshot: &Snapshot) -> Option<&str> { + snapshot + .body + .as_ref() + .and_then(|body| body.get("details")) + .and_then(|details| details.get("code")) + .and_then(Value::as_str) +} + +fn next_path(snapshot: &Snapshot) -> Option { + link(snapshot, "next").map(|next| build_path_from_link(&next)) +} + +pub(crate) fn run_first_page(world: &mut World) { + with_world(world, |world| { + let db = world.db.as_ref().expect("db context"); + world.last_response = Some(run_async(async { + let app = build_app(build_state(db.pool.clone())).await; + let cookie = login_cookie(&app).await; + get_users(&app, "/api/v1/users?limit=2", Some(cookie)).await + })); + }); +} + +pub(crate) fn run_follow_next_to_final(world: &mut World) { + with_world(world, |world| { + let db = world.db.as_ref().expect("db context"); + let (last_response, traversal_ids) = run_async(async { + let app = build_app(build_state(db.pool.clone())).await; + let cookie = login_cookie(&app).await; + collect_pages_until_final(&app, cookie).await + }); + world.last_response = Some(last_response); + world.traversal_ids = traversal_ids; + }); +} + +pub(crate) fn run_next_then_prev(world: &mut World) { + with_world(world, |world| { + let db = world.db.as_ref().expect("db context"); + world.last_response = Some(run_async(async { + let app = build_app(build_state(db.pool.clone())).await; + let cookie = login_cookie(&app).await; + let first = get_users(&app, "/api/v1/users?limit=2", Some(cookie.clone())).await; + let middle_path = + build_path_from_link(&link(&first, "next").expect("first page next link")); + let middle = get_users(&app, &middle_path, Some(cookie.clone())).await; + let final_path = + build_path_from_link(&link(&middle, "next").expect("middle page next link")); + let final_page = get_users(&app, &final_path, Some(cookie.clone())).await; + let prev_path = + build_path_from_link(&link(&final_page, "prev").expect("final page prev link")); + get_users(&app, &prev_path, Some(cookie)).await + })); + }); +} + +pub(crate) fn run_authenticated_request(world: &mut World, path: &'static str) { + with_world(world, |world| { + let db = world.db.as_ref().expect("db context"); + world.last_response = Some(run_async(async { + let app = build_app(build_state(db.pool.clone())).await; + let cookie = login_cookie(&app).await; + get_users(&app, path, Some(cookie)).await + })); + }); +} + +pub(crate) fn run_unauthenticated_request(world: &mut World) { + with_world(world, |world| { + let db = world.db.as_ref().expect("db context"); + world.last_response = Some(run_async(async { + let app = build_app(build_state(db.pool.clone())).await; + get_users(&app, "/api/v1/users", None).await + })); + }); +} + +pub(crate) fn assert_status(world: &mut World, status: u16) { + with_world(world, |world| { + assert_eq!( + world.last_response.as_ref().expect("response").status, + status + ); + }); +} + +pub(crate) fn assert_users(world: &mut World, expected: &[&str]) { + with_world(world, |world| { + let ids = user_ids(world.last_response.as_ref().expect("response")); + assert_eq!(ids, expected); + }); +} + +pub(crate) fn assert_next_only(world: &mut World) { + with_world(world, |world| { + let response = world.last_response.as_ref().expect("response"); + assert!(link(response, "next").is_some()); + assert!(link(response, "prev").is_none()); + }); +} + +pub(crate) fn assert_prev_only(world: &mut World) { + with_world(world, |world| { + let response = world.last_response.as_ref().expect("response"); + assert!(link(response, "prev").is_some()); + assert!(link(response, "next").is_none()); + }); +} + +pub(crate) fn assert_full_traversal(world: &mut World) { + with_world(world, |world| { + assert_eq!(world.traversal_ids, ORDERED_USER_IDS) + }); +} + +pub(crate) fn assert_error(world: &mut World, status: u16, detail_code: &str) { + with_world(world, |world| { + let response = world.last_response.as_ref().expect("response"); + assert_eq!(response.status, status); + assert_eq!(error_detail_code(response), Some(detail_code)); + }); +} diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index 66bdd4e2..870ff9d8 100644 --- a/docs/backend-roadmap.md +++ b/docs/backend-roadmap.md @@ -229,7 +229,7 @@ see `docs/keyset-pagination-design.md` for the detailed crate design. ### 4.2. Endpoint adoption -- [ ] 4.2.1. Replace offset pagination in `GET /api/users` with the new crate, +- [x] 4.2.1. Replace offset pagination in `GET /api/users` with the new crate, including Diesel filters that respect `(created_at, id)` ordering and bb8 connection pooling. - [ ] 4.2.2. Update the repository layer to surface pagination-aware errors diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 57066d2a..f814df73 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: IN PROGRESS +Status: IMPLEMENTED ## Purpose / big picture @@ -196,11 +196,27 @@ them requires escalation, not a workaround. `make check-fmt`, `make lint`, and `make test`. The final `make test` run completed 1206 Rust tests successfully with 4 skipped, then passed the root, frontend, and token workspace tests. -- [ ] M5: BDD feature +- [x] M5: BDD feature `backend/tests/features/users_list_pagination.feature` and step definitions cover happy and unhappy paths; full gate replay (`make check-fmt`, `make lint`, `make test`) is green; roadmap entry - 4.2.1 marked done; PR opened. + 4.2.1 marked done; final commit ready for PR creation. +- [x] 2026-05-01: M5 added + `backend/tests/features/users_list_pagination.feature` and + `backend/tests/users_list_pagination_bdd.rs` with split flow support. The + scenarios cover first page links, forward traversal to the final page, + reverse traversal from the final page, oversized limit rejection, invalid + cursor rejection, and unauthenticated access. The direct BDD test run passed + 14 tests. +- [x] 2026-05-01: Roadmap item 4.2.1 in `docs/backend-roadmap.md` marked + complete after the endpoint, Diesel adapter, and BDD traversal coverage were + in place. +- [x] 2026-05-01: M5 full gates passed after refactoring the BDD traversal + helper to satisfy Clippy: `make fmt`, `make markdownlint`, + `make check-fmt`, `make lint`, and `make test`. The final `make test` run + completed 1220 Rust tests successfully with 4 skipped, then passed the root + Vitest test, frontend workspace tests, TypeScript checks, and token contrast + checks. ## Surprises & discoveries @@ -239,6 +255,10 @@ them requires escalation, not a workaround. needs the project `ErrorSchema` with `invalid_limit` details, so M4 parses a raw string limit in the inbound adapter and converts to `PageParams` after endpoint-specific validation. +- 2026-05-01: The M5 BDD fixture initially used a nested next-link traversal + loop inside the step closure. Project Clippy runs with + `clippy::excessive_nesting` as a hard error, so the traversal was extracted + into a small async helper before the full lint gate was accepted. ## Decision log @@ -307,7 +327,26 @@ them requires escalation, not a workaround. ## Outcomes & retrospective -(to be filled in at completion) +The users list endpoint now uses the workspace pagination crate end to end. +`GET /api/v1/users` returns a paginated envelope, uses opaque direction-aware +cursor tokens, and keeps `self`, `next`, and `prev` link generation in the +inbound HTTP adapter. The domain owns the user cursor key and the outbound +Diesel adapter owns all SQL, preserving the hexagonal boundary. + +The storage path now has a composite `(created_at, id)` index and the Diesel +repository performs `limit + 1` keyset reads without `OFFSET` or `COUNT(*)`. +Forward and reverse pages are returned to callers in stable ascending order, +with overflow trimming handled in the query adapter. + +The main implementation friction was reconciling the generic pagination +crate's limit-normalisation behaviour with the users endpoint's stricter +acceptance criterion. The endpoint now performs adapter-local raw limit +validation, which keeps the shared crate reusable while returning the required +structured `invalid_limit` response for oversized requests. + +Validation finished cleanly on 2026-05-01: focused BDD coverage for the users +list passed, then `make fmt`, `make markdownlint`, `make check-fmt`, +`make lint`, and `make test` all passed. ## Context and orientation From b1264ef0d825a6567ab0d369f4fbd740719a9f6f Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 20:04:06 +0200 Subject: [PATCH 08/18] Record users pagination PR status Update the execplan to reflect that the implementation branch now has a draft review PR. Keep the living plan aligned with the final handoff state. --- ...4-2-1-replace-users-offset-pagination-with-new-crate.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index f814df73..58cd7507 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: IMPLEMENTED +Status: IMPLEMENTED; DRAFT PR OPEN ## Purpose / big picture @@ -200,7 +200,7 @@ them requires escalation, not a workaround. `backend/tests/features/users_list_pagination.feature` and step definitions cover happy and unhappy paths; full gate replay (`make check-fmt`, `make lint`, `make test`) is green; roadmap entry - 4.2.1 marked done; final commit ready for PR creation. + 4.2.1 marked done; draft PR opened. - [x] 2026-05-01: M5 added `backend/tests/features/users_list_pagination.feature` and `backend/tests/users_list_pagination_bdd.rs` with split flow support. The @@ -217,6 +217,9 @@ them requires escalation, not a workaround. completed 1220 Rust tests successfully with 4 skipped, then passed the root Vitest test, frontend workspace tests, TypeScript checks, and token contrast checks. +- [x] 2026-05-01: Draft PR + [#349](https://github.com/leynos/wildside/pull/349) updated from the + pre-implementation plan into the implementation review PR. ## Surprises & discoveries From c37fb766df9c60e85fb353fe18fcbaa241f4057d Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 20:09:17 +0200 Subject: [PATCH 09/18] Stabilize hook tool lookup Export the local Cargo, Bun, Python, Go, and workspace binary paths from the Makefile so non-login hook environments can run the same format and lint targets as interactive shells. Record the hook failure and restricted-path validation replay in the users pagination execplan. --- Makefile | 1 + ...1-replace-users-offset-pagination-with-new-crate.md | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/Makefile b/Makefile index a9a95764..ff8e1a81 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHELL := bash BUN_PATH := $(HOME)/.bun/bin:$(PATH) KUBE_VERSION ?= 1.31.0 +export PATH := $(HOME)/.cargo/bin:$(HOME)/.bun/bin:$(HOME)/.local/bin:$(HOME)/go/bin:$(CURDIR)/node_modules/.bin:$(PATH) define ensure_tool @command -v $(1) >/dev/null 2>&1 || { \ diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 58cd7507..8f1398b1 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -220,6 +220,11 @@ them requires escalation, not a workaround. - [x] 2026-05-01: Draft PR [#349](https://github.com/leynos/wildside/pull/349) updated from the pre-implementation plan into the implementation review PR. +- [x] 2026-05-01: Post-turn hook failure fixed by making `Makefile` targets + bootstrap the local Cargo, Bun, Python, Go, and workspace binary paths + before running format, lint, and Markdown checks. Replayed + `PATH=/usr/local/bin:/usr/bin:/bin make check-fmt lint` and + `PATH=/usr/local/bin:/usr/bin:/bin make markdownlint`; both passed. ## Surprises & discoveries @@ -262,6 +267,11 @@ them requires escalation, not a workaround. loop inside the step closure. Project Clippy runs with `clippy::excessive_nesting` as a hard error, so the traversal was extracted into a small async helper before the full lint gate was accepted. +- 2026-05-01: The post-turn hook runs Makefile targets in a non-login + environment that did not include `~/.cargo/bin`, `~/.bun/bin`, + `~/.local/bin`, or `~/go/bin`. The interactive gates had passed because + those paths were already present. Exporting them from the Makefile makes + the gates deterministic for both interactive and hook execution. ## Decision log From 7bf594833774174f3a77e6b64ef86ccdd459f1e4 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 7 May 2026 19:11:56 +0200 Subject: [PATCH 10/18] Parameterize users overflow trimming test Replace the duplicated forward and reverse overflow trimming tests with one `rstest` table that covers both cursor directions. Keep the same expected page boundaries while reducing repeated fixture setup. --- .../persistence/diesel_users_query.rs | 54 ++++++------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/backend/src/outbound/persistence/diesel_users_query.rs b/backend/src/outbound/persistence/diesel_users_query.rs index 0d93a595..9160909f 100644 --- a/backend/src/outbound/persistence/diesel_users_query.rs +++ b/backend/src/outbound/persistence/diesel_users_query.rs @@ -281,8 +281,16 @@ mod tests { Ok(()) } + #[rstest] + #[case(Direction::Next, None, 0, 2)] + #[case(Direction::Prev, Some(2usize), 1, 3)] #[tokio::test] - async fn list_users_page_trims_forward_overflow_row() -> TestResult { + async fn list_users_page_trims_overflow_row( + #[case] direction: Direction, + #[case] cursor_index: Option, + #[case] expected_start: usize, + #[case] expected_end: usize, + ) -> TestResult { let rows = vec![ user_at( "11111111-1111-1111-1111-111111111111", @@ -302,45 +310,15 @@ mod tests { ]; let repository = Arc::new(StubUserRepository::with_page_rows(rows.clone())); let query = DieselUsersQuery::from_repository(repository); + let limit = rows.len() - 1; + let request = match cursor_index { + None => ListUsersPageRequest::new(None, limit), + Some(i) => request_with_cursor(&rows[i], direction, limit), + }; - let page = query - .list_users_page( - rows[0].id(), - ListUsersPageRequest::new(None, rows.len() - 1), - ) - .await?; - - assert_eq!(page.rows(), &rows[0..2]); - assert!(page.has_more()); - Ok(()) - } - - #[tokio::test] - async fn list_users_page_trims_reverse_overflow_row() -> TestResult { - let rows = vec![ - user_at( - "11111111-1111-1111-1111-111111111111", - "Overflow", - "2026-01-01T00:00:00Z", - )?, - user_at( - "22222222-2222-2222-2222-222222222222", - "Ada Two", - "2026-01-02T00:00:00Z", - )?, - user_at( - "33333333-3333-3333-3333-333333333333", - "Ada Three", - "2026-01-03T00:00:00Z", - )?, - ]; - let repository = Arc::new(StubUserRepository::with_page_rows(rows.clone())); - let query = DieselUsersQuery::from_repository(repository); - let request = request_with_cursor(&rows[2], Direction::Prev, rows.len() - 1); - - let page = query.list_users_page(rows[2].id(), request).await?; + let page = query.list_users_page(rows[0].id(), request).await?; - assert_eq!(page.rows(), &rows[1..3]); + assert_eq!(page.rows(), &rows[expected_start..expected_end]); assert!(page.has_more()); Ok(()) } From d59d6c444e3591f0eeec5cf37b45da1358337bb8 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 7 May 2026 19:17:57 +0200 Subject: [PATCH 11/18] Merge users boundary cursor helpers Replace the duplicated next and previous users cursor helpers with one direction-aware boundary helper. Keep the existing pagination emission rules while sharing the cursor encoding call. --- backend/src/inbound/http/users_pagination.rs | 36 ++++++++------------ 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/backend/src/inbound/http/users_pagination.rs b/backend/src/inbound/http/users_pagination.rs index 98ec2dea..cdf41fba 100644 --- a/backend/src/inbound/http/users_pagination.rs +++ b/backend/src/inbound/http/users_pagination.rs @@ -109,8 +109,8 @@ pub fn build_users_page_response( ) -> Result, Error> { let has_more = page.has_more(); let rows = page.into_rows(); - let next_cursor = next_cursor(&rows, has_more, direction)?; - let prev_cursor = prev_cursor(&rows, has_more, direction)?; + let next_cursor = boundary_cursor(rows.last(), Direction::Next, direction, has_more)?; + let prev_cursor = boundary_cursor(rows.first(), Direction::Prev, direction, has_more)?; let request_url = current_request_url(request)?; let links = PaginationLinks::from_request( &request_url, @@ -142,29 +142,21 @@ fn cursor_direction(cursor: &Cursor) -> UsersPageDirection { } } -fn next_cursor( - rows: &[User], +fn boundary_cursor( + row: Option<&User>, + cursor_dir: Direction, + page_dir: UsersPageDirection, has_more: bool, - direction: UsersPageDirection, -) -> Result, Error> { - let should_emit = match direction { - UsersPageDirection::First | UsersPageDirection::Next => has_more, - UsersPageDirection::Prev => true, - }; - encode_boundary_cursor(rows.last(), Direction::Next, should_emit) -} - -fn prev_cursor( - rows: &[User], - has_more: bool, - direction: UsersPageDirection, ) -> Result, Error> { - let should_emit = match direction { - UsersPageDirection::First => false, - UsersPageDirection::Next => true, - UsersPageDirection::Prev => has_more, + let should_emit = match (page_dir, cursor_dir) { + (UsersPageDirection::First, Direction::Next) => has_more, + (UsersPageDirection::First, Direction::Prev) => false, + (UsersPageDirection::Next, Direction::Next) => has_more, + (UsersPageDirection::Next, Direction::Prev) => true, + (UsersPageDirection::Prev, Direction::Next) => true, + (UsersPageDirection::Prev, Direction::Prev) => has_more, }; - encode_boundary_cursor(rows.first(), Direction::Prev, should_emit) + encode_boundary_cursor(row, cursor_dir, should_emit) } fn encode_boundary_cursor( From d5b04f641c94026cad2df8a7b5aa85150483d787 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 7 May 2026 19:21:58 +0200 Subject: [PATCH 12/18] Share users pagination request setup Extract the repeated authenticated and unauthenticated users list request setup in the pagination BDD flow helpers. Keep the multi-step traversal helpers separate because they exercise distinct pagination journeys. --- .../users_list_pagination_bdd/flow_support.rs | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/backend/tests/users_list_pagination_bdd/flow_support.rs b/backend/tests/users_list_pagination_bdd/flow_support.rs index 298a370a..8f349723 100644 --- a/backend/tests/users_list_pagination_bdd/flow_support.rs +++ b/backend/tests/users_list_pagination_bdd/flow_support.rs @@ -247,17 +247,25 @@ fn next_path(snapshot: &Snapshot) -> Option { link(snapshot, "next").map(|next| build_path_from_link(&next)) } -pub(crate) fn run_first_page(world: &mut World) { +fn run_request(world: &mut World, path: &'static str, authenticated: bool) { with_world(world, |world| { let db = world.db.as_ref().expect("db context"); world.last_response = Some(run_async(async { let app = build_app(build_state(db.pool.clone())).await; - let cookie = login_cookie(&app).await; - get_users(&app, "/api/v1/users?limit=2", Some(cookie)).await + let cookie = if authenticated { + Some(login_cookie(&app).await) + } else { + None + }; + get_users(&app, path, cookie).await })); }); } +pub(crate) fn run_first_page(world: &mut World) { + run_request(world, "/api/v1/users?limit=2", true); +} + pub(crate) fn run_follow_next_to_final(world: &mut World) { with_world(world, |world| { let db = world.db.as_ref().expect("db context"); @@ -292,24 +300,11 @@ pub(crate) fn run_next_then_prev(world: &mut World) { } pub(crate) fn run_authenticated_request(world: &mut World, path: &'static str) { - with_world(world, |world| { - let db = world.db.as_ref().expect("db context"); - world.last_response = Some(run_async(async { - let app = build_app(build_state(db.pool.clone())).await; - let cookie = login_cookie(&app).await; - get_users(&app, path, Some(cookie)).await - })); - }); + run_request(world, path, true); } pub(crate) fn run_unauthenticated_request(world: &mut World) { - with_world(world, |world| { - let db = world.db.as_ref().expect("db context"); - world.last_response = Some(run_async(async { - let app = build_app(build_state(db.pool.clone())).await; - get_users(&app, "/api/v1/users", None).await - })); - }); + run_request(world, "/api/v1/users", false); } pub(crate) fn assert_status(world: &mut World, status: u16) { From 1ccdbc27d2b3bdef71ee9d2f8e57a7613975859a Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 7 May 2026 19:31:03 +0200 Subject: [PATCH 13/18] Share users repository keyset query Replace the duplicated next and previous users repository page queries with one direction-aware keyset helper. Preserve the existing predicates, sort orders, and reverse handling for previous-page results. --- .../persistence/diesel_user_repository.rs | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/backend/src/outbound/persistence/diesel_user_repository.rs b/backend/src/outbound/persistence/diesel_user_repository.rs index 04607df6..b06a773c 100644 --- a/backend/src/outbound/persistence/diesel_user_repository.rs +++ b/backend/src/outbound/persistence/diesel_user_repository.rs @@ -189,10 +189,7 @@ impl UserRepository for DieselUserRepository { .map_err(map_diesel_error)?, Some(cursor) => { let (key, direction) = cursor.into_parts(); - match direction { - Direction::Next => list_page_after(&mut conn, key, fetch_limit).await?, - Direction::Prev => list_page_before(&mut conn, key, fetch_limit).await?, - } + list_page_keyset(&mut conn, key, fetch_limit, direction).await? } }; @@ -200,43 +197,41 @@ impl UserRepository for DieselUserRepository { } } -async fn list_page_after( +async fn list_page_keyset( conn: &mut diesel_async::AsyncPgConnection, key: UserCursorKey, fetch_limit: i64, + direction: Direction, ) -> Result, UserPersistenceError> { - users::table - .filter( - users::created_at.gt(key.created_at).or(users::created_at - .eq(key.created_at) - .and(users::id.gt(key.id))), - ) - .order((users::created_at.asc(), users::id.asc())) - .limit(fetch_limit) - .select(UserRow::as_select()) - .load(conn) - .await - .map_err(map_diesel_error) -} + let mut query = users::table.into_boxed(); -async fn list_page_before( - conn: &mut diesel_async::AsyncPgConnection, - key: UserCursorKey, - fetch_limit: i64, -) -> Result, UserPersistenceError> { - let mut rows = users::table - .filter( - users::created_at.lt(key.created_at).or(users::created_at - .eq(key.created_at) - .and(users::id.lt(key.id))), - ) - .order((users::created_at.desc(), users::id.desc())) + query = match direction { + Direction::Next => query + .filter( + users::created_at.gt(key.created_at).or(users::created_at + .eq(key.created_at) + .and(users::id.gt(key.id))), + ) + .order((users::created_at.asc(), users::id.asc())), + Direction::Prev => query + .filter( + users::created_at.lt(key.created_at).or(users::created_at + .eq(key.created_at) + .and(users::id.lt(key.id))), + ) + .order((users::created_at.desc(), users::id.desc())), + }; + + let mut rows = query .limit(fetch_limit) .select(UserRow::as_select()) .load(conn) .await .map_err(map_diesel_error)?; - rows.reverse(); + + if matches!(direction, Direction::Prev) { + rows.reverse(); + } Ok(rows) } From 3c06b1d7eabb2dda0167f4a111b156fffeb3844f Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 20:09:41 +0200 Subject: [PATCH 14/18] Pin patched frontend audit dependencies Update pnpm security overrides for vulnerable transitive packages reported by `make audit`, including `basic-ftp` and `ip-address`. Regenerate `pnpm-lock.yaml` so the audit gate resolves patched versions. --- package.json | 10 +- pnpm-lock.yaml | 321 ++++++++++++------------------------------------- 2 files changed, 85 insertions(+), 246 deletions(-) diff --git a/package.json b/package.json index 548c8727..a72f601a 100644 --- a/package.json +++ b/package.json @@ -35,18 +35,20 @@ "overrides": { "basic-ftp": "5.3.1", "dompurify": "3.4.0", - "ip-address": "10.1.1", + "ip-address": "10.2.0", "uuid": "14.0.0" }, "pnpm": { "overrides": { "@isaacs/brace-expansion": "5.0.1", - "brace-expansion@<5.0.5": "5.0.5", + "brace-expansion@<5.0.6": "5.0.6", "ajv": "8.18.0", + "fast-uri": "3.1.2", "glob": "11.1.0", "js-yaml": "4.1.1", "lodash": "4.18.1", "markdown-it": "14.1.1", + "mermaid": "11.15.0", "minimatch": "10.2.3", "lodash-es": "4.18.1", "pino": "9.13.1", @@ -54,10 +56,10 @@ "rollup": "4.59.0", "basic-ftp": "5.3.1", "dompurify": "3.4.0", - "ip-address": "10.1.1", + "ip-address": "10.2.0", "tar-fs": "3.1.1", "validator": "13.15.23", - "ws": "8.18.3", + "ws": "8.20.1", "yauzl": "3.2.1", "yaml@<1.10.3": "1.10.3", "yaml@>=2.0.0 <2.8.3": "2.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4fa1178..6a3aca44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,12 +6,14 @@ settings: overrides: '@isaacs/brace-expansion': 5.0.1 - brace-expansion@<5.0.5: 5.0.5 + brace-expansion@<5.0.6: 5.0.6 ajv: 8.18.0 + fast-uri: 3.1.2 glob: 11.1.0 js-yaml: 4.1.1 lodash: 4.18.1 markdown-it: 14.1.1 + mermaid: 11.15.0 minimatch: 10.2.3 lodash-es: 4.18.1 pino: 9.13.1 @@ -19,10 +21,10 @@ overrides: rollup: 4.59.0 basic-ftp: 5.3.1 dompurify: 3.4.0 - ip-address: 10.1.1 + ip-address: 10.2.0 tar-fs: 3.1.1 validator: 13.15.23 - ws: 8.18.3 + ws: 8.20.1 yauzl: 3.2.1 yaml@<1.10.3: 1.10.3 yaml@>=2.0.0 <2.8.3: 2.8.3 @@ -152,9 +154,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/utils@9.2.0': - resolution: {integrity: sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -303,20 +302,8 @@ packages: '@bundled-es-modules/memfs@4.17.0': resolution: {integrity: sha512-ykdrkEmQr9BV804yd37ikXfNnvxrwYfY9Z2/EtMHFEFadEjsQXJ1zL9bVZrKNLDtm91UdUOEHso6Aweg93K6xQ==} - '@chevrotain/cst-dts-gen@11.0.3': - resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} - - '@chevrotain/gast@11.0.3': - resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} - - '@chevrotain/regexp-to-ast@11.0.3': - resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} - - '@chevrotain/types@11.0.3': - resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} - - '@chevrotain/utils@11.0.3': - resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} '@commander-js/extra-typings@14.0.0': resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} @@ -525,8 +512,8 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.0.1': - resolution: {integrity: sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==} + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -594,10 +581,10 @@ packages: '@mermaid-js/mermaid-zenuml@0.2.2': resolution: {integrity: sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw==} peerDependencies: - mermaid: ^10 || ^11 + mermaid: 11.15.0 - '@mermaid-js/parser@0.6.2': - resolution: {integrity: sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==} + '@mermaid-js/parser@1.1.1': + resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1042,6 +1029,9 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1225,8 +1215,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1298,14 +1288,6 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} - chevrotain-allstar@0.3.1: - resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} - peerDependencies: - chevrotain: ^11.0.0 - - chevrotain@11.0.3: - resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1388,12 +1370,6 @@ packages: resolution: {integrity: sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==} engines: {node: '>=18'} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1584,8 +1560,8 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} - dagre-d3-es@7.0.11: - resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} daisyui@4.12.24: resolution: {integrity: sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==} @@ -1595,8 +1571,8 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} - dayjs@1.11.18: - resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} @@ -1714,6 +1690,9 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -1759,9 +1738,6 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -1777,8 +1753,8 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fastparse@1.1.2: resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==} @@ -1875,10 +1851,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - globby@16.1.0: resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==} engines: {node: '>=20'} @@ -1968,8 +1940,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.1.1: - resolution: {integrity: sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} is-alphabetical@2.0.1: @@ -2124,16 +2096,13 @@ packages: resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==} hasBin: true + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - - langium@3.3.1: - resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} - engines: {node: '>=16.0.0'} - layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -2154,10 +2123,6 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - locate-path@8.0.0: resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} engines: {node: '>=20'} @@ -2205,9 +2170,9 @@ packages: resolution: {integrity: sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==} engines: {node: '>=20'} - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} hasBin: true marked@4.3.0: @@ -2230,8 +2195,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.11.0: - resolution: {integrity: sha512-9lb/VNkZqWTRjVgCV+l1N+t4kyi94y+l5xrmBmbbxZYkfRl5hEDaTPMOcaWKCl1McG8nBEaMlWwkcAEEgjhBgg==} + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2326,9 +2291,6 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2494,12 +2456,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -2602,9 +2558,6 @@ packages: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2988,9 +2941,6 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} @@ -3117,26 +3067,6 @@ packages: jsdom: optional: true - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - - vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -3162,8 +3092,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3224,8 +3154,6 @@ snapshots: package-manager-detector: 1.3.0 tinyexec: 1.0.1 - '@antfu/utils@9.2.0': {} - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -3399,22 +3327,7 @@ snapshots: stream: 0.0.3 util: 0.12.5 - '@chevrotain/cst-dts-gen@11.0.3': - dependencies: - '@chevrotain/gast': 11.0.3 - '@chevrotain/types': 11.0.3 - lodash-es: 4.18.1 - - '@chevrotain/gast@11.0.3': - dependencies: - '@chevrotain/types': 11.0.3 - lodash-es: 4.18.1 - - '@chevrotain/regexp-to-ast@11.0.3': {} - - '@chevrotain/types@11.0.3': {} - - '@chevrotain/utils@11.0.3': {} + '@chevrotain/types@11.1.2': {} '@commander-js/extra-typings@14.0.0(commander@14.0.2)': dependencies: @@ -3555,18 +3468,11 @@ snapshots: '@iconify/types@2.0.0': {} - '@iconify/utils@3.0.1': + '@iconify/utils@3.1.3': dependencies: '@antfu/install-pkg': 1.1.0 - '@antfu/utils': 9.2.0 '@iconify/types': 2.0.0 - debug: 4.4.3 - globals: 15.15.0 - kolorist: 1.8.0 - local-pkg: 1.1.2 - mlly: 1.8.0 - transitivePeerDependencies: - - supports-color + import-meta-resolve: 4.2.0 '@isaacs/cliui@8.0.2': dependencies: @@ -3633,32 +3539,31 @@ snapshots: '@mermaid-js/mermaid-cli@11.12.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2))': dependencies: - '@mermaid-js/mermaid-zenuml': 0.2.2(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(mermaid@11.11.0) + '@mermaid-js/mermaid-zenuml': 0.2.2(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(mermaid@11.15.0) chalk: 5.6.2 commander: 14.0.0 import-meta-resolve: 4.2.0 - mermaid: 11.11.0 + mermaid: 11.15.0 puppeteer: 23.11.1(typescript@5.9.2) transitivePeerDependencies: - '@babel/core' - '@babel/template' - '@types/react' - - supports-color - ts-node - '@mermaid-js/mermaid-zenuml@0.2.2(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(mermaid@11.11.0)': + '@mermaid-js/mermaid-zenuml@0.2.2(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(mermaid@11.15.0)': dependencies: '@zenuml/core': 3.40.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24) - mermaid: 11.11.0 + mermaid: 11.15.0 transitivePeerDependencies: - '@babel/core' - '@babel/template' - '@types/react' - ts-node - '@mermaid-js/parser@0.6.2': + '@mermaid-js/parser@1.1.1': dependencies: - langium: 3.3.1 + '@chevrotain/types': 11.1.2 '@nodelib/fs.scandir@2.1.5': dependencies: @@ -4180,6 +4085,11 @@ snapshots: '@types/node': 22.18.12 optional: true + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@22.18.12)(jiti@2.6.1)(yaml@2.8.3))': dependencies: '@babel/core': 7.28.4 @@ -4289,7 +4199,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4383,7 +4293,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.3 @@ -4455,20 +4365,6 @@ snapshots: check-error@2.1.1: {} - chevrotain-allstar@0.3.1(chevrotain@11.0.3): - dependencies: - chevrotain: 11.0.3 - lodash-es: 4.18.1 - - chevrotain@11.0.3: - dependencies: - '@chevrotain/cst-dts-gen': 11.0.3 - '@chevrotain/gast': 11.0.3 - '@chevrotain/regexp-to-ast': 11.0.3 - '@chevrotain/types': 11.0.3 - '@chevrotain/utils': 11.0.3 - lodash-es: 4.18.1 - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -4542,10 +4438,6 @@ snapshots: component-emitter@2.0.0: {} - confbox@0.1.8: {} - - confbox@0.2.2: {} - convert-source-map@2.0.0: {} cose-base@1.0.3: @@ -4761,7 +4653,7 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 - dagre-d3-es@7.0.11: + dagre-d3-es@7.0.14: dependencies: d3: 7.9.0 lodash-es: 4.18.1 @@ -4777,7 +4669,7 @@ snapshots: data-uri-to-buffer@6.0.2: {} - dayjs@1.11.18: {} + dayjs@1.11.20: {} debug@4.4.1: dependencies: @@ -4876,6 +4768,8 @@ snapshots: dependencies: es-errors: 1.3.0 + es-toolkit@1.46.1: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -4948,8 +4842,6 @@ snapshots: expect-type@1.2.2: {} - exsolve@1.0.7: {} - extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -4972,7 +4864,7 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fastparse@1.1.2: {} @@ -5077,8 +4969,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 - globals@15.15.0: {} - globby@16.1.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -5157,7 +5047,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.1.1: {} + ip-address@10.2.0: {} is-alphabetical@2.0.1: {} @@ -5275,17 +5165,11 @@ snapshots: dependencies: commander: 8.3.0 - khroma@2.1.0: {} - - kolorist@1.8.0: {} - - langium@3.3.1: + katex@0.16.47: dependencies: - chevrotain: 11.0.3 - chevrotain-allstar: 0.3.1(chevrotain@11.0.3) - vscode-languageserver: 9.0.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.0.8 + commander: 8.3.0 + + khroma@2.1.0: {} layout-base@1.0.2: {} @@ -5301,12 +5185,6 @@ snapshots: dependencies: uc.micro: 2.1.0 - local-pkg@1.1.2: - dependencies: - mlly: 1.8.0 - pkg-types: 2.3.0 - quansync: 0.2.11 - locate-path@8.0.0: dependencies: p-locate: 6.0.0 @@ -5373,7 +5251,7 @@ snapshots: transitivePeerDependencies: - supports-color - marked@15.0.12: {} + marked@16.4.2: {} marked@4.3.0: {} @@ -5392,30 +5270,29 @@ snapshots: merge2@1.4.1: {} - mermaid@11.11.0: + mermaid@11.15.0: dependencies: '@braintree/sanitize-url': 7.1.1 - '@iconify/utils': 3.0.1 - '@mermaid-js/parser': 0.6.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.1 '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.1 cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) cytoscape-fcose: 2.2.0(cytoscape@3.33.1) d3: 7.9.0 d3-sankey: 0.12.3 - dagre-d3-es: 7.0.11 - dayjs: 1.11.18 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 dompurify: 3.4.0 - katex: 0.16.22 + es-toolkit: 1.46.1 + katex: 0.16.47 khroma: 2.1.0 - lodash-es: 4.18.1 - marked: 15.0.12 + marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 ts-dedent: 2.2.0 uuid: 14.0.0 - transitivePeerDependencies: - - supports-color micromark-core-commonmark@2.0.3: dependencies: @@ -5596,7 +5473,7 @@ snapshots: minimatch@10.2.3: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimist@1.2.8: {} @@ -5604,13 +5481,6 @@ snapshots: mitt@3.0.1: {} - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.1 - ms@2.1.3: {} mz@2.7.0: @@ -5807,18 +5677,6 @@ snapshots: pirates@4.0.7: {} - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - pkg-types@2.3.0: - dependencies: - confbox: 0.2.2 - exsolve: 1.0.7 - pathe: 2.0.3 - points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -5908,7 +5766,7 @@ snapshots: debug: 4.4.1 devtools-protocol: 0.0.1367902 typed-query-selector: 2.12.0 - ws: 8.18.3 + ws: 8.20.1 transitivePeerDependencies: - bare-buffer - bufferutil @@ -5936,8 +5794,6 @@ snapshots: dependencies: side-channel: 1.1.0 - quansync@0.2.11: {} - queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -6129,7 +5985,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.1 + ip-address: 10.2.0 smart-buffer: 4.2.0 sonic-boom@4.2.0: @@ -6356,8 +6212,6 @@ snapshots: uc.micro@2.1.0: {} - ufo@1.6.1: {} - unbzip2-stream@1.4.3: dependencies: buffer: 5.7.1 @@ -6561,23 +6415,6 @@ snapshots: - tsx - yaml - vscode-jsonrpc@8.2.0: {} - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - - vscode-uri@3.0.8: {} - which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -6611,7 +6448,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: {} + ws@8.20.1: {} y18n@5.0.8: {} From ca470cf78daf00d1a8550e35a76dbede03e4ff80 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 20 May 2026 01:56:17 +0200 Subject: [PATCH 15/18] Require paginated users query implementations Remove the default `UsersQuery::list_users_page` implementation so missing pagination support fails at compile time instead of returning a runtime internal error. Make the adapter guardrail users test double implement the paginated method explicitly so it follows the tightened trait contract. --- backend/src/domain/ports/users_query.rs | 8 +-- .../tests/adapter_guardrails/doubles_users.rs | 69 +++++++++++++++---- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/backend/src/domain/ports/users_query.rs b/backend/src/domain/ports/users_query.rs index 9d01da03..122c08c0 100644 --- a/backend/src/domain/ports/users_query.rs +++ b/backend/src/domain/ports/users_query.rs @@ -62,11 +62,9 @@ pub trait UsersQuery: Send + Sync { /// Return one keyset-ordered users page for the authenticated user. async fn list_users_page( &self, - _authenticated_user: &UserId, - _request: ListUsersPageRequest, - ) -> Result { - Err(Error::internal("paginated users query is not implemented")) - } + authenticated_user: &UserId, + request: ListUsersPageRequest, + ) -> Result; } /// Temporary fixture users query used until persistence is wired. diff --git a/backend/tests/adapter_guardrails/doubles_users.rs b/backend/tests/adapter_guardrails/doubles_users.rs index f74e4db1..2bf781d0 100644 --- a/backend/tests/adapter_guardrails/doubles_users.rs +++ b/backend/tests/adapter_guardrails/doubles_users.rs @@ -5,7 +5,8 @@ use std::sync::{Arc, Mutex}; use super::recording_double_macro::recording_double; use async_trait::async_trait; use backend::domain::ports::{ - LoginService, UpdateUserInterestsRequest, UserInterestsCommand, UserProfileQuery, UsersQuery, + ListUsersPageRequest, LoginService, UpdateUserInterestsRequest, UserInterestsCommand, + UserProfileQuery, UsersPage, UsersQuery, }; use backend::domain::{Error, LoginCredentials, User, UserId, UserInterests}; @@ -60,20 +61,62 @@ impl LoginService for RecordingLoginService { } } -recording_double! { - /// Configurable success or failure outcome for RecordingUsersQuery. - pub(crate) enum UsersResponse { - Ok(Vec), - Err(Error), +/// Configurable success or failure outcome for RecordingUsersQuery. +#[derive(Clone)] +pub(crate) enum UsersResponse { + Ok(Vec), + Err(Error), +} + +#[derive(Clone)] +pub(crate) struct RecordingUsersQuery { + calls: Arc>>, + response: Arc>, +} + +impl RecordingUsersQuery { + pub(crate) fn new(response: UsersResponse) -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + response: Arc::new(Mutex::new(response)), + } } - pub(crate) struct RecordingUsersQuery { - calls: String, - trait: UsersQuery, - method: list_users(&self, authenticated_user: &UserId) -> Result, Error>, - record: authenticated_user.to_string(), - calls_lock: "users calls lock", - response_lock: "users response lock", + pub(crate) fn calls(&self) -> Vec { + self.calls.lock().expect("users calls lock").clone() + } + + pub(crate) fn set_response(&self, response: UsersResponse) { + *self.response.lock().expect("users response lock") = response; + } +} + +#[async_trait] +impl UsersQuery for RecordingUsersQuery { + async fn list_users(&self, authenticated_user: &UserId) -> Result, Error> { + self.calls + .lock() + .expect("users calls lock") + .push(authenticated_user.to_string()); + match self.response.lock().expect("users response lock").clone() { + UsersResponse::Ok(users) => Ok(users), + UsersResponse::Err(error) => Err(error), + } + } + + async fn list_users_page( + &self, + authenticated_user: &UserId, + _request: ListUsersPageRequest, + ) -> Result { + self.calls + .lock() + .expect("users calls lock") + .push(authenticated_user.to_string()); + match self.response.lock().expect("users response lock").clone() { + UsersResponse::Ok(users) => Ok(UsersPage::new(users, false)), + UsersResponse::Err(error) => Err(error), + } } } From bfebcda11e27f6d421409c40778c41dff2927806 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 20 May 2026 11:40:47 +0200 Subject: [PATCH 16/18] Tighten users pagination review findings Apply the still-valid review findings for users keyset pagination: build the users index concurrently, encode page limits with `NonZeroUsize`, strengthen cursor and trace-id coverage, and update the related documentation. Add the requested Rustdoc examples while keeping the existing module size gate satisfied. --- .../metadata.toml | 1 + .../up.sql | 2 +- backend/src/domain/ports/user_repository.rs | 15 ++-- backend/src/domain/ports/users_query.rs | 5 +- backend/src/domain/user.rs | 47 +++++++++- backend/src/inbound/http/users_pagination.rs | 85 ++++++++++++++++++- .../persistence/diesel_user_repository.rs | 5 +- .../persistence/diesel_users_query.rs | 20 +++-- backend/tests/diesel_user_repository.rs | 21 +++-- .../features/users_list_pagination.feature | 1 + backend/tests/users_list_pagination_bdd.rs | 13 ++- .../users_list_pagination_bdd/flow_support.rs | 24 ++++++ docs/backend-roadmap.md | 2 +- ...-users-offset-pagination-with-new-crate.md | 5 +- 14 files changed, 212 insertions(+), 34 deletions(-) create mode 100644 backend/migrations/2026-05-01-000000_add_users_created_at_id_index/metadata.toml diff --git a/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/metadata.toml b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/metadata.toml new file mode 100644 index 00000000..79e9221c --- /dev/null +++ b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/metadata.toml @@ -0,0 +1 @@ +run_in_transaction = false diff --git a/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql index 870244bc..3ae8e560 100644 --- a/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql +++ b/backend/migrations/2026-05-01-000000_add_users_created_at_id_index/up.sql @@ -1,3 +1,3 @@ -- Support keyset pagination over users ordered by creation time. -CREATE INDEX idx_users_created_at_id ON users (created_at, id); +CREATE INDEX CONCURRENTLY idx_users_created_at_id ON users (created_at, id); diff --git a/backend/src/domain/ports/user_repository.rs b/backend/src/domain/ports/user_repository.rs index f28c6194..684043f3 100644 --- a/backend/src/domain/ports/user_repository.rs +++ b/backend/src/domain/ports/user_repository.rs @@ -1,4 +1,6 @@ //! Port abstraction for user persistence adapters and their errors. +use std::num::NonZeroUsize; + use async_trait::async_trait; use pagination::Cursor; @@ -20,7 +22,7 @@ define_port_error! { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListUsersPageRequest { cursor: Option>, - limit: usize, + limit: NonZeroUsize, } impl ListUsersPageRequest { @@ -29,14 +31,17 @@ impl ListUsersPageRequest { /// # Examples /// /// ``` + /// use std::num::NonZeroUsize; + /// /// use backend::domain::ports::ListUsersPageRequest; /// - /// let request = ListUsersPageRequest::new(None, 20); + /// let limit = NonZeroUsize::new(20).expect("non-zero limit"); + /// let request = ListUsersPageRequest::new(None, limit); /// assert_eq!(request.limit(), 20); /// assert!(request.cursor().is_none()); /// ``` #[must_use] - pub const fn new(cursor: Option>, limit: usize) -> Self { + pub const fn new(cursor: Option>, limit: NonZeroUsize) -> Self { Self { cursor, limit } } @@ -49,12 +54,12 @@ impl ListUsersPageRequest { /// Return the caller-normalized page size. #[must_use] pub const fn limit(&self) -> usize { - self.limit + self.limit.get() } /// Consume the request into its cursor and limit components. #[must_use] - pub fn into_parts(self) -> (Option>, usize) { + pub fn into_parts(self) -> (Option>, NonZeroUsize) { (self.cursor, self.limit) } } diff --git a/backend/src/domain/ports/users_query.rs b/backend/src/domain/ports/users_query.rs index 122c08c0..73ef98f3 100644 --- a/backend/src/domain/ports/users_query.rs +++ b/backend/src/domain/ports/users_query.rs @@ -120,9 +120,12 @@ mod tests { #[tokio::test] async fn fixture_users_query_returns_first_paginated_page() { + use std::num::NonZeroUsize; + let query = FixtureUsersQuery; let user_id = UserId::new("11111111-1111-1111-1111-111111111111").expect("fixture user id"); - let request = ListUsersPageRequest::new(None, 20); + let request = + ListUsersPageRequest::new(None, NonZeroUsize::new(20).expect("non-zero page limit")); let page = query .list_users_page(&user_id, request) diff --git a/backend/src/domain/user.rs b/backend/src/domain/user.rs index 0947442f..4051a559 100644 --- a/backend/src/domain/user.rs +++ b/backend/src/domain/user.rs @@ -244,6 +244,17 @@ pub struct User { impl User { /// Build a new [`User`] from validated components. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::{DisplayName, User, UserId}; + /// let id = UserId::new("00000000-0000-0000-0000-000000000000").expect("valid id"); + /// let name = DisplayName::new("Ada Lovelace").expect("valid display name"); + /// let created_at = "2026-01-01T00:00:00Z".parse().expect("timestamp"); + /// let user = User::new(id, name, created_at); + /// assert_eq!(user.created_at(), created_at); + /// ``` pub fn new(id: UserId, display_name: DisplayName, created_at: DateTime) -> Self { Self { id, @@ -253,6 +264,16 @@ impl User { } /// Build a new [`User`] from validated components with the current time. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::{DisplayName, User, UserId}; + /// let id = UserId::new("00000000-0000-0000-0000-000000000000").expect("valid id"); + /// let name = DisplayName::new("Ada Lovelace").expect("valid display name"); + /// let user = User::with_current_timestamp(id.clone(), name); + /// assert_eq!(user.id(), &id); + /// ``` pub fn with_current_timestamp(id: UserId, display_name: DisplayName) -> Self { Self::new(id, display_name, Utc::now()) } @@ -269,7 +290,13 @@ impl User { /// Fallible constructor enforcing identifier and display name invariants. /// - /// Prefer [`User::new`] when components are already validated. + /// # Examples + /// + /// ``` + /// use backend::domain::User; + /// let user = User::try_from_strings("00000000-0000-0000-0000-000000000000", "Ada").expect("valid user"); + /// assert_eq!(user.display_name().as_ref(), "Ada"); + /// ``` pub fn try_from_strings( id: impl AsRef, display_name: impl Into, @@ -279,7 +306,14 @@ impl User { /// Fallible constructor enforcing invariants with an explicit timestamp. /// - /// Prefer [`User::new`] when components are already validated. + /// # Examples + /// + /// ``` + /// use backend::domain::User; + /// let created_at = "2026-01-01T00:00:00Z".parse().expect("timestamp"); + /// let user = User::try_from_strings_at("00000000-0000-0000-0000-000000000000", "Ada", created_at).expect("valid user"); + /// assert_eq!(user.created_at(), created_at); + /// ``` pub fn try_from_strings_at( id: impl AsRef, display_name: impl Into, @@ -302,6 +336,15 @@ impl User { } /// Timestamp when the user was first created. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::User; + /// # let created_at = "2026-01-01T00:00:00Z".parse().expect("timestamp"); + /// # let user = User::try_from_strings_at("00000000-0000-0000-0000-000000000000", "Ada", created_at).expect("valid user"); + /// assert_eq!(user.created_at(), created_at); + /// ``` pub fn created_at(&self) -> DateTime { self.created_at } diff --git a/backend/src/inbound/http/users_pagination.rs b/backend/src/inbound/http/users_pagination.rs index cdf41fba..d05ba1e0 100644 --- a/backend/src/inbound/http/users_pagination.rs +++ b/backend/src/inbound/http/users_pagination.rs @@ -4,6 +4,8 @@ //! construction, and OpenAPI response tokens. Domain ports receive decoded, //! transport-neutral pagination requests. +use std::num::NonZeroUsize; + use actix_web::HttpRequest; use pagination::{Cursor, Direction, MAX_LIMIT, PageParams, Paginated, PaginationLinks}; use serde::Deserialize; @@ -63,10 +65,29 @@ pub struct PaginatedUsersResponse { links: PaginationLinksSchema, } +/// Direction implied by a users page request. +/// +/// # Examples +/// +/// ``` +/// use backend::inbound::http::users_pagination::UsersPageDirection; +/// +/// let direction = UsersPageDirection::Next; +/// let label = match direction { +/// UsersPageDirection::First => "first", +/// UsersPageDirection::Next => "next", +/// UsersPageDirection::Prev => "prev", +/// }; +/// +/// assert_eq!(label, "next"); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UsersPageDirection { + /// The first page was requested without a cursor. First, + /// A forward cursor was requested. Next, + /// A backward cursor was requested. Prev, } @@ -76,6 +97,29 @@ pub enum UsersPageDirection { /// /// Returns an invalid request error when `limit` is malformed, zero, above /// [`MAX_LIMIT`], or when `cursor` is not an opaque users cursor. +/// +/// # Examples +/// +/// ```ignore +/// use backend::domain::ports::ListUsersPageRequest; +/// use backend::inbound::http::users_pagination::{ +/// PageParams, UsersListQueryParams, UsersPageDirection, parse_users_page_params, +/// }; +/// +/// let query = UsersListQueryParams { +/// cursor: None, +/// limit: Some("2".to_owned()), +/// }; +/// let (params, request, direction): ( +/// PageParams, +/// ListUsersPageRequest, +/// UsersPageDirection, +/// ) = parse_users_page_params(query).expect("valid users pagination params"); +/// +/// assert_eq!(params.limit(), 2); +/// assert_eq!(request.limit(), 2); +/// assert_eq!(direction, UsersPageDirection::First); +/// ``` pub fn parse_users_page_params( params: UsersListQueryParams, ) -> Result<(PageParams, ListUsersPageRequest, UsersPageDirection), Error> { @@ -91,7 +135,9 @@ pub fn parse_users_page_params( let direction = cursor .as_ref() .map_or(UsersPageDirection::First, cursor_direction); - let request = ListUsersPageRequest::new(cursor, page_params.limit()); + let limit = NonZeroUsize::new(page_params.limit()) + .ok_or_else(|| invalid_limit_error(params.limit.as_deref()))?; + let request = ListUsersPageRequest::new(cursor, limit); Ok((page_params, request, direction)) } @@ -101,6 +147,43 @@ pub fn parse_users_page_params( /// /// Returns an internal error if cursor encoding or request URL reconstruction /// fails. +/// +/// # Examples +/// +/// ``` +/// use actix_web::test::TestRequest; +/// use backend::domain::ports::UsersPage; +/// use backend::domain::User; +/// use backend::inbound::http::users_pagination::{ +/// UsersPageDirection, build_users_page_response, +/// }; +/// use pagination::{PageParams, Paginated}; +/// +/// let request = TestRequest::default() +/// .uri("/api/v1/users?limit=2") +/// .to_http_request(); +/// let params = PageParams::new(None, Some(2)).expect("valid page params"); +/// let user = User::try_from_strings_at( +/// "11111111-1111-1111-1111-111111111111", +/// "Ada One", +/// "2026-01-01T00:00:00Z".parse().expect("timestamp"), +/// ) +/// .expect("valid user"); +/// let page = UsersPage::new(vec![user], false); +/// +/// let response: Paginated = build_users_page_response( +/// &request, +/// ¶ms, +/// page, +/// UsersPageDirection::First, +/// ) +/// .expect("users page response"); +/// +/// assert_eq!(response.data.len(), 1); +/// assert_eq!(response.limit, 2); +/// assert!(response.links.next.is_none()); +/// assert!(response.links.prev.is_none()); +/// ``` pub fn build_users_page_response( request: &HttpRequest, params: &PageParams, diff --git a/backend/src/outbound/persistence/diesel_user_repository.rs b/backend/src/outbound/persistence/diesel_user_repository.rs index b06a773c..667fb23f 100644 --- a/backend/src/outbound/persistence/diesel_user_repository.rs +++ b/backend/src/outbound/persistence/diesel_user_repository.rs @@ -9,6 +9,8 @@ //! All Diesel and pool errors are mapped to the domain's `UserPersistenceError` //! variants using the constructor helpers generated by `define_port_error!`. +use std::num::NonZeroUsize; + use async_trait::async_trait; use diesel::prelude::*; use diesel::upsert::excluded; @@ -124,8 +126,9 @@ fn row_to_users(rows: Vec) -> Result, UserPersistenceError> { rows.into_iter().map(row_to_user).collect() } -fn fetch_limit(limit: usize) -> Result { +fn fetch_limit(limit: NonZeroUsize) -> Result { let with_overflow_row = limit + .get() .checked_add(1) .ok_or_else(|| UserPersistenceError::query("page limit overflow"))?; i64::try_from(with_overflow_row) diff --git a/backend/src/outbound/persistence/diesel_users_query.rs b/backend/src/outbound/persistence/diesel_users_query.rs index 9160909f..d2db2d8a 100644 --- a/backend/src/outbound/persistence/diesel_users_query.rs +++ b/backend/src/outbound/persistence/diesel_users_query.rs @@ -93,7 +93,7 @@ fn trim_overflow_row(rows: &mut Vec, limit: usize, direction: Direction) { #[cfg(test)] mod tests { //! Regression coverage for users query mapping and response shape. - use std::{error::Error as StdError, sync::Mutex}; + use std::{error::Error as StdError, num::NonZeroUsize, sync::Mutex}; use super::*; use crate::domain::ErrorCode; @@ -226,7 +226,7 @@ mod tests { fn request_with_cursor( user: &User, direction: Direction, - limit: usize, + limit: NonZeroUsize, ) -> ListUsersPageRequest { ListUsersPageRequest::new( Some(Cursor::with_direction(UserCursorKey::from(user), direction)), @@ -282,7 +282,7 @@ mod tests { } #[rstest] - #[case(Direction::Next, None, 0, 2)] + #[case(Direction::Next, Some(0usize), 0, 2)] #[case(Direction::Prev, Some(2usize), 1, 3)] #[tokio::test] async fn list_users_page_trims_overflow_row( @@ -311,10 +311,11 @@ mod tests { let repository = Arc::new(StubUserRepository::with_page_rows(rows.clone())); let query = DieselUsersQuery::from_repository(repository); let limit = rows.len() - 1; - let request = match cursor_index { - None => ListUsersPageRequest::new(None, limit), - Some(i) => request_with_cursor(&rows[i], direction, limit), - }; + let request = request_with_cursor( + &rows[cursor_index.expect("cursor-backed trimming case")], + direction, + NonZeroUsize::new(limit).expect("non-zero test page limit"), + ); let page = query.list_users_page(rows[0].id(), request).await?; @@ -338,7 +339,10 @@ mod tests { let err = query .list_users_page( &user_id("11111111-1111-1111-1111-111111111111")?, - ListUsersPageRequest::new(None, 20), + ListUsersPageRequest::new( + None, + NonZeroUsize::new(20).expect("non-zero test page limit"), + ), ) .await .expect_err("repository failures should map to domain errors"); diff --git a/backend/tests/diesel_user_repository.rs b/backend/tests/diesel_user_repository.rs index 35f2cfb1..b26d0448 100644 --- a/backend/tests/diesel_user_repository.rs +++ b/backend/tests/diesel_user_repository.rs @@ -11,6 +11,7 @@ //! synchronous steps and reuses a shared Tokio runtime in the test context. //! This keeps database operations deterministic and avoids recreating a runtime //! for each step. +use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use backend::domain::ports::{ListUsersPageRequest, UserPersistenceError, UserRepository}; @@ -319,7 +320,7 @@ fn diesel_list_page_uses_created_at_id_keyset_order(diesel_world: Option(()) }, diff --git a/backend/tests/features/users_list_pagination.feature b/backend/tests/features/users_list_pagination.feature index 42fa76e0..e0da1e97 100644 --- a/backend/tests/features/users_list_pagination.feature +++ b/backend/tests/features/users_list_pagination.feature @@ -34,3 +34,4 @@ Feature: Users list pagination Given db-present startup mode with five ordered users When the client requests the users list without a session Then the users response is unauthorised + And the users error response includes a trace id diff --git a/backend/tests/users_list_pagination_bdd.rs b/backend/tests/users_list_pagination_bdd.rs index bff8a2ed..47c946fa 100644 --- a/backend/tests/users_list_pagination_bdd.rs +++ b/backend/tests/users_list_pagination_bdd.rs @@ -11,10 +11,10 @@ use support::handle_cluster_setup_failure; mod flow_support; use flow_support::{ - ORDERED_USER_IDS, World, assert_error, assert_full_traversal, assert_next_only, - assert_prev_only, assert_status, assert_users, run_authenticated_request, run_first_page, - run_follow_next_to_final, run_next_then_prev, run_unauthenticated_request, seed_users, - setup_db_context, skip, store_db, + ORDERED_USER_IDS, World, assert_error, assert_error_trace_id, assert_full_traversal, + assert_next_only, assert_prev_only, assert_status, assert_users, run_authenticated_request, + run_first_page, run_follow_next_to_final, run_next_then_prev, run_unauthenticated_request, + seed_users, setup_db_context, skip, store_db, }; #[fixture] @@ -113,6 +113,11 @@ fn the_users_response_is_unauthorised(world: &mut World) { assert_status(world, 401); } +#[then("the users error response includes a trace id")] +fn the_users_error_response_includes_a_trace_id(world: &mut World) { + assert_error_trace_id(world); +} + #[scenario( path = "tests/features/users_list_pagination.feature", name = "First users page exposes the next link only" diff --git a/backend/tests/users_list_pagination_bdd/flow_support.rs b/backend/tests/users_list_pagination_bdd/flow_support.rs index 8f349723..0869dcd8 100644 --- a/backend/tests/users_list_pagination_bdd/flow_support.rs +++ b/backend/tests/users_list_pagination_bdd/flow_support.rs @@ -8,6 +8,7 @@ use actix_web::body::BoxBody; use actix_web::cookie::{Cookie, Key, SameSite}; use actix_web::dev::{Service, ServiceResponse}; use actix_web::{App, test as actix_test, web}; +use backend::domain::TRACE_ID_HEADER; use backend::domain::ports::{FixtureRouteSubmissionService, RouteSubmissionService}; use backend::inbound::http::state::HttpState; use backend::inbound::http::users::{LoginRequest, list_users, login}; @@ -49,6 +50,7 @@ pub(crate) struct World { #[derive(Clone, Debug)] struct Snapshot { status: u16, + trace_id: Option, body: Option, } @@ -172,8 +174,14 @@ where request = request.cookie(cookie); } let response = actix_test::call_service(app, request.to_request()).await; + let trace_id = response + .headers() + .get(TRACE_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); Snapshot { status: response.status().as_u16(), + trace_id, body: parse_json_body(actix_test::read_body(response).await.as_ref()), } } @@ -352,3 +360,19 @@ pub(crate) fn assert_error(world: &mut World, status: u16, detail_code: &str) { assert_eq!(error_detail_code(response), Some(detail_code)); }); } + +pub(crate) fn assert_error_trace_id(world: &mut World) { + with_world(world, |world| { + let response = world.last_response.as_ref().expect("response"); + let trace_id = response.trace_id.as_deref().expect("trace id header"); + assert!(!trace_id.is_empty()); + assert_eq!( + response + .body + .as_ref() + .and_then(|body| body.get("traceId")) + .and_then(Value::as_str), + Some(trace_id) + ); + }); +} diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index 870ff9d8..13646733 100644 --- a/docs/backend-roadmap.md +++ b/docs/backend-roadmap.md @@ -229,7 +229,7 @@ see `docs/keyset-pagination-design.md` for the detailed crate design. ### 4.2. Endpoint adoption -- [x] 4.2.1. Replace offset pagination in `GET /api/users` with the new crate, +- [x] 4.2.1. Replace offset pagination in `GET /api/v1/users` with the new crate, including Diesel filters that respect `(created_at, id)` ordering and bb8 connection pooling. - [ ] 4.2.2. Update the repository layer to surface pagination-aware errors diff --git a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md index 8f1398b1..993c340d 100644 --- a/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -429,8 +429,9 @@ The pagination crate (`backend/crates/pagination`) provides: `PaginationLinks::from_request(url, params, next, prev)` for link generation. -It is **not** yet declared in `backend/Cargo.toml`. Add it as -`pagination = { path = "crates/pagination" }`. +The dependency is already declared in `backend/Cargo.toml` as +`pagination = { path = "crates/pagination" }`, reflecting the historical +adoption work for this endpoint. User-visible response shape today is a raw JSON array. After the change it becomes: From 7c99d9692acd4acd2507e638117ad1244ffdf9bd Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 20 May 2026 18:44:39 +0200 Subject: [PATCH 17/18] Deduplicate users query recording double Extract the shared response path in `RecordingUsersQuery` so the list and paginated list methods record calls and map configured responses through one helper. --- .../tests/adapter_guardrails/doubles_users.rs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/backend/tests/adapter_guardrails/doubles_users.rs b/backend/tests/adapter_guardrails/doubles_users.rs index 2bf781d0..64b80b5a 100644 --- a/backend/tests/adapter_guardrails/doubles_users.rs +++ b/backend/tests/adapter_guardrails/doubles_users.rs @@ -89,11 +89,8 @@ impl RecordingUsersQuery { pub(crate) fn set_response(&self, response: UsersResponse) { *self.response.lock().expect("users response lock") = response; } -} -#[async_trait] -impl UsersQuery for RecordingUsersQuery { - async fn list_users(&self, authenticated_user: &UserId) -> Result, Error> { + fn respond(&self, authenticated_user: &UserId) -> Result, Error> { self.calls .lock() .expect("users calls lock") @@ -103,20 +100,21 @@ impl UsersQuery for RecordingUsersQuery { UsersResponse::Err(error) => Err(error), } } +} + +#[async_trait] +impl UsersQuery for RecordingUsersQuery { + async fn list_users(&self, authenticated_user: &UserId) -> Result, Error> { + self.respond(authenticated_user) + } async fn list_users_page( &self, authenticated_user: &UserId, _request: ListUsersPageRequest, ) -> Result { - self.calls - .lock() - .expect("users calls lock") - .push(authenticated_user.to_string()); - match self.response.lock().expect("users response lock").clone() { - UsersResponse::Ok(users) => Ok(UsersPage::new(users, false)), - UsersResponse::Err(error) => Err(error), - } + self.respond(authenticated_user) + .map(|users| UsersPage::new(users, false)) } } From 551264b350b070ab0a426c655939657e8cac81f2 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 20 May 2026 18:50:08 +0200 Subject: [PATCH 18/18] Document users page helpers Add runnable Rustdoc examples for the users page accessors and paginated query method so the public pagination port shows expected usage and results. --- backend/src/domain/ports/users_query.rs | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/backend/src/domain/ports/users_query.rs b/backend/src/domain/ports/users_query.rs index 73ef98f3..a130a941 100644 --- a/backend/src/domain/ports/users_query.rs +++ b/backend/src/domain/ports/users_query.rs @@ -35,18 +35,58 @@ impl UsersPage { } /// Borrow the users in this page. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::User; + /// use backend::domain::ports::UsersPage; + /// + /// let user = User::try_from_strings("00000000-0000-0000-0000-000000000000", "Ada") + /// .expect("valid user"); + /// let page = UsersPage::new(vec![user], false); + /// + /// let rows = page.rows(); + /// assert_eq!(rows.len(), 1); + /// assert_eq!(rows[0].display_name().as_ref(), "Ada"); + /// ``` #[must_use] pub fn rows(&self) -> &[User] { &self.rows } /// Consume the page and return its users. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::User; + /// use backend::domain::ports::UsersPage; + /// + /// let user = User::try_from_strings("00000000-0000-0000-0000-000000000000", "Ada") + /// .expect("valid user"); + /// let page = UsersPage::new(vec![user], true); + /// + /// let rows = page.into_rows(); + /// assert_eq!(rows.len(), 1); + /// assert_eq!(rows[0].display_name().as_ref(), "Ada"); + /// ``` #[must_use] pub fn into_rows(self) -> Vec { self.rows } /// Whether another page exists in the requested direction. + /// + /// # Examples + /// + /// ``` + /// use backend::domain::ports::UsersPage; + /// + /// let page = UsersPage::new(Vec::new(), true); + /// + /// assert!(page.has_more()); + /// ``` #[must_use] pub const fn has_more(&self) -> bool { self.has_more @@ -60,6 +100,31 @@ pub trait UsersQuery: Send + Sync { async fn list_users(&self, authenticated_user: &UserId) -> Result, Error>; /// Return one keyset-ordered users page for the authenticated user. + /// + /// # Examples + /// + /// ``` + /// # async fn example() -> Result<(), backend::domain::Error> { + /// use std::num::NonZeroUsize; + /// + /// use backend::domain::UserId; + /// use backend::domain::ports::{FixtureUsersQuery, ListUsersPageRequest, UsersQuery}; + /// + /// let query = FixtureUsersQuery; + /// let authenticated_user = + /// UserId::new("11111111-1111-1111-1111-111111111111").expect("valid user id"); + /// let request = ListUsersPageRequest::new( + /// None, + /// NonZeroUsize::new(20).expect("non-zero page limit"), + /// ); + /// + /// let page = query.list_users_page(&authenticated_user, request).await?; + /// + /// assert_eq!(page.rows().len(), 1); + /// assert!(!page.has_more()); + /// # Ok(()) + /// # } + /// ``` async fn list_users_page( &self, authenticated_user: &UserId,