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/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/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/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/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 new file mode 100644 index 00000000..3ae8e560 --- /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 CONCURRENTLY idx_users_created_at_id ON users (created_at, id); 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/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..684043f3 100644 --- a/backend/src/domain/ports/user_repository.rs +++ b/backend/src/domain/ports/user_repository.rs @@ -1,7 +1,10 @@ //! Port abstraction for user persistence adapters and their errors. +use std::num::NonZeroUsize; + 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 +18,52 @@ define_port_error! { } } +/// Request for a keyset-ordered page from the users table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListUsersPageRequest { + cursor: Option>, + limit: NonZeroUsize, +} + +impl ListUsersPageRequest { + /// Build a users page request from a cursor and caller-normalized limit. + /// + /// # Examples + /// + /// ``` + /// use std::num::NonZeroUsize; + /// + /// use backend::domain::ports::ListUsersPageRequest; + /// + /// 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: NonZeroUsize) -> 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.get() + } + + /// Consume the request into its cursor and limit components. + #[must_use] + pub fn into_parts(self) -> (Option>, NonZeroUsize) { + (self.cursor, self.limit) + } +} + #[async_trait] pub trait UserRepository: Send + Sync { /// Insert or update a user record. @@ -22,4 +71,18 @@ 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. + /// + /// 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, + ) -> 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..a130a941 100644 --- a/backend/src/domain/ports/users_query.rs +++ b/backend/src/domain/ports/users_query.rs @@ -7,13 +7,129 @@ 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. + /// + /// # 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 + } +} + /// 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. + /// + /// # 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, + request: ListUsersPageRequest, + ) -> Result; } /// Temporary fixture users query used until persistence is wired. @@ -23,19 +139,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()?]) + } - // 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)]) + async fn list_users_page( + &self, + _authenticated_user: &UserId, + request: ListUsersPageRequest, + ) -> Result { + if request.cursor().is_some() { + return Ok(UsersPage::new(Vec::new(), false)); + } + + 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 +182,23 @@ 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() { + 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, NonZeroUsize::new(20).expect("non-zero page limit")); + + 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..4051a559 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,44 @@ 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 } + /// + /// # 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, + display_name, + created_at: truncate_to_microseconds(created_at), + } + } + + /// 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()) } /// Build a new [`User`] from string inputs, panicking if validation fails. @@ -256,15 +290,39 @@ 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, + ) -> Result { + Self::try_from_strings_at(id, display_name, Utc::now()) + } + + /// Fallible constructor enforcing invariants with an explicit timestamp. + /// + /// # 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, + 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 +334,27 @@ impl User { pub fn display_name(&self) -> &DisplayName { &self.display_name } + + /// 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 + } +} + +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 +363,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 +386,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/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..d05ba1e0 --- /dev/null +++ b/backend/src/inbound/http/users_pagination.rs @@ -0,0 +1,285 @@ +//! 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 std::num::NonZeroUsize; + +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, +} + +/// 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, +} + +/// 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. +/// +/// # 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> { + 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 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)) +} + +/// Build the paginated HTTP response envelope for one users page. +/// +/// # Errors +/// +/// 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, + page: UsersPage, + direction: UsersPageDirection, +) -> Result, Error> { + let has_more = page.has_more(); + let rows = page.into_rows(); + 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, + 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 boundary_cursor( + row: Option<&User>, + cursor_dir: Direction, + page_dir: UsersPageDirection, + has_more: bool, +) -> Result, Error> { + 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(row, cursor_dir, 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/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..667fb23f 100644 --- a/backend/src/outbound/persistence/diesel_user_repository.rs +++ b/backend/src/outbound/persistence/diesel_user_repository.rs @@ -9,14 +9,18 @@ //! 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; 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}; @@ -115,7 +119,20 @@ 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)) +} + +fn row_to_users(rows: Vec) -> Result, UserPersistenceError> { + rows.into_iter().map(row_to_user).collect() +} + +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) + .map_err(|_| UserPersistenceError::query("page limit exceeds database range")) } #[async_trait] @@ -126,6 +143,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) @@ -155,6 +173,69 @@ 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(); + list_page_keyset(&mut conn, key, fetch_limit, direction).await? + } + }; + + row_to_users(rows) + } +} + +async fn list_page_keyset( + conn: &mut diesel_async::AsyncPgConnection, + key: UserCursorKey, + fetch_limit: i64, + direction: Direction, +) -> Result, UserPersistenceError> { + let mut query = users::table.into_boxed(); + + 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)?; + + if matches!(direction, Direction::Prev) { + 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..d2db2d8a 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,15 +50,55 @@ 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)] 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; + 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: NonZeroUsize, + ) -> 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,74 @@ mod tests { assert_eq!(err.code(), expected_code); Ok(()) } + + #[rstest] + #[case(Direction::Next, Some(0usize), 0, 2)] + #[case(Direction::Prev, Some(2usize), 1, 3)] + #[tokio::test] + 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", + "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 limit = rows.len() - 1; + 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?; + + assert_eq!(page.rows(), &rows[expected_start..expected_end]); + 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, + NonZeroUsize::new(20).expect("non-zero test page limit"), + ), + ) + .await + .expect_err("repository failures should map to domain errors"); + + assert_eq!(err.code(), expected_code); + Ok(()) + } } 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/doubles_users.rs b/backend/tests/adapter_guardrails/doubles_users.rs index f74e4db1..64b80b5a 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,60 @@ 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; + } + + fn respond(&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_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.respond(authenticated_user) + .map(|users| UsersPage::new(users, false)) } } 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..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!( @@ -221,6 +222,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_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/diesel_user_repository.rs b/backend/tests/diesel_user_repository.rs index f3fe00e5..b26d0448 100644 --- a/backend/tests/diesel_user_repository.rs +++ b/backend/tests/diesel_user_repository.rs @@ -11,11 +11,14 @@ //! 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::{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}; @@ -43,7 +46,18 @@ 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) +} + +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") } // ----------------------------------------------------------------------------- @@ -285,6 +299,73 @@ 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-02T00: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 limit = NonZeroUsize::new(2).expect("non-zero test page limit"); + + let first_page = repo + .list_page(ListUsersPageRequest::new(None, limit)) + .await?; + assert_eq!(first_page.as_slice(), &users[0..3]); + + let next_cursor = + Cursor::with_direction(UserCursorKey::from(&users[1]), Direction::Next); + let next_page = repo + .list_page(ListUsersPageRequest::new(Some(next_cursor), limit)) + .await?; + assert_eq!(next_page.as_slice(), &users[2..4]); + + let prev_cursor = + Cursor::with_direction(UserCursorKey::from(&users[2]), Direction::Prev); + let prev_page = repo + .list_page(ListUsersPageRequest::new(Some(prev_cursor), limit)) + .await?; + assert_eq!(prev_page.as_slice(), &users[0..2]); + + Ok::<(), UserPersistenceError>(()) + }, + |_, result| { + result.expect("paginated list succeeds"); + }, + ); +} + #[rstest] fn diesel_reports_errors_when_schema_missing( diesel_world: Option, diff --git a/backend/tests/features/users_list_pagination.feature b/backend/tests/features/users_list_pagination.feature new file mode 100644 index 00000000..e0da1e97 --- /dev/null +++ b/backend/tests/features/users_list_pagination.feature @@ -0,0 +1,37 @@ +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 + And the users error response includes a trace id 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/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/backend/tests/users_list_pagination_bdd.rs b/backend/tests/users_list_pagination_bdd.rs new file mode 100644 index 00000000..47c946fa --- /dev/null +++ b/backend/tests/users_list_pagination_bdd.rs @@ -0,0 +1,167 @@ +//! 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_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] +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); +} + +#[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" +)] +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..0869dcd8 --- /dev/null +++ b/backend/tests/users_list_pagination_bdd/flow_support.rs @@ -0,0 +1,378 @@ +//! 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::TRACE_ID_HEADER; +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, + trace_id: Option, + 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; + 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()), + } +} + +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)) +} + +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 = 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"); + 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) { + run_request(world, path, true); +} + +pub(crate) fn run_unauthenticated_request(world: &mut World) { + run_request(world, "/api/v1/users", false); +} + +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)); + }); +} + +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 66bdd4e2..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 -- [ ] 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 new file mode 100644 index 00000000..993c340d --- /dev/null +++ b/docs/execplans/backend-4-2-1-replace-users-offset-pagination-with-new-crate.md @@ -0,0 +1,902 @@ +# 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: IMPLEMENTED; DRAFT PR OPEN + +## 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 + +- [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). `cargo check -p backend`, `make + check-fmt`, `make lint`, and a clean rerun of `make test` passed on + 2026-05-01. +- [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. +- [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. +- [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. +- [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. +- [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; 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 + 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. +- [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 + +- 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. +- 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. +- 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`. +- 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. +- 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. +- 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 + +- 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. + +- 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. + +- 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. + +- 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 + +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 + +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. + +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: + +```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) 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: {}