fix(manager-core): F-P-012 keyset cursor on app_user_repo::list (created_at, id) tiebreaker

ORDER BY created_at DESC, id DESC but cursor was `WHERE created_at <
$2` — when two users were created at the same instant, pagination
could skip the second row at a page boundary or return it twice.

- Add ListCursor { created_at, id } with `<rfc3339>_<uuid>` encode /
  decode helpers.
- Change WHERE predicate to `(created_at, id) < ($2, $3)` matching the
  ORDER BY.
- Surface the opaque cursor string through UsersListOpts /
  UsersListPage / users_admin_api ListUsersResponse instead of the raw
  DateTime — keeps the wire format stable while the id half stops the
  boundary bug.

Same shape as F-P-005 (execution_logs).

AUDIT.md anchor: F-P-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:50:25 +02:00
parent 9cd1213aac
commit fea95bd63b
5 changed files with 61 additions and 26 deletions

View File

@@ -19,7 +19,6 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{
AppId, AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, Invitation, InvitationId,
InviteOpts, Principal, UpdateUserInput, UsersError, UsersListOpts, UsersService,
@@ -75,12 +74,14 @@ pub fn app_users_router(state: AppUsersState) -> Router {
#[derive(Debug, Serialize)]
pub struct ListUsersResponse {
pub users: Vec<AppUser>,
pub next_cursor: Option<DateTime<Utc>>,
/// F-P-012: opaque `<rfc3339>_<uuid>` cursor with id tiebreaker.
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListUsersQuery {
pub cursor: Option<DateTime<Utc>>,
/// F-P-012: opaque `<rfc3339>_<uuid>` cursor with id tiebreaker.
pub cursor: Option<String>,
pub limit: Option<i64>,
}