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:
@@ -190,10 +190,7 @@ fn bind_list(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallC
|
||||
let cursor = match opts.get("cursor") {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => {
|
||||
let s = d.clone().into_string().unwrap_or_default();
|
||||
Some(parse_cursor(&s)?)
|
||||
}
|
||||
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||
Some(_) => return Err(runtime_err("users::list: cursor must be a string or ()")),
|
||||
};
|
||||
let svc = svc.clone();
|
||||
@@ -508,17 +505,12 @@ fn list_page_to_map(page: &UsersListPage) -> Map {
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor
|
||||
.map_or(Dynamic::UNIT, |d| Dynamic::from(d.to_rfc3339())),
|
||||
.as_ref()
|
||||
.map_or(Dynamic::UNIT, |s| Dynamic::from(s.clone())),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
fn parse_cursor(s: &str) -> Result<chrono::DateTime<chrono::Utc>, Box<EvalAltResult>> {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.map(|d| d.with_timezone(&chrono::Utc))
|
||||
.map_err(|e| runtime_err(&format!("users::list: invalid cursor: {e}")))
|
||||
}
|
||||
|
||||
fn parse_user_id(s: &str, ctx: &str) -> Result<AppUserId, Box<EvalAltResult>> {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map(AppUserId::from)
|
||||
|
||||
Reference in New Issue
Block a user