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