1use base64::Engine;
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use time::OffsetDateTime;
8use time::format_description::well_known::Rfc3339;
9use uuid::Uuid;
10
11use super::{Caller, Store, StoreError, Tx, commit, io};
12use crate::audit::{Action, Actor, Entry};
13use crate::backend::PageQuery;
14use crate::clock::{from_nanos, nanos};
15use crate::ids::VersionId;
16use crate::query::{self, BadQuery};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Cursor {
22 stored_at: i64,
23 uuid: Uuid,
24}
25
26impl Cursor {
27 pub fn after(last: &Listed) -> Cursor {
29 Cursor {
30 stored_at: nanos(last.stored_at),
31 uuid: last.uuid,
32 }
33 }
34
35 pub fn parse(text: &str) -> Option<Cursor> {
37 let bytes: [u8; 24] = URL_SAFE_NO_PAD.decode(text).ok()?.try_into().ok()?;
40 let (at, uuid) = bytes.split_at(8);
41 Some(Cursor {
42 stored_at: i64::from_be_bytes(at.try_into().ok()?),
43 uuid: Uuid::from_slice(uuid).ok()?,
44 })
45 }
46}
47
48impl std::fmt::Display for Cursor {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 let mut bytes = [0; 24];
51 bytes[..8].copy_from_slice(&self.stored_at.to_be_bytes());
52 bytes[8..].copy_from_slice(self.uuid.as_bytes());
53 f.write_str(&URL_SAFE_NO_PAD.encode(bytes))
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct ListQuery {
61 pub limit: u32,
62 pub cursor: Option<Cursor>,
63 pub since: Option<OffsetDateTime>,
64 pub until: Option<OffsetDateTime>,
65}
66
67impl ListQuery {
68 pub const DEFAULT_LIMIT: u32 = 100;
69 pub const MAX_LIMIT: u32 = 1000;
70
71 pub fn new(
75 limit: Option<u64>,
76 cursor: Option<&str>,
77 since: Option<&str>,
78 until: Option<&str>,
79 ) -> Result<ListQuery, BadQuery> {
80 let limit = match limit {
81 None => ListQuery::DEFAULT_LIMIT,
82 Some(n) => u32::try_from(n)
83 .ok()
84 .filter(|n| (1..=ListQuery::MAX_LIMIT).contains(n))
85 .ok_or(BadQuery)?,
86 };
87 let cursor = cursor
88 .map(|text| Cursor::parse(text).ok_or(BadQuery))
89 .transpose()?;
90 Ok(ListQuery {
91 limit,
92 cursor,
93 since: since.map(time_bound).transpose()?,
94 until: until.map(time_bound).transpose()?,
95 })
96 }
97
98 pub fn from_query(query: Option<&str>) -> Result<ListQuery, BadQuery> {
101 let mut read = query::pairs(query, &["limit", "cursor", "since", "until"])?;
102 let limit = read
103 .remove("limit")
104 .map(|n| n.parse::<u64>().map_err(|_| BadQuery))
105 .transpose()?;
106 ListQuery::new(
107 limit,
108 read.get("cursor").map(String::as_str),
109 read.get("since").map(String::as_str),
110 read.get("until").map(String::as_str),
111 )
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Listed {
118 pub uuid: Uuid,
119 pub version: VersionId,
121 pub stored_at: OffsetDateTime,
123 pub updated_at: OffsetDateTime,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Listing {
130 pub vcons: Vec<Listed>,
131 pub next: Option<Cursor>,
132}
133
134impl Listing {
135 pub fn to_json(&self) -> serde_json::Value {
137 let vcons: Vec<serde_json::Value> = self
138 .vcons
139 .iter()
140 .map(|v| {
141 serde_json::json!({
142 "uuid": v.uuid.to_string(),
143 "version": v.version.to_string(),
144 "stored_at": rfc3339(v.stored_at),
145 "updated_at": rfc3339(v.updated_at),
146 })
147 })
148 .collect();
149 serde_json::json!({
150 "vcons": vcons,
151 "next": self.next.map(|c| c.to_string()),
152 })
153 }
154}
155
156impl Store {
157 pub fn list(&self, who: &Caller, query: &ListQuery) -> Result<Listing, StoreError> {
161 let mut tx = self.catalog.immediate()?;
162 let listing = page(&mut tx, query);
163 let status = if listing.is_ok() { 200 } else { 500 };
164 self.record(
165 &mut tx,
166 &Entry {
167 actor: Actor::Key(who.key),
168 action: Action::List,
169 target: None,
170 version: None,
171 status,
172 },
173 )?;
174 commit(tx)?;
175 listing
176 }
177}
178
179fn time_bound(text: &str) -> Result<OffsetDateTime, BadQuery> {
182 let at = OffsetDateTime::parse(text, &Rfc3339).map_err(|_| BadQuery)?;
183 i64::try_from(at.unix_timestamp_nanos()).map_err(|_| BadQuery)?;
184 Ok(at.to_offset(time::UtcOffset::UTC))
185}
186
187fn rfc3339(at: OffsetDateTime) -> String {
188 at.to_offset(time::UtcOffset::UTC)
189 .format(&Rfc3339)
190 .expect("every time a catalog holds formats")
191}
192
193fn page(tx: &mut Tx, query: &ListQuery) -> Result<Listing, StoreError> {
197 let found = tx.page(&PageQuery {
198 before: query.cursor.map(|c| (c.stored_at, c.uuid)),
199 since: query.since.map(nanos),
200 until: query.until.map(nanos),
201 rows: i64::from(query.limit) + 1,
202 })?;
203 let mut vcons = Vec::with_capacity(found.len());
204 for row in found {
205 let (Some(first), Some(Some(current))) =
206 (from_nanos(row.stored_at), row.updated_at.map(from_nanos))
207 else {
208 return Err(StoreError::Damaged);
209 };
210 vcons.push(Listed {
211 uuid: row.uuid,
212 version: row.version,
213 stored_at: first,
214 updated_at: current,
215 });
216 }
217 let limit = usize::try_from(query.limit).map_err(io)?;
218 let next = if vcons.len() > limit {
219 vcons.truncate(limit);
220 vcons.last().map(|last| Cursor {
221 stored_at: nanos(last.stored_at),
222 uuid: last.uuid,
223 })
224 } else {
225 None
226 };
227 Ok(Listing { vcons, next })
228}
229
230#[cfg(test)]
231mod tests {
232 use std::collections::BTreeSet;
233
234 use serde_json::json;
235 use time::macros::datetime;
236
237 use super::super::testing::vcon;
238 use super::super::tests::{Fixture, fixture};
239 use super::*;
240 use crate::retention::Retention;
241 use crate::store::Created;
242
243 fn uuid(n: u32) -> String {
244 format!("01a07da8-c2bb-83e5-b9a2-{n:012x}")
245 }
246
247 fn at(minute: u8) -> OffsetDateTime {
248 datetime!(2026-10-25 12:00 UTC)
249 .replace_minute(minute)
250 .expect("a minute")
251 }
252
253 fn store(f: &Fixture, n: u32, when: OffsetDateTime) -> VersionId {
255 f.clock.set(when);
256 let (found, body) = vcon(&uuid(n), "listed");
257 match f.store.create(&f.caller, &found, &body) {
258 Ok(Created::New(version)) => version,
259 other => panic!("vCon {n}: {other:?}"),
260 }
261 }
262
263 fn query(limit: u32) -> ListQuery {
264 ListQuery {
265 limit,
266 cursor: None,
267 since: None,
268 until: None,
269 }
270 }
271
272 fn pages(f: &Fixture, first: ListQuery) -> Vec<Listing> {
274 let mut pages = Vec::new();
275 let mut next = Some(first);
276 while let Some(query) = next {
277 let page = f.store.list(&f.caller, &query).expect("a page");
278 next = page.next.map(|cursor| ListQuery {
279 cursor: Some(cursor),
280 ..query
281 });
282 pages.push(page);
283 assert!(pages.len() < 100, "the cursors end");
284 }
285 pages
286 }
287
288 fn uuids(listing: &[Listing]) -> Vec<String> {
289 listing
290 .iter()
291 .flat_map(|p| p.vcons.iter().map(|v| v.uuid.to_string()))
292 .collect()
293 }
294
295 fn records(f: &Fixture, action: &str) -> Vec<serde_json::Value> {
296 f.store
297 .records()
298 .into_iter()
299 .filter(|r| r["action"] == action)
300 .collect()
301 }
302
303 #[test]
304 fn a_cursor_reads_back_from_its_text_and_from_nothing_else() {
305 let cursor = Cursor {
306 stored_at: 1_792_929_600_123_456_789,
307 uuid: Uuid::parse_str(&uuid(7)).expect("uuid"),
308 };
309 let text = cursor.to_string();
310 assert_eq!(text.len(), 32, "24 bytes in base64url without padding");
311 assert!(
312 text.bytes()
313 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
314 );
315 assert_eq!(Cursor::parse(&text), Some(cursor));
316 let negative = Cursor {
317 stored_at: -1,
318 uuid: Uuid::nil(),
319 };
320 assert_eq!(Cursor::parse(&negative.to_string()), Some(negative));
321 let mut other_last = text.clone().into_bytes();
322 other_last[31] = b'!';
326 for bad in [
327 String::new(),
328 text[..31].to_owned(),
329 format!("{text}A"),
330 format!("{text}="),
331 String::from_utf8(other_last).expect("ASCII"),
332 text.replace(['-', '_'], "+"),
333 ] {
334 if bad == text {
335 continue;
336 }
337 assert_eq!(Cursor::parse(&bad), None, "{bad:?}");
338 }
339 }
340
341 #[test]
342 fn a_query_takes_a_limit_of_1_to_1000_a_cursor_and_rfc_3339_times() {
343 let cursor = Cursor {
344 stored_at: 5,
345 uuid: Uuid::nil(),
346 };
347 assert_eq!(ListQuery::new(None, None, None, None), Ok(query(100)));
348 assert_eq!(ListQuery::new(Some(1), None, None, None), Ok(query(1)));
349 assert_eq!(
350 ListQuery::new(Some(1000), None, None, None),
351 Ok(query(1000))
352 );
353 for limit in [0, 1001, u64::MAX] {
354 assert_eq!(
355 ListQuery::new(Some(limit), None, None, None),
356 Err(BadQuery),
357 "{limit}"
358 );
359 }
360 assert_eq!(
361 ListQuery::new(
362 None,
363 Some(&cursor.to_string()),
364 Some("2026-10-25T13:00:00+01:00"),
365 Some("2026-10-26T00:00:00.5Z"),
366 ),
367 Ok(ListQuery {
368 limit: 100,
369 cursor: Some(cursor),
370 since: Some(datetime!(2026-10-25 12:00 UTC)),
371 until: Some(datetime!(2026-10-26 00:00:00.5 UTC)),
372 })
373 );
374 assert_eq!(
375 ListQuery::new(None, Some("not-a-cursor"), None, None),
376 Err(BadQuery)
377 );
378 for time in ["2026-10-25", "yesterday", "", "2026-10-25T12:00:00"] {
379 assert_eq!(
380 ListQuery::new(None, None, Some(time), None),
381 Err(BadQuery),
382 "{time}"
383 );
384 assert_eq!(
385 ListQuery::new(None, None, None, Some(time)),
386 Err(BadQuery),
387 "{time}"
388 );
389 }
390 assert_eq!(
392 ListQuery::new(None, None, Some("2263-01-01T00:00:00Z"), None),
393 Err(BadQuery)
394 );
395 }
396
397 #[test]
398 fn a_query_string_names_each_parameter_at_most_once() {
399 assert_eq!(ListQuery::from_query(None), Ok(query(100)));
400 assert_eq!(
401 ListQuery::from_query(Some("limit=7&since=2026-10-25T12%3A00%3A00Z")),
402 Ok(ListQuery {
403 since: Some(datetime!(2026-10-25 12:00 UTC)),
404 ..query(7)
405 })
406 );
407 for bad in [
408 "limit=abc",
409 "limit=-1",
410 "limit=1.5",
411 "limit=",
412 "offset=5",
413 "limit=5&limit=6",
414 "cursor=",
415 "until=2026-10-25",
416 ] {
417 assert_eq!(ListQuery::from_query(Some(bad)), Err(BadQuery), "{bad}");
418 }
419 }
420
421 #[test]
422 fn an_empty_store_lists_nothing_and_records_the_list() {
423 let f = fixture();
424 let page = f.store.list(&f.caller, &query(10)).expect("a page");
425 assert_eq!(
426 page,
427 Listing {
428 vcons: vec![],
429 next: None
430 }
431 );
432 let listed = records(&f, "list");
433 assert_eq!(listed.len(), 1);
434 assert_eq!(listed[0]["actor"], f.caller.key.to_string());
435 assert_eq!(listed[0]["status"], 200);
436 assert_eq!(listed[0]["target"], serde_json::Value::Null);
437 assert_eq!(listed[0]["version"], serde_json::Value::Null);
438 }
439
440 #[test]
441 fn the_list_is_newest_first_by_first_store_and_leaves_out_deleted_vcons() {
442 let f = fixture();
443 let v1 = store(&f, 1, at(1));
444 let v2 = store(&f, 2, at(2));
445 store(&f, 3, at(3));
446 f.clock.set(at(10));
448 let (found, body) = vcon(&uuid(1), "replaced");
449 let v1b = f
450 .store
451 .replace(&f.caller, v1, &found, &body)
452 .expect("replaced");
453 f.store
454 .delete(&f.caller, found_uuid(3), Retention::Years(7))
455 .expect("deleted");
456 let page = f.store.list(&f.caller, &query(10)).expect("a page");
457 assert_eq!(
458 page.vcons,
459 vec![
460 Listed {
461 uuid: found_uuid(2),
462 version: v2,
463 stored_at: at(2),
464 updated_at: at(2),
465 },
466 Listed {
467 uuid: found_uuid(1),
468 version: v1b,
469 stored_at: at(1),
470 updated_at: at(10),
471 },
472 ]
473 );
474 assert_eq!(page.next, None);
475 f.store.restore(&f.caller, found_uuid(3)).expect("restored");
477 let page = f.store.list(&f.caller, &query(10)).expect("a page");
478 assert_eq!(uuids(&[page]), vec![uuid(3), uuid(2), uuid(1)]);
479 }
480
481 fn found_uuid(n: u32) -> Uuid {
482 Uuid::parse_str(&uuid(n)).expect("uuid")
483 }
484
485 fn nine(f: &Fixture) -> Vec<String> {
488 for (n, minute) in [
489 (1, 1),
490 (2, 2),
491 (3, 2),
492 (4, 2),
493 (5, 3),
494 (6, 4),
495 (7, 4),
496 (8, 5),
497 (9, 6),
498 ] {
499 store(f, n, at(minute));
500 }
501 [9, 8, 7, 6, 5, 4, 3, 2, 1].map(uuid).to_vec()
502 }
503
504 #[test]
505 fn paging_at_every_size_returns_each_vcon_exactly_once_in_order() {
506 let f = fixture();
507 let all = nine(&f);
508 for limit in 1..=10 {
509 let pages = pages(&f, query(limit));
510 assert_eq!(uuids(&pages), all, "limit {limit}");
511 let full = usize::try_from(limit).expect("small");
512 for page in &pages[..pages.len() - 1] {
513 assert_eq!(page.vcons.len(), full, "limit {limit}");
514 assert!(page.next.is_some());
515 }
516 assert_eq!(pages.last().expect("a page").next, None, "limit {limit}");
517 assert_eq!(
518 pages.len(),
519 all.len().div_ceil(full).max(1),
520 "limit {limit}"
521 );
522 }
523 }
524
525 #[test]
526 fn changes_between_pages_move_no_other_vcon() {
527 let f = fixture();
528 nine(&f);
529 let first = f.store.list(&f.caller, &query(3)).expect("a page");
530 assert_eq!(uuids(std::slice::from_ref(&first)), [9, 8, 7].map(uuid));
531 store(&f, 10, at(30));
534 f.clock.set(at(31));
535 let current = f
536 .store
537 .list(&f.caller, &query(10))
538 .expect("a page")
539 .vcons
540 .iter()
541 .find(|v| v.uuid == found_uuid(5))
542 .expect("vCon 5")
543 .version;
544 let (found, body) = vcon(&uuid(5), "replaced");
545 f.store
546 .replace(&f.caller, current, &found, &body)
547 .expect("replaced");
548 f.store
549 .delete(&f.caller, found_uuid(4), Retention::Years(7))
550 .expect("deleted");
551 f.store
552 .delete(&f.caller, found_uuid(8), Retention::Years(7))
553 .expect("deleted");
554 f.store.restore(&f.caller, found_uuid(8)).expect("restored");
555 let rest = pages(
556 &f,
557 ListQuery {
558 cursor: first.next,
559 ..query(3)
560 },
561 );
562 let mut seen = uuids(std::slice::from_ref(&first));
563 seen.extend(uuids(&rest));
564 assert_eq!(seen, [9, 8, 7, 6, 5, 3, 2, 1].map(uuid));
565 let unique: BTreeSet<&String> = seen.iter().collect();
566 assert_eq!(unique.len(), seen.len(), "no vCon twice");
567 }
568
569 #[test]
570 fn since_is_inclusive_and_until_exclusive() {
571 let f = fixture();
572 nine(&f);
573 let between = |since: Option<u8>, until: Option<u8>| {
574 uuids(&pages(
575 &f,
576 ListQuery {
577 since: since.map(at),
578 until: until.map(at),
579 ..query(2)
580 },
581 ))
582 };
583 assert_eq!(between(Some(2), Some(4)), [5, 4, 3, 2].map(uuid));
584 assert_eq!(between(Some(4), None), [9, 8, 7, 6].map(uuid));
585 assert_eq!(between(None, Some(2)), vec![uuid(1)]);
586 assert_eq!(between(Some(4), Some(4)), Vec::<String>::new());
587 assert_eq!(between(Some(5), Some(2)), Vec::<String>::new());
588 }
589
590 #[test]
591 fn a_live_vcon_whose_version_row_is_missing_is_damage_and_recorded() {
592 let f = fixture();
593 store(&f, 1, at(1));
594 f.store
595 .conn_for_test()
596 .execute("DELETE FROM version", [])
597 .expect("damaged");
598 assert_eq!(
599 f.store.list(&f.caller, &query(10)),
600 Err(StoreError::Damaged)
601 );
602 let listed = records(&f, "list");
603 assert_eq!(listed.len(), 1);
604 assert_eq!(listed[0]["status"], 500);
605 }
606
607 #[test]
608 fn a_list_whose_record_fails_lists_nothing() {
609 let f = fixture();
610 store(&f, 1, at(1));
611 f.store.break_audit();
612 assert_eq!(
613 f.store.list(&f.caller, &query(10)),
614 Err(StoreError::AuditFailed)
615 );
616 }
617
618 #[test]
619 fn a_page_as_json() {
620 let cursor = Cursor {
621 stored_at: 1,
622 uuid: Uuid::nil(),
623 };
624 let version = VersionId([0xab; 16]);
625 let page = Listing {
626 vcons: vec![Listed {
627 uuid: found_uuid(1),
628 version,
629 stored_at: datetime!(2026-10-25 12:00 UTC),
630 updated_at: datetime!(2026-10-25 12:30:00.25 UTC),
631 }],
632 next: Some(cursor),
633 };
634 assert_eq!(
635 page.to_json(),
636 json!({
637 "vcons": [{
638 "uuid": uuid(1),
639 "version": version.to_string(),
640 "stored_at": "2026-10-25T12:00:00Z",
641 "updated_at": "2026-10-25T12:30:00.25Z",
642 }],
643 "next": cursor.to_string(),
644 })
645 );
646 let last = Listing {
647 vcons: vec![],
648 next: None,
649 };
650 assert_eq!(last.to_json(), json!({"vcons": [], "next": null}));
651 }
652
653 fn vcon_columns(f: &Fixture) -> Vec<(String, String, Option<String>, bool)> {
656 let conn = f.store.conn_for_test();
657 let mut rows = conn.prepare("PRAGMA table_info(vcon)").expect("prepare");
658 rows.query_map([], |r| Ok((r.get(1)?, r.get(2)?, r.get(4)?, r.get(3)?)))
659 .expect("query")
660 .collect::<Result<_, _>>()
661 .expect("rows")
662 }
663
664 #[test]
665 fn a_version_1_catalog_is_upgraded_with_each_uuids_first_store_time() {
666 let mut f = fixture();
667 let fresh = vcon_columns(&f);
668 let v1 = store(&f, 1, at(1));
669 store(&f, 2, at(2));
670 f.clock.set(at(20));
671 let (found, body) = vcon(&uuid(1), "replaced");
672 f.store
673 .replace(&f.caller, v1, &found, &body)
674 .expect("replaced");
675 let before = f.store.list(&f.caller, &query(10)).expect("a page");
676 f.store
678 .conn_for_test()
679 .execute_batch(
680 "DROP INDEX vcon_listed;
681 ALTER TABLE vcon DROP COLUMN stored_at;
682 PRAGMA user_version = 1;",
683 )
684 .expect("made version 1");
685 f.reopen();
686 let version: i64 = f
687 .store
688 .conn_for_test()
689 .pragma_query_value(None, "user_version", |r| r.get(0))
690 .expect("version");
691 assert_eq!(version, 2);
692 assert_eq!(vcon_columns(&f), fresh, "the same table as a new catalog");
693 let after = f.store.list(&f.caller, &query(10)).expect("a page");
694 assert_eq!(after, before, "the first store times come back");
695 let indexed: i64 = f
696 .store
697 .conn_for_test()
698 .query_row(
699 "SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'vcon_listed'",
700 [],
701 |r| r.get(0),
702 )
703 .expect("index");
704 assert_eq!(indexed, 1);
705 }
706}