Skip to content

Commit 3d213a3

Browse files
committed
support pagination keys
1 parent 96242fa commit 3d213a3

3 files changed

Lines changed: 206 additions & 38 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,11 @@ Local transaction-id conversion helpers:
230230
- `bt util xact to-pretty 1000192656880881099`
231231
- Convert pretty version id to transaction id:
232232
- `bt util xact from-pretty 81cd05ee665fdfb3`
233-
- Convert transaction id to timestamp:
233+
- Convert transaction id, pretty version id, or pagination key to timestamp (local timezone by default):
234234
- `bt util xact to-time 1000192656880881099`
235+
- `bt util xact to-time 81cd05ee665fdfb3`
236+
- `bt util xact to-time p07639577379371417602`
237+
- `bt util xact to-time p07639577379371417602 --utc`
235238
- `bt util xact to-time 1000192656880881099 --format unix`
236239
- Convert timestamp to transaction id:
237240
- `bt util xact from-time` (defaults to current time)
@@ -241,6 +244,8 @@ Local transaction-id conversion helpers:
241244
- Inspect any xact value:
242245
- `bt util xact inspect 1000192656880881099`
243246
- `bt util xact inspect 81cd05ee665fdfb3`
247+
- `bt util xact inspect p07639577379371417602`
248+
- `bt util xact inspect p07639577379371417602 --utc`
244249

245250
## `bt auth`
246251

src/util_cmd.rs

Lines changed: 191 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::{anyhow, bail, Context, Result};
2-
use chrono::{DateTime, NaiveDate, SecondsFormat, Utc};
2+
use chrono::{DateTime, Local, NaiveDate, SecondsFormat, Utc};
33
use clap::{Args, Subcommand, ValueEnum};
44
use serde::Serialize;
55

@@ -58,13 +58,17 @@ struct FromPrettyArgs {
5858

5959
#[derive(Debug, Clone, Args)]
6060
struct ToTimeArgs {
61-
/// Decimal transaction id
62-
#[arg(value_name = "XACT_ID")]
63-
xact_id: String,
61+
/// Decimal transaction id, 16-char pretty version id, or pagination key
62+
#[arg(value_name = "XACT_OR_PAGINATION")]
63+
value: String,
6464

6565
/// Output format for non-JSON mode
6666
#[arg(long, value_enum, default_value_t = TimeOutputFormat::Iso)]
6767
format: TimeOutputFormat,
68+
69+
/// Display ISO timestamps in UTC instead of the local timezone
70+
#[arg(long)]
71+
utc: bool,
6872
}
6973

7074
#[derive(Debug, Clone, Copy, ValueEnum, Eq, PartialEq)]
@@ -97,25 +101,35 @@ enum TimeInputFormat {
97101

98102
#[derive(Debug, Clone, Args)]
99103
struct InspectArgs {
100-
/// Decimal transaction id or 16-char pretty version id
101-
#[arg(value_name = "XACT_OR_VERSION")]
104+
/// Decimal transaction id, 16-char pretty version id, or pagination key
105+
#[arg(value_name = "XACT_OR_PAGINATION")]
102106
value: String,
107+
108+
/// Display ISO timestamps in UTC instead of the local timezone
109+
#[arg(long)]
110+
utc: bool,
103111
}
104112

105113
#[derive(Debug, Clone, Copy, Serialize)]
106114
#[serde(rename_all = "snake_case")]
107115
enum InputKind {
108116
XactId,
109117
PrettyVersionId,
118+
PaginationKey,
110119
}
111120

112121
#[derive(Debug, Clone, Serialize)]
113122
struct XactInfo {
114123
input_kind: InputKind,
115124
xact_id: String,
116125
pretty_version: String,
126+
#[serde(skip_serializing_if = "Option::is_none")]
127+
pagination_key: Option<String>,
128+
#[serde(skip_serializing_if = "Option::is_none")]
129+
pagination_row_num: Option<u16>,
117130
unix_seconds: u64,
118131
iso_utc: String,
132+
iso_local: String,
119133
counter: u16,
120134
}
121135

@@ -169,22 +183,26 @@ fn run_from_pretty(json: bool, args: FromPrettyArgs) -> Result<()> {
169183
}
170184

171185
fn run_to_time(json: bool, args: ToTimeArgs) -> Result<()> {
172-
let xact = parse_xact_id(&args.xact_id)?;
173-
let unix_seconds = xact_to_unix_seconds(xact);
174-
let iso = unix_seconds_to_iso(unix_seconds)?;
186+
let info = inspect_xact_like_input(&args.value)?;
175187
if json {
176-
println!(
177-
"{}",
178-
serde_json::to_string(&serde_json::json!({
179-
"xact_id": xact.to_string(),
180-
"unix_seconds": unix_seconds,
181-
"iso_utc": iso,
182-
}))?
183-
);
188+
let iso = display_iso(&info, args.utc).to_string();
189+
let mut payload = serde_json::json!({
190+
"input_kind": input_kind_label(info.input_kind),
191+
"xact_id": &info.xact_id,
192+
"unix_seconds": info.unix_seconds,
193+
"iso": iso,
194+
"iso_utc": &info.iso_utc,
195+
"iso_local": &info.iso_local,
196+
"timezone": timezone_label(args.utc),
197+
});
198+
if let Some(pagination_key) = info.pagination_key.as_deref() {
199+
payload["pagination_key"] = serde_json::json!(pagination_key);
200+
}
201+
println!("{}", serde_json::to_string(&payload)?);
184202
} else {
185203
match args.format {
186-
TimeOutputFormat::Iso => println!("{iso}"),
187-
TimeOutputFormat::Unix => println!("{unix_seconds}"),
204+
TimeOutputFormat::Iso => println!("{}", display_iso(&info, args.utc)),
205+
TimeOutputFormat::Unix => println!("{}", info.unix_seconds),
188206
}
189207
}
190208
Ok(())
@@ -218,41 +236,72 @@ fn run_from_time(json: bool, args: FromTimeArgs) -> Result<()> {
218236
fn run_inspect(json: bool, args: InspectArgs) -> Result<()> {
219237
let info = inspect_xact_like_input(&args.value)?;
220238
if json {
221-
println!("{}", serde_json::to_string(&info)?);
239+
let mut payload = serde_json::to_value(&info)?;
240+
payload["iso"] = serde_json::json!(display_iso(&info, args.utc));
241+
payload["timezone"] = serde_json::json!(timezone_label(args.utc));
242+
println!("{}", serde_json::to_string(&payload)?);
222243
} else {
223-
println!(
224-
"Input kind: {}\nXact ID: {}\nPretty version: {}\nUnix seconds: {}\nISO UTC: {}\nCounter: {}",
225-
match info.input_kind {
226-
InputKind::XactId => "xact_id",
227-
InputKind::PrettyVersionId => "pretty_version_id",
228-
},
229-
info.xact_id,
230-
info.pretty_version,
231-
info.unix_seconds,
232-
info.iso_utc,
233-
info.counter
234-
);
244+
let mut lines = vec![
245+
format!("Input kind: {}", input_kind_label(info.input_kind)),
246+
format!("Xact ID: {}", info.xact_id),
247+
format!("Pretty version: {}", info.pretty_version),
248+
format!("Unix seconds: {}", info.unix_seconds),
249+
format!(
250+
"{}: {}",
251+
iso_display_label(args.utc),
252+
display_iso(&info, args.utc)
253+
),
254+
format!("Counter: {}", info.counter),
255+
];
256+
if let Some(pagination_key) = info.pagination_key {
257+
lines.insert(1, format!("Pagination key: {pagination_key}"));
258+
}
259+
if let Some(row_num) = info.pagination_row_num {
260+
lines.push(format!("Pagination row number: {row_num}"));
261+
}
262+
println!("{}", lines.join("\n"));
235263
}
236264
Ok(())
237265
}
238266

239267
fn inspect_xact_like_input(value: &str) -> Result<XactInfo> {
240-
let (input_kind, xact_id) = if is_pretty_version(value) {
241-
(InputKind::PrettyVersionId, load_pretty_xact(value)?)
268+
let is_pagination_key = is_pagination_key_like(value);
269+
let (input_kind, xact_id, pagination_key, pagination_row_num) = if is_pagination_key {
270+
let parsed = parse_pagination_key(value)?;
271+
let unix_seconds = pagination_key_to_unix_seconds(parsed);
272+
let counter = pagination_key_xact_counter(parsed);
273+
let xact_id = build_xact_id(unix_seconds, counter);
274+
(
275+
InputKind::PaginationKey,
276+
xact_id.to_string(),
277+
Some(format_pagination_key(parsed)),
278+
Some(pagination_key_row_num(parsed)),
279+
)
280+
} else if is_pretty_version(value) {
281+
(
282+
InputKind::PrettyVersionId,
283+
load_pretty_xact(value)?,
284+
None,
285+
None,
286+
)
242287
} else {
243288
let parsed = parse_xact_id(value)?;
244-
(InputKind::XactId, parsed.to_string())
289+
(InputKind::XactId, parsed.to_string(), None, None)
245290
};
246291

247292
let xact = parse_xact_id(&xact_id)?;
248293
let unix_seconds = xact_to_unix_seconds(xact);
249-
let iso_utc = unix_seconds_to_iso(unix_seconds)?;
294+
let iso_utc = unix_seconds_to_iso_utc(unix_seconds)?;
295+
let iso_local = unix_seconds_to_iso_local(unix_seconds)?;
250296
Ok(XactInfo {
251297
input_kind,
252298
xact_id,
253299
pretty_version: prettify_xact(xact),
300+
pagination_key,
301+
pagination_row_num,
254302
unix_seconds,
255303
iso_utc,
304+
iso_local,
256305
counter: xact_counter(xact),
257306
})
258307
}
@@ -270,6 +319,23 @@ fn parse_xact_id(value: &str) -> Result<u64> {
270319
.with_context(|| format!("invalid transaction id '{value}'"))
271320
}
272321

322+
fn parse_pagination_key(value: &str) -> Result<u64> {
323+
let numeric = value
324+
.strip_prefix('p')
325+
.or_else(|| value.strip_prefix('P'))
326+
.ok_or_else(|| {
327+
anyhow!("invalid pagination key '{value}' (expected p followed by digits)")
328+
})?;
329+
330+
if numeric.is_empty() || !numeric.chars().all(|c| c.is_ascii_digit()) {
331+
bail!("invalid pagination key '{value}' (expected p followed by digits)");
332+
}
333+
334+
numeric
335+
.parse::<u64>()
336+
.with_context(|| format!("invalid pagination key '{value}'"))
337+
}
338+
273339
fn parse_timestamp(value: &str, input: TimeInputFormat) -> Result<u64> {
274340
match input {
275341
TimeInputFormat::Unix => value
@@ -335,17 +401,79 @@ fn build_xact_id(unix_seconds: u64, counter: u16) -> u64 {
335401
TOP_BITS | ((unix_seconds & 0xffff_ffff_ffff) << 16) | u64::from(counter)
336402
}
337403

338-
fn unix_seconds_to_iso(unix_seconds: u64) -> Result<String> {
404+
fn format_pagination_key(pagination_key: u64) -> String {
405+
format!("p{pagination_key:020}")
406+
}
407+
408+
fn pagination_key_to_unix_seconds(pagination_key: u64) -> u64 {
409+
pagination_key >> 32
410+
}
411+
412+
fn pagination_key_xact_counter(pagination_key: u64) -> u16 {
413+
((pagination_key >> 16) & 0xffff) as u16
414+
}
415+
416+
fn pagination_key_row_num(pagination_key: u64) -> u16 {
417+
(pagination_key & 0xffff) as u16
418+
}
419+
420+
fn unix_seconds_to_utc_datetime(unix_seconds: u64) -> Result<DateTime<Utc>> {
339421
let dt = DateTime::<Utc>::from_timestamp(unix_seconds as i64, 0).ok_or_else(|| {
340422
anyhow!("cannot represent unix timestamp as UTC datetime: {unix_seconds}")
341423
})?;
424+
Ok(dt)
425+
}
426+
427+
fn unix_seconds_to_iso_utc(unix_seconds: u64) -> Result<String> {
428+
let dt = unix_seconds_to_utc_datetime(unix_seconds)?;
429+
Ok(dt.to_rfc3339_opts(SecondsFormat::Secs, true))
430+
}
431+
432+
fn unix_seconds_to_iso_local(unix_seconds: u64) -> Result<String> {
433+
let dt = unix_seconds_to_utc_datetime(unix_seconds)?.with_timezone(&Local);
342434
Ok(dt.to_rfc3339_opts(SecondsFormat::Secs, true))
343435
}
344436

345437
fn is_pretty_version(value: &str) -> bool {
346438
value.len() == 16 && value.chars().all(|c| c.is_ascii_hexdigit())
347439
}
348440

441+
fn is_pagination_key_like(value: &str) -> bool {
442+
value.starts_with('p') || value.starts_with('P')
443+
}
444+
445+
fn input_kind_label(input_kind: InputKind) -> &'static str {
446+
match input_kind {
447+
InputKind::XactId => "xact_id",
448+
InputKind::PrettyVersionId => "pretty_version_id",
449+
InputKind::PaginationKey => "pagination_key",
450+
}
451+
}
452+
453+
fn display_iso(info: &XactInfo, utc: bool) -> &str {
454+
if utc {
455+
&info.iso_utc
456+
} else {
457+
&info.iso_local
458+
}
459+
}
460+
461+
fn timezone_label(utc: bool) -> &'static str {
462+
if utc {
463+
"utc"
464+
} else {
465+
"local"
466+
}
467+
}
468+
469+
fn iso_display_label(utc: bool) -> &'static str {
470+
if utc {
471+
"ISO UTC"
472+
} else {
473+
"ISO local"
474+
}
475+
}
476+
349477
fn current_unix_seconds() -> u64 {
350478
Utc::now().timestamp().max(0) as u64
351479
}
@@ -395,6 +523,32 @@ mod tests {
395523
assert_eq!(info.xact_id, "1000192656880881099");
396524
}
397525

526+
#[test]
527+
fn inspect_decodes_pagination_key_time() {
528+
let info = inspect_xact_like_input("p07639577379371417602").unwrap();
529+
assert!(matches!(info.input_kind, InputKind::PaginationKey));
530+
assert_eq!(
531+
info.pagination_key.as_deref(),
532+
Some("p07639577379371417602")
533+
);
534+
assert_eq!(info.xact_id, "1000197162952719243");
535+
assert_eq!(info.unix_seconds, 1_778_727_718);
536+
assert_eq!(info.iso_utc, "2026-05-14T03:01:58Z");
537+
assert_eq!(info.counter, 31_627);
538+
assert_eq!(info.pagination_row_num, Some(2));
539+
}
540+
541+
#[test]
542+
fn pagination_key_parser_accepts_short_form() {
543+
let info = inspect_xact_like_input("p0").unwrap();
544+
assert!(matches!(info.input_kind, InputKind::PaginationKey));
545+
assert_eq!(
546+
info.pagination_key.as_deref(),
547+
Some("p00000000000000000000")
548+
);
549+
assert_eq!(info.unix_seconds, 0);
550+
}
551+
398552
#[test]
399553
fn parse_iso_date_without_time() {
400554
assert_eq!(

tests/cli.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ fn setup_instrument_accepts_deprecated_agents_alias() {
121121
.success();
122122
}
123123

124+
#[test]
125+
fn util_xact_to_time_accepts_pagination_key_with_utc() {
126+
bt_command()
127+
.args(["util", "xact", "to-time", "p07639577379371417602", "--utc"])
128+
.assert()
129+
.success()
130+
.stdout(predicate::str::contains("2026-05-14T03:01:58Z"));
131+
}
132+
124133
#[test]
125134
fn setup_uses_codex_detected_on_path_without_explicit_agent() {
126135
let repo = make_git_repo();

0 commit comments

Comments
 (0)