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 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/// Where a page ends: the last listed vCon's first stored time and uuid.
20/// Clients get it as opaque text and send it back unchanged.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Cursor {
23    stored_at: i64,
24    uuid: Uuid,
25}
26
27impl Cursor {
28    /// The cursor `Display` wrote, or None for any other text.
29    pub fn parse(text: &str) -> Option<Cursor> {
30        // The engine refuses padding and spare bits, so one cursor has one
31        // text.
32        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/// What a list asks for: at most `limit` vCons, after `cursor`, first
51/// stored at or after `since` and before `until`.
52#[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    /// A list query from its parameters' values, the one rule for them
65    /// wherever they come from: a limit of 1 to `MAX_LIMIT`, a cursor
66    /// `Cursor::parse` reads, and RFC 3339 times.
67    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    /// A list query from an HTTP query string, which may name `limit`,
92    /// `cursor`, `since` and `until`, each at most once.
93    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/// A live vCon as the list shows it.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct Listed {
111    pub uuid: Uuid,
112    /// Its current version.
113    pub version: VersionId,
114    /// When the uuid was first stored.
115    pub stored_at: OffsetDateTime,
116    /// When its current version was stored.
117    pub updated_at: OffsetDateTime,
118}
119
120/// One page of a list, and the cursor of the next one, None on the last.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Listing {
123    pub vcons: Vec<Listed>,
124    pub next: Option<Cursor>,
125}
126
127impl Listing {
128    /// The page as `GET /vcons` answers it, times in RFC 3339 UTC.
129    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    /// A page of the tenant's live vCons, newest first by when each uuid
151    /// was first stored, then by uuid. The page and its audit record are
152    /// one transaction.
153    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
173/// A time a query bounds the list by: RFC 3339, within what the catalog's
174/// nanoseconds hold.
175fn 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
187/// The page itself, read in `tx`. One row past the limit says whether a
188/// next page exists. A live uuid whose current version has no row is
189/// damage, never left out quietly.
190fn 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    /// Stores vCon `n` at `when`, and returns its version.
276    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    /// Every page of `query`, followed through its cursors.
295    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        // The last character carries no spare bits at 24 bytes, so any
345        // change to it is another cursor; a character outside the
346        // alphabet is none.
347        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        // Valid RFC 3339, but past what the catalog's nanoseconds hold.
413        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        // Replacing vCon 1 later changes its version, not its place.
469        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        // A restored vCon comes back in its first place.
498        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    /// Nine vCons, some stored in the same instant, so ties fall to the
508    /// uuid: newest first, then the higher uuid first.
509    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        // Between pages: a new vCon, vCon 5 replaced, vCon 4 deleted, and
554        // vCon 8, already listed, deleted then restored.
555        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    /// The columns of the vcon table, with their types, defaults and
676    /// whether each takes NULL.
677    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        // Back to version 1: no stored_at column and no index.
699        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}