Skip to main content

vcon_server/store/
list.rs

1//! Listing a tenant's live vCons, newest stored first, a page at a time.
2//! The HTTP route, the MCP tools and the console all list through here,
3//! so they take the same parameters and leave the same audit record.
4
5use 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/// Where a page ends: the last listed vCon's first stored time and uuid.
19/// Clients get it as opaque text and send it back unchanged.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Cursor {
22    stored_at: i64,
23    uuid: Uuid,
24}
25
26impl Cursor {
27    /// The cursor of a page that ends at `last`.
28    pub fn after(last: &Listed) -> Cursor {
29        Cursor {
30            stored_at: nanos(last.stored_at),
31            uuid: last.uuid,
32        }
33    }
34
35    /// The cursor `Display` wrote, or None for any other text.
36    pub fn parse(text: &str) -> Option<Cursor> {
37        // The engine refuses padding and spare bits, so one cursor has one
38        // text.
39        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/// What a list asks for: at most `limit` vCons, after `cursor`, first
58/// stored at or after `since` and before `until`.
59#[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    /// A list query from its parameters' values, the one rule for them
72    /// wherever they come from: a limit of 1 to `MAX_LIMIT`, a cursor
73    /// `Cursor::parse` reads, and RFC 3339 times.
74    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    /// A list query from an HTTP query string, which may name `limit`,
99    /// `cursor`, `since` and `until`, each at most once.
100    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/// A live vCon as the list shows it.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Listed {
118    pub uuid: Uuid,
119    /// Its current version.
120    pub version: VersionId,
121    /// When the uuid was first stored.
122    pub stored_at: OffsetDateTime,
123    /// When its current version was stored.
124    pub updated_at: OffsetDateTime,
125}
126
127/// One page of a list, and the cursor of the next one, None on the last.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Listing {
130    pub vcons: Vec<Listed>,
131    pub next: Option<Cursor>,
132}
133
134impl Listing {
135    /// The page as `GET /vcons` answers it, times in RFC 3339 UTC.
136    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    /// A page of the tenant's live vCons, newest first by when each uuid
158    /// was first stored, then by uuid. The page and its audit record are
159    /// one transaction.
160    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
179/// A time a query bounds the list by: RFC 3339, within what the catalog's
180/// nanoseconds hold.
181fn 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
193/// The page itself, read in `tx`. One row past the limit says whether a
194/// next page exists. A live uuid whose current version has no row is
195/// damage, never left out quietly.
196fn 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    /// Stores vCon `n` at `when`, and returns its version.
254    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    /// Every page of `query`, followed through its cursors.
273    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        // The last character carries no spare bits at 24 bytes, so any
323        // change to it is another cursor; a character outside the
324        // alphabet is none.
325        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        // Valid RFC 3339, but past what the catalog's nanoseconds hold.
391        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        // Replacing vCon 1 later changes its version, not its place.
447        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        // A restored vCon comes back in its first place.
476        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    /// Nine vCons, some stored in the same instant, so ties fall to the
486    /// uuid: newest first, then the higher uuid first.
487    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        // Between pages: a new vCon, vCon 5 replaced, vCon 4 deleted, and
532        // vCon 8, already listed, deleted then restored.
533        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    /// The columns of the vcon table, with their types, defaults and
654    /// whether each takes NULL.
655    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        // Back to version 1: no stored_at column and no index, and none of
677        // version 4's places and pack tables.
678        f.store
679            .conn_for_test()
680            .execute_batch(&format!(
681                "{}
682                 DROP INDEX vcon_listed;
683                 ALTER TABLE vcon DROP COLUMN stored_at;
684                 PRAGMA user_version = 1;",
685                crate::store::packed::tests::BEFORE_PACKS
686            ))
687            .expect("made version 1");
688        f.reopen();
689        let version: i64 = f
690            .store
691            .conn_for_test()
692            .pragma_query_value(None, "user_version", |r| r.get(0))
693            .expect("version");
694        assert_eq!(
695            version,
696            crate::catalog::SCHEMA_VERSION,
697            "the catalog is brought to the current version"
698        );
699        assert_eq!(vcon_columns(&f), fresh, "the same table as a new catalog");
700        let after = f.store.list(&f.caller, &query(10)).expect("a page");
701        assert_eq!(after, before, "the first store times come back");
702        let indexed: i64 = f
703            .store
704            .conn_for_test()
705            .query_row(
706                "SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'vcon_listed'",
707                [],
708                |r| r.get(0),
709            )
710            .expect("index");
711        assert_eq!(indexed, 1);
712    }
713}