Layer settings over a file that documents itself
Compiled default, then the file, then the environment -- with the environment winning, which is the reverse of the usual arrangement and is the point: LUMBRIDGE_CLAUDE_OAUTH=0 is documented as one switch off, and a switch a configuration file can silently turn back on is not a switch. Where a variable has pinned a value the settings pane shows that row disabled and names the variable rather than accepting an edit that would do nothing. default.toml carries every key Lumbridge understands, commented out, showing the compiled default, and a test uncomments them and asserts the key set is exactly the set the binary knows -- so neither half can drift from the other. toml reads and toml_edit writes, because serialising the parsed model back over the file would strip every comment and every key this build does not recognise, and here the comments are the documentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
This commit is contained in:
co-authored by
Claude Opus 5
parent
401760d670
commit
28e455f968
@@ -10,6 +10,14 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
toml = "0.9"
|
||||
# `toml` reads; `toml_edit` writes. Serialising the parsed model back over the
|
||||
# file would strip every comment and every key this build does not recognise,
|
||||
# and in this design the comments are the documentation.
|
||||
toml_edit = "0.25"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Already in the workspace's dependency closure via lumbridge-storage's tests.
|
||||
tempfile = "3.27.0"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Lumbridge settings.
|
||||
#
|
||||
# This file is the documentation. Every key Lumbridge understands appears below,
|
||||
# commented out, showing the value Lumbridge uses when you say nothing — so the
|
||||
# whole configuration surface can be read without leaving the file.
|
||||
#
|
||||
# Two kinds of comment appear here and the difference is load-bearing:
|
||||
#
|
||||
# #key = value a setting, commented out, showing the compiled default.
|
||||
# Remove the '#' to take it over.
|
||||
# # a sentence explanation. Never a setting.
|
||||
#
|
||||
# A test uncomments the first kind, parses the result, and checks that the set
|
||||
# of keys is exactly the set Lumbridge knows about. Neither half can drift.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layers
|
||||
#
|
||||
# compiled default -> this file -> the environment
|
||||
#
|
||||
# The environment wins, which is the reverse of the usual arrangement and is
|
||||
# deliberate. LUMBRIDGE_CLAUDE_OAUTH=0 is documented as one switch off, and a
|
||||
# switch a configuration file can silently turn back on is not a switch. Where a
|
||||
# variable has pinned a value the settings pane shows that row disabled and
|
||||
# names the variable, rather than accepting an edit that would do nothing.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What Lumbridge does to this file
|
||||
#
|
||||
# Changing something in the settings pane rewrites the one key you changed and
|
||||
# leaves everything else exactly as you left it: your comments, your ordering,
|
||||
# your spacing, and any key this build does not recognise.
|
||||
#
|
||||
# An unrecognised key is kept, not deleted, and listed on the Advanced page —
|
||||
# a file written by a newer Lumbridge still opens in an older one. A syntax
|
||||
# error is a different matter: Lumbridge keeps the last configuration it read
|
||||
# and shows a banner naming the line, because a typo must never quietly reset
|
||||
# your settings to defaults.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Who may write each key
|
||||
#
|
||||
# Every key below says either "a person only" or "a person, or an agent holding
|
||||
# the Configure capability". Anything that names a program, a filesystem path,
|
||||
# or a network destination is a person only, and a request from anywhere else is
|
||||
# refused rather than quietly downgraded. An agent that can arrange panes must
|
||||
# not be able to choose what runs inside them.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What is deliberately not here
|
||||
#
|
||||
# Four things could plausibly be settings and never will be, because each would
|
||||
# turn this file into an attack:
|
||||
#
|
||||
# - The address an access token is sent to. A configuration file that can
|
||||
# redirect it is a credential exfiltration path with a friendly name.
|
||||
# - The file that token is read from. The same attack from the other end.
|
||||
# - The name Lumbridge gives when it calls a provider. Lumbridge identifies
|
||||
# itself instead of borrowing the harness's identity, so that its requests
|
||||
# stay distinguishable from the harness's in the provider's own logs.
|
||||
# - The program a new pane launches. A layout-only agent cannot choose the
|
||||
# shell you get, and a settings key would hand it exactly that at one
|
||||
# remove.
|
||||
#
|
||||
# Nothing here turns telemetry on or off, because there is none to turn off.
|
||||
# The Advanced page lists every file, endpoint, and child process Lumbridge
|
||||
# reads, by name.
|
||||
|
||||
|
||||
[appearance]
|
||||
|
||||
# The syntax theme every interface colour is derived from. Lumbridge does not
|
||||
# store a palette; it takes five anchors from the theme and computes the rest,
|
||||
# so a theme name changes the whole window and not just the editor.
|
||||
# Written by a person, or by an agent holding the Configure capability.
|
||||
# Takes effect immediately.
|
||||
#theme = "lumbridge-slate"
|
||||
|
||||
# The action colour: focus rings, selection, the active tab. Kept separate from
|
||||
# the theme so that the colour meaning "you are here" can be chosen once and
|
||||
# survive a change of theme.
|
||||
# Written by a person, or by an agent holding the Configure capability.
|
||||
# Takes effect immediately.
|
||||
#accent = "blue"
|
||||
|
||||
|
||||
[usage]
|
||||
|
||||
# Read the access token Claude Code stored on this machine, and use it to ask
|
||||
# Anthropic one documented question about your own subscription: the per-model
|
||||
# weekly limits a Max plan meters separately, and an answer on a cold start
|
||||
# before any session has taken a turn.
|
||||
#
|
||||
# The token is never persisted, never logged, never copied into application
|
||||
# state, never written to a crash report, and never passed as a command-line
|
||||
# argument. It is released as soon as the request is made. No refresh token is
|
||||
# ever used — renewing a credential is the harness's job. The request identifies
|
||||
# Lumbridge as the caller.
|
||||
#
|
||||
# LUMBRIDGE_CLAUDE_OAUTH=0 in the environment turns this off and outranks
|
||||
# whatever this file says.
|
||||
# Written by a person, or by an agent holding the Configure capability.
|
||||
# Restarts the probe.
|
||||
#claude_account_endpoint = true
|
||||
|
||||
# Follow the session transcripts Claude Code writes, for token spend. This is
|
||||
# the only local surface that reports what that harness actually spent. The
|
||||
# reader models four token counters and nothing else, so the conversations in
|
||||
# those files are not representable in a Lumbridge value. It reports nothing at
|
||||
# all until it has read the existing backlog to the end, because a partly-read
|
||||
# backlog is indistinguishable from a burst of spend.
|
||||
# Written by a person, or by an agent holding the Configure capability.
|
||||
# Restarts the probe.
|
||||
#claude_transcripts = true
|
||||
|
||||
# Launch `codex app-server` and ask it for the quota windows it already knows.
|
||||
# The client cannot express a request outside a two-variant enum and answers
|
||||
# every server-to-client request with "method not found", so a harness asking
|
||||
# Lumbridge for a credential is refused by construction.
|
||||
# Written by a person, or by an agent holding the Configure capability.
|
||||
# Restarts the probe.
|
||||
#codex_app_server = true
|
||||
|
||||
# Where the installed status-line bridge writes Claude Code's rate-limit
|
||||
# windows. Left unset, Lumbridge looks where the bridge installer puts it; set
|
||||
# this only if you installed the bridge somewhere else. It has to match what the
|
||||
# bridge was installed with exactly, or the windows read as unavailable forever.
|
||||
#
|
||||
# The example below is an example, not a default — there is no compiled default
|
||||
# for this key. LUMBRIDGE_CLAUDE_FEED in the environment overrides it.
|
||||
# Written by a person only: it names a filesystem path.
|
||||
# Restarts the probe.
|
||||
#claude_rate_limit_feed = "/example/path/to/claude-rate-limits.jsonl"
|
||||
|
||||
|
||||
[terminal]
|
||||
|
||||
# Let a program running in a pane write your system clipboard through the OSC 52
|
||||
# escape sequence. Off unless you ask for it: OSC 52 is available to anything
|
||||
# that can write to your terminal, which includes the contents of a log file you
|
||||
# happen to cat, so leaving it on means a file can replace what you are about to
|
||||
# paste.
|
||||
# Written by a person only: it hands a program a capability outside its pane.
|
||||
# Applies to new panes; panes already open keep what they were launched with.
|
||||
#allow_osc52_clipboard = false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,14 +27,27 @@
|
||||
//! file that can redirect where an access token is sent is a credential
|
||||
//! exfiltration path with a friendly name.
|
||||
|
||||
//!
|
||||
//! **The file is edited, never regenerated.** [`file`] rewrites the one key
|
||||
//! that changed through `toml_edit`, so a hand-written file keeps its comments,
|
||||
//! its ordering, and any key a newer build wrote that this one does not
|
||||
//! understand. `assets/default.toml` is the reference file, and it is the
|
||||
//! documentation: a test asserts it names every setting in [`SETTINGS`].
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod file;
|
||||
pub mod paths;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use file::{
|
||||
DEFAULT_TOML, LoadOutcome, ParseFailure, SettingValue, SettingsFile, WriteError, WriteOrigin,
|
||||
load,
|
||||
};
|
||||
|
||||
/// Who is allowed to write a field.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WriteAuthority {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
//! Local, device-owned persistence for Lumbridge.
|
||||
//!
|
||||
//! This database stores workspace metadata and remote routing profiles. API
|
||||
//! keys, subscription credentials, SSH private keys, and Tailscale credentials
|
||||
//! are intentionally outside its contract.
|
||||
//! This database stores workspace metadata, remote routing profiles, and the
|
||||
//! durable interface state described below. API keys, subscription credentials,
|
||||
//! SSH private keys, and Tailscale credentials are intentionally outside its
|
||||
//! contract.
|
||||
//!
|
||||
//! # Interface state is not configuration
|
||||
//!
|
||||
//! Two kinds of value survive a restart and they do not live in the same place.
|
||||
//! If a person would want it in a dotfile and copied to their other machine, it
|
||||
//! is configuration and belongs in `settings.toml`, which `lumbridge-settings`
|
||||
//! owns. If losing it is *annoying rather than wrong* — the sidebar comes back
|
||||
//! at its default width instead of the width you dragged it to — it is
|
||||
//! interface state and belongs here, in a file that is device-local by design
|
||||
//! and never synchronised.
|
||||
//!
|
||||
//! That is what [`UiStateKey`] and [`Store::set_ui_state`] are for. Decision
|
||||
//! 0021 listed sidebar width, collapsed sections and visibility as the first
|
||||
//! three, under "Not in this pass".
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -10,21 +25,36 @@ use lumbridge_core::{RemoteHost, RemoteTransport};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
/// The schema version this build writes and understands.
|
||||
pub const SUPPORTED_SCHEMA_VERSION: u32 = 2;
|
||||
pub const SUPPORTED_SCHEMA_VERSION: u32 = 3;
|
||||
|
||||
/// The baseline schema.
|
||||
/// The version-stamp table, created before anything else can be decided.
|
||||
///
|
||||
/// [`Store::migrate`] has to read the stamp before it applies a single
|
||||
/// statement, so this one table cannot be part of [`SCHEMA`] — it must already
|
||||
/// exist to answer the question that chooses between creating and migrating.
|
||||
/// It is a named constant so that the table a v2 file already has and the table
|
||||
/// a fresh file gets are the same text rather than two copies that can drift.
|
||||
const APP_META_TABLE: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;
|
||||
";
|
||||
|
||||
/// The baseline schema, as of version 2.
|
||||
///
|
||||
/// Creation only. It no longer stamps the version, because it used to do so
|
||||
/// unconditionally inside the same batch as the `CREATE TABLE IF NOT EXISTS`
|
||||
/// statements — so opening an older file added no columns but flipped the stamp
|
||||
/// forward anyway, and opening a *newer* file silently stamped it back down.
|
||||
/// Both produced a database whose recorded version was a lie.
|
||||
///
|
||||
/// It is deliberately frozen at version 2. Everything a later version adds is
|
||||
/// its own constant, applied by the fresh-create path and by the migration step
|
||||
/// that earns it — the *same* constant in both, which is what makes "created
|
||||
/// fresh" and "upgraded from v2" produce an identical schema instead of two
|
||||
/// that merely look alike.
|
||||
const SCHEMA: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_hosts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
@@ -90,6 +120,116 @@ CREATE TABLE IF NOT EXISTS workspace_snapshots (
|
||||
) STRICT;
|
||||
";
|
||||
|
||||
/// The `ui_state` table, added at schema version 3.
|
||||
///
|
||||
/// Run exactly once per database: either by the fresh-create path or by the
|
||||
/// v2 → v3 step, never both. Unlike [`SCHEMA`] it is therefore *not*
|
||||
/// `IF NOT EXISTS`. A file that already has something called `ui_state` at the
|
||||
/// moment we would create it is not the file we think it is, and adopting an
|
||||
/// object of unknown shape because its name happened to match is exactly the
|
||||
/// class of quiet lie the version stamp was rebuilt to stop. Failing here rolls
|
||||
/// the whole migration back and leaves the file at the version it really is.
|
||||
///
|
||||
/// `PRIMARY KEY NOT NULL` because `SQLite` still admits a NULL into a `TEXT`
|
||||
/// primary key otherwise, and a row nobody can name is a row nobody can delete.
|
||||
///
|
||||
/// The `CHECK` is a backstop, not the cap. [`Store::set_ui_state`] rejects in
|
||||
/// bytes, and `length()` counts characters, so the Rust check is always the
|
||||
/// stricter of the two and always fires first; this one exists so a value
|
||||
/// cannot grow without bound through some future path that writes the table
|
||||
/// directly.
|
||||
const UI_STATE_TABLE: &str = r"
|
||||
CREATE TABLE ui_state (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL CHECK (length(value) <= 16384),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) STRICT;
|
||||
";
|
||||
|
||||
/// The largest value [`Store::set_ui_state`] will store, in bytes.
|
||||
///
|
||||
/// Interface state is a width, a flag, a short list of collapsed section names.
|
||||
/// Sixteen kibibytes is far more than any of those and far less than anything
|
||||
/// that would make this table a place to park a transcript, a scrollback buffer
|
||||
/// or a serialised workspace — all of which have their own homes, with their
|
||||
/// own retention.
|
||||
pub const MAX_UI_STATE_VALUE_BYTES: usize = 16 * 1024;
|
||||
|
||||
// [`UI_STATE_TABLE`] has shipped, so the 16384 in its CHECK can never be
|
||||
// edited. Raising the cap above it would turn a clean refusal naming the key
|
||||
// into a bare constraint violation from SQLite, so the drift is caught here, at
|
||||
// compile time, rather than by whoever writes the first oversized value.
|
||||
const _: () = {
|
||||
const { assert!(MAX_UI_STATE_VALUE_BYTES <= 16384) };
|
||||
};
|
||||
|
||||
/// The complete set of durable interface-state keys.
|
||||
///
|
||||
/// Closed on purpose. The obvious design is a free-form `&str` key, and it
|
||||
/// means a typo is a silent miss at runtime — you read `sidebar.wdith`, get
|
||||
/// `None`, and fall back to the default forever without anything going wrong
|
||||
/// loudly enough to notice. Here a typo does not compile, and the stored names
|
||||
/// live in one place where a rename is visibly a schema change.
|
||||
///
|
||||
/// Values are opaque strings at this boundary. Storage does not know that a
|
||||
/// width is a number or that a collapsed-section set is a list; the caller
|
||||
/// serialises and parses, and must treat an unparseable value as absent rather
|
||||
/// than as a zero.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum UiStateKey {
|
||||
/// How wide the sidebar was, in pixels, when the drag was released.
|
||||
SidebarWidth,
|
||||
/// Whether the sidebar was showing at all.
|
||||
SidebarVisible,
|
||||
/// Which sidebar sections the user had folded away.
|
||||
SidebarCollapsedSections,
|
||||
}
|
||||
|
||||
impl UiStateKey {
|
||||
/// Every key, for exhaustive tests and for dumping the table.
|
||||
///
|
||||
/// A `match` in [`Self::storage_key`] makes a *new* key impossible to
|
||||
/// forget; this array makes it impossible to forget here too, because
|
||||
/// adding a variant without extending it fails the length assertion in the
|
||||
/// round-trip test.
|
||||
pub const ALL: [Self; 3] = [
|
||||
Self::SidebarWidth,
|
||||
Self::SidebarVisible,
|
||||
Self::SidebarCollapsedSections,
|
||||
];
|
||||
|
||||
/// The name this key is stored under.
|
||||
///
|
||||
/// Part of the on-disk format: changing one of these strings orphans the
|
||||
/// value a user already has, so it is a migration, not a rename.
|
||||
#[must_use]
|
||||
pub const fn storage_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::SidebarWidth => "sidebar.width",
|
||||
Self::SidebarVisible => "sidebar.visible",
|
||||
Self::SidebarCollapsedSections => "sidebar.collapsed_sections",
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover a key from its stored name, or `None` if this build has no such
|
||||
/// key.
|
||||
///
|
||||
/// `None` is the honest answer for a row written by a newer build: an
|
||||
/// unknown key is left alone rather than guessed at or deleted.
|
||||
#[must_use]
|
||||
pub fn from_storage_key(value: &str) -> Option<Self> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.storage_key() == value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UiStateKey {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.storage_key())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StorageError {
|
||||
Database(rusqlite::Error),
|
||||
@@ -111,6 +251,17 @@ pub enum StorageError {
|
||||
MissingMigration {
|
||||
from: u32,
|
||||
},
|
||||
/// A durable interface-state value exceeded [`MAX_UI_STATE_VALUE_BYTES`].
|
||||
///
|
||||
/// Refused whole rather than truncated. A width or a list of collapsed
|
||||
/// sections cut off in the middle would come back as unparseable at best
|
||||
/// and as a plausible wrong value at worst, and this table is the one place
|
||||
/// where losing state is supposed to be merely annoying.
|
||||
UiStateValueTooLarge {
|
||||
key: UiStateKey,
|
||||
bytes: usize,
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageError {
|
||||
@@ -122,6 +273,10 @@ impl std::fmt::Display for StorageError {
|
||||
formatter,
|
||||
"no migration is defined from schema {from}; this build cannot upgrade the file"
|
||||
),
|
||||
Self::UiStateValueTooLarge { key, bytes, limit } => write!(
|
||||
formatter,
|
||||
"interface state for {key} is {bytes} bytes, over the {limit}-byte limit"
|
||||
),
|
||||
Self::SchemaTooNew { found, supported } => write!(
|
||||
formatter,
|
||||
"this workspace was written by a newer Lumbridge (schema {found}; this build understands {supported})"
|
||||
@@ -140,6 +295,12 @@ impl From<rusqlite::Error> for StorageError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, StorageError>;
|
||||
|
||||
/// The columns every `remote_hosts` query selects, in order.
|
||||
///
|
||||
/// Named so that the two queries share one reader: a `SELECT` list and a
|
||||
/// positional `row.get(n)` that disagree is a bug no type checks.
|
||||
type RemoteHostColumns = (String, String, String, Option<String>, Option<u16>, String);
|
||||
|
||||
/// The one `SQLite` connection owned by a runtime instance.
|
||||
pub struct Store {
|
||||
connection: Connection,
|
||||
@@ -181,12 +342,7 @@ impl Store {
|
||||
/// The version is read *before* anything is applied, which is the whole
|
||||
/// point: a migration has to know where it is starting from.
|
||||
fn migrate(connection: &Connection) -> Result<()> {
|
||||
connection.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;",
|
||||
)?;
|
||||
connection.execute_batch(APP_META_TABLE)?;
|
||||
let found: Option<u32> = connection
|
||||
.query_row(
|
||||
"SELECT value FROM app_meta WHERE key = 'schema_version'",
|
||||
@@ -211,7 +367,15 @@ impl Store {
|
||||
connection.execute_batch("BEGIN IMMEDIATE;")?;
|
||||
let applied = (|| -> Result<()> {
|
||||
if found.is_none() {
|
||||
// A fresh file is created at the current version directly
|
||||
// rather than by replaying every step: the steps exist to move
|
||||
// files that already hold data, and replaying them on an empty
|
||||
// database is a slower way to reach the same place with more
|
||||
// chances to diverge. What keeps the two paths honest is that
|
||||
// each version's tables are one constant, used here and by the
|
||||
// step below.
|
||||
connection.execute_batch(SCHEMA)?;
|
||||
connection.execute_batch(UI_STATE_TABLE)?;
|
||||
}
|
||||
// Ordered, forward-only steps. Each takes the database from the
|
||||
// version named to the next one; none may be edited once shipped.
|
||||
@@ -235,14 +399,17 @@ impl Store {
|
||||
|
||||
/// One forward step, from the named version to the next.
|
||||
///
|
||||
/// There are none yet: version 2 is the baseline. An unhandled step is an
|
||||
/// error rather than a silent success, so raising
|
||||
/// Version 2 is the baseline; `from = 2` adds the `ui_state` table. An
|
||||
/// unhandled step is an error rather than a silent success, so raising
|
||||
/// [`SUPPORTED_SCHEMA_VERSION`] without writing the step that earns it
|
||||
/// fails at the first open instead of stamping a version the file has not
|
||||
/// actually reached. A step must never be edited once it has shipped.
|
||||
fn migration_step(_connection: &Connection, from: u32) -> Result<()> {
|
||||
// 1 => connection.execute_batch("ALTER TABLE …")?,
|
||||
Err(StorageError::MissingMigration { from })
|
||||
fn migration_step(connection: &Connection, from: u32) -> Result<()> {
|
||||
match from {
|
||||
2 => connection.execute_batch(UI_STATE_TABLE)?,
|
||||
_ => return Err(StorageError::MissingMigration { from }),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the currently installed schema version.
|
||||
@@ -301,22 +468,62 @@ impl Store {
|
||||
.query_row(
|
||||
"SELECT id, label, hostname, username, port, transport FROM remote_hosts WHERE id = ?1",
|
||||
[id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<u16>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
},
|
||||
Self::remote_host_columns,
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
row.map(|(id, label, hostname, username, port, transport)| {
|
||||
row.map(Self::remote_host_from_columns).transpose()
|
||||
}
|
||||
|
||||
/// Load every saved remote host profile, ordered by label.
|
||||
///
|
||||
/// Ordered here rather than by the caller so that two lists of the same
|
||||
/// hosts are never in two different orders. `NOCASE` sorts `alpha` beside
|
||||
/// `Alpha` instead of putting every capital letter first, which is what a
|
||||
/// person reading a list of their own machines expects; it folds ASCII
|
||||
/// only, so a non-ASCII label sorts by code point. The identifier breaks
|
||||
/// ties, because two hosts may share a label and a list that reshuffles
|
||||
/// between reads is a list you cannot click.
|
||||
///
|
||||
/// A row naming a transport this build does not know is an error for the
|
||||
/// whole call rather than a silently dropped host: a devices list that is
|
||||
/// quietly missing an entry is worse than one that refuses to render.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when a row cannot be read or names a transport this
|
||||
/// build does not understand.
|
||||
pub fn remote_hosts(&self) -> Result<Vec<RemoteHost>> {
|
||||
let mut statement = self.connection.prepare(
|
||||
"SELECT id, label, hostname, username, port, transport
|
||||
FROM remote_hosts
|
||||
ORDER BY label COLLATE NOCASE, id",
|
||||
)?;
|
||||
let rows = statement.query_map([], Self::remote_host_columns)?;
|
||||
|
||||
let mut hosts = Vec::new();
|
||||
for row in rows {
|
||||
hosts.push(Self::remote_host_from_columns(row?)?);
|
||||
}
|
||||
Ok(hosts)
|
||||
}
|
||||
|
||||
/// The raw column tuple, shared so one query cannot drift from the other.
|
||||
fn remote_host_columns(row: &rusqlite::Row<'_>) -> rusqlite::Result<RemoteHostColumns> {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn remote_host_from_columns(columns: RemoteHostColumns) -> Result<RemoteHost> {
|
||||
let (id, label, hostname, username, port, transport) = columns;
|
||||
let transport = RemoteTransport::from_storage_name(&transport)
|
||||
.ok_or_else(|| StorageError::UnknownTransport(transport.clone()))?;
|
||||
.ok_or(StorageError::UnknownTransport(transport))?;
|
||||
Ok(RemoteHost {
|
||||
id,
|
||||
label,
|
||||
@@ -325,8 +532,58 @@ impl Store {
|
||||
port,
|
||||
transport,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Read one piece of durable interface state, if it has ever been written.
|
||||
///
|
||||
/// `None` means "never written", and the caller must answer it with its own
|
||||
/// default rather than with a zero. The value is an opaque string; this
|
||||
/// boundary does not know what a sidebar width looks like.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `SQLite` cannot read the row.
|
||||
pub fn ui_state(&self, key: UiStateKey) -> Result<Option<String>> {
|
||||
self.connection
|
||||
.query_row(
|
||||
"SELECT value FROM ui_state WHERE key = ?1",
|
||||
[key.storage_key()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
|
||||
/// Write one piece of durable interface state, replacing any previous
|
||||
/// value.
|
||||
///
|
||||
/// The caller owns the encoding. Storage stores strings, so that adding a
|
||||
/// field to the sidebar's own types is not a schema change here.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`StorageError::UiStateValueTooLarge`] when the value exceeds
|
||||
/// [`MAX_UI_STATE_VALUE_BYTES`], in which case nothing is written, and a
|
||||
/// database error when `SQLite` cannot store the row.
|
||||
pub fn set_ui_state(&self, key: UiStateKey, value: &str) -> Result<()> {
|
||||
if value.len() > MAX_UI_STATE_VALUE_BYTES {
|
||||
return Err(StorageError::UiStateValueTooLarge {
|
||||
key,
|
||||
bytes: value.len(),
|
||||
limit: MAX_UI_STATE_VALUE_BYTES,
|
||||
});
|
||||
}
|
||||
self.connection.execute(
|
||||
r"
|
||||
INSERT INTO ui_state (key, value)
|
||||
VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
",
|
||||
params![key.storage_key(), value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save a credential-free UI snapshot for a workspace.
|
||||
@@ -380,19 +637,72 @@ impl Store {
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Every object the file declares, as `(type, name, sql)`, name-ordered.
|
||||
///
|
||||
/// Ordered by name rather than by creation order precisely because the two
|
||||
/// paths under test create the same objects in different orders.
|
||||
#[cfg(test)]
|
||||
fn schema_objects(&self) -> Result<Vec<(String, String, Option<String>)>> {
|
||||
let mut statement = self
|
||||
.connection
|
||||
.prepare("SELECT type, name, sql FROM sqlite_master ORDER BY name")?;
|
||||
let rows = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use lumbridge_core::{RemoteHost, RemoteTransport};
|
||||
|
||||
use super::{SUPPORTED_SCHEMA_VERSION, StorageError, Store};
|
||||
use super::{
|
||||
APP_META_TABLE, MAX_UI_STATE_VALUE_BYTES, SCHEMA, SUPPORTED_SCHEMA_VERSION, StorageError,
|
||||
Store, UiStateKey,
|
||||
};
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Build the file a version-2 build would have left behind.
|
||||
///
|
||||
/// Deliberately assembled from the same constants that build shipped —
|
||||
/// [`APP_META_TABLE`] then [`SCHEMA`], stamped by hand — so that "upgraded
|
||||
/// from v2" in these tests means the real thing and not a v3 file with a
|
||||
/// table removed.
|
||||
fn version_two_database(path: &Path) {
|
||||
let connection = Connection::open(path).expect("a v2 file should open");
|
||||
connection
|
||||
.execute_batch(APP_META_TABLE)
|
||||
.expect("the stamp table");
|
||||
connection.execute_batch(SCHEMA).expect("the v2 baseline");
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO app_meta (key, value) VALUES ('schema_version', '2')",
|
||||
[],
|
||||
)
|
||||
.expect("stamp version 2");
|
||||
}
|
||||
|
||||
fn a_host(id: &str, label: &str) -> RemoteHost {
|
||||
RemoteHost {
|
||||
id: id.to_owned(),
|
||||
label: label.to_owned(),
|
||||
hostname: format!("{id}.example"),
|
||||
username: None,
|
||||
port: None,
|
||||
transport: RemoteTransport::OpenSsh,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initializes_schema_and_round_trips_remote_host() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
assert_eq!(store.schema_version().expect("schema version"), 2);
|
||||
assert_eq!(
|
||||
store.schema_version().expect("schema version"),
|
||||
SUPPORTED_SCHEMA_VERSION
|
||||
);
|
||||
|
||||
let host = RemoteHost {
|
||||
id: "metal".into(),
|
||||
@@ -535,4 +845,383 @@ mod tests {
|
||||
SUPPORTED_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_two_database_is_upgraded_in_place_and_keeps_its_rows() {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory");
|
||||
let path = directory.path().join("workspace.db");
|
||||
version_two_database(&path);
|
||||
|
||||
let host = a_host("build", "Build box");
|
||||
{
|
||||
let connection = Connection::open(&path).expect("the v2 file");
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO remote_hosts (id, label, hostname, transport)
|
||||
VALUES (?1, ?2, ?3, 'openssh')",
|
||||
rusqlite::params![host.id, host.label, host.hostname],
|
||||
)
|
||||
.expect("a row written by the older build");
|
||||
}
|
||||
|
||||
let store = Store::open(&path).expect("a v2 file should upgrade, not be refused");
|
||||
assert_eq!(
|
||||
store.schema_version().expect("version"),
|
||||
3,
|
||||
"the upgraded file must claim the version it actually reached"
|
||||
);
|
||||
assert_eq!(
|
||||
store.remote_host("build").expect("host should load"),
|
||||
Some(host),
|
||||
"a migration must carry the user's existing rows forward, not reset them"
|
||||
);
|
||||
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarWidth, "312")
|
||||
.expect("the migrated file should have ui_state");
|
||||
assert_eq!(
|
||||
store.ui_state(UiStateKey::SidebarWidth).expect("read back"),
|
||||
Some("312".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
/// The two ways to reach version 3 must arrive at the same place.
|
||||
///
|
||||
/// A fresh database is created directly rather than by replaying steps, so
|
||||
/// nothing but this test stops the creation path and the migration path
|
||||
/// from drifting into two schemas that merely look alike — the failure
|
||||
/// mode being a column that exists only on machines old enough to have
|
||||
/// been upgraded.
|
||||
#[test]
|
||||
fn an_upgraded_database_has_the_same_schema_as_a_freshly_created_one() {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory");
|
||||
|
||||
let fresh_path = directory.path().join("fresh.db");
|
||||
let fresh = Store::open(&fresh_path).expect("a fresh store");
|
||||
|
||||
let upgraded_path = directory.path().join("upgraded.db");
|
||||
version_two_database(&upgraded_path);
|
||||
let upgraded = Store::open(&upgraded_path).expect("an upgraded store");
|
||||
|
||||
assert_eq!(
|
||||
fresh.schema_objects().expect("fresh schema"),
|
||||
upgraded.schema_objects().expect("upgraded schema"),
|
||||
"creating a v3 file and upgrading a v2 file must produce one schema"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half-way case: the step applied, and then the run stopped.
|
||||
///
|
||||
/// This is the one the transaction exists for. The step's `CREATE TABLE`
|
||||
/// succeeds and the stamp that would record it does not, which without a
|
||||
/// transaction leaves a file carrying a v3 table under a v2 stamp — a lie
|
||||
/// in the other direction, and one that would make the next open try the
|
||||
/// step again and fail forever. The abort is forced by a temporary trigger
|
||||
/// so that the failure lands between the two writes, where no natural error
|
||||
/// could be aimed.
|
||||
#[test]
|
||||
fn a_migration_interrupted_after_its_step_rolls_the_step_back_too() {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory");
|
||||
let path = directory.path().join("workspace.db");
|
||||
version_two_database(&path);
|
||||
|
||||
{
|
||||
let connection = Connection::open(&path).expect("the v2 file");
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TEMP TRIGGER stop_the_stamp BEFORE UPDATE ON main.app_meta
|
||||
BEGIN SELECT RAISE(ABORT, 'interrupted'); END;",
|
||||
)
|
||||
.expect("a trigger that stops the run between its two writes");
|
||||
assert!(
|
||||
Store::migrate(&connection).is_err(),
|
||||
"a migration whose stamp cannot be written must not report success"
|
||||
);
|
||||
}
|
||||
|
||||
let connection = Connection::open(&path).expect("reopen");
|
||||
let stamp: String = connection
|
||||
.query_row(
|
||||
"SELECT value FROM app_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("a stamp");
|
||||
assert_eq!(
|
||||
stamp, "2",
|
||||
"the file is still a version 2 file and must say so"
|
||||
);
|
||||
let tables: i64 = connection
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'ui_state'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count");
|
||||
assert_eq!(
|
||||
tables, 0,
|
||||
"a version 2 stamp over a version 3 table is exactly the half-way state the transaction prevents"
|
||||
);
|
||||
}
|
||||
|
||||
/// An interrupted migration must leave the file at the version it was.
|
||||
///
|
||||
/// The interruption is a real one rather than a simulated panic: a file
|
||||
/// that already holds an object called `ui_state` makes the v2 → v3 step
|
||||
/// fail before it can apply. What matters is what is left behind — a file
|
||||
/// still stamped 2, with its own `ui_state` untouched, that a fixed build
|
||||
/// can try again on.
|
||||
#[test]
|
||||
fn a_migration_that_cannot_apply_leaves_the_file_at_version_two() {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory");
|
||||
let path = directory.path().join("workspace.db");
|
||||
version_two_database(&path);
|
||||
{
|
||||
let connection = Connection::open(&path).expect("the v2 file");
|
||||
connection
|
||||
.execute_batch("CREATE TABLE ui_state (something_else TEXT NOT NULL) STRICT;")
|
||||
.expect("an object in the way");
|
||||
}
|
||||
|
||||
let Err(error) = Store::open(&path) else {
|
||||
panic!("a migration that cannot complete must not report success");
|
||||
};
|
||||
assert!(
|
||||
matches!(error, StorageError::Database(_)),
|
||||
"expected the failing statement to surface, got {error}"
|
||||
);
|
||||
|
||||
let connection = Connection::open(&path).expect("reopen");
|
||||
let stamp: String = connection
|
||||
.query_row(
|
||||
"SELECT value FROM app_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("a stamp");
|
||||
assert_eq!(
|
||||
stamp, "2",
|
||||
"a rolled-back migration must leave the file at the version it really is"
|
||||
);
|
||||
|
||||
let column: String = connection
|
||||
.query_row(
|
||||
"SELECT name FROM pragma_table_info('ui_state')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("the pre-existing table");
|
||||
assert_eq!(
|
||||
column, "something_else",
|
||||
"the rollback must undo the migration's own work, not the file's"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_state_is_absent_until_written_and_then_round_trips() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
assert_eq!(
|
||||
store
|
||||
.ui_state(UiStateKey::SidebarCollapsedSections)
|
||||
.expect("read"),
|
||||
None,
|
||||
"a key nobody has written is unknown, and the caller supplies the default"
|
||||
);
|
||||
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarCollapsedSections, "detached,quota")
|
||||
.expect("write");
|
||||
assert_eq!(
|
||||
store
|
||||
.ui_state(UiStateKey::SidebarCollapsedSections)
|
||||
.expect("read back")
|
||||
.as_deref(),
|
||||
Some("detached,quota")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_a_key_twice_replaces_it_rather_than_failing() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarVisible, "false")
|
||||
.expect("first write");
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarVisible, "true")
|
||||
.expect("a second write must update the row, not collide with it");
|
||||
assert_eq!(
|
||||
store.ui_state(UiStateKey::SidebarVisible).expect("read"),
|
||||
Some("true".to_owned()),
|
||||
"the last write wins; interface state has no history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_independent_of_one_another() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarWidth, "240")
|
||||
.expect("write");
|
||||
assert_eq!(
|
||||
store.ui_state(UiStateKey::SidebarVisible).expect("read"),
|
||||
None,
|
||||
"writing one key must not appear to have written another"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_at_the_cap_is_stored_and_a_larger_one_is_refused_whole() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
let at_cap = "x".repeat(MAX_UI_STATE_VALUE_BYTES);
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarCollapsedSections, &at_cap)
|
||||
.expect("the cap itself is allowed; the limit is inclusive");
|
||||
|
||||
let over_cap = "y".repeat(MAX_UI_STATE_VALUE_BYTES + 1);
|
||||
let Err(error) = store.set_ui_state(UiStateKey::SidebarCollapsedSections, &over_cap) else {
|
||||
panic!("a value over the cap must be refused");
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
StorageError::UiStateValueTooLarge {
|
||||
key: UiStateKey::SidebarCollapsedSections,
|
||||
bytes,
|
||||
limit: MAX_UI_STATE_VALUE_BYTES,
|
||||
} if bytes == MAX_UI_STATE_VALUE_BYTES + 1
|
||||
),
|
||||
"expected UiStateValueTooLarge naming the key and the size, got {error}"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.ui_state(UiStateKey::SidebarCollapsedSections)
|
||||
.expect("read back"),
|
||||
Some(at_cap),
|
||||
"a refused write must leave the previous value intact, not truncate over it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cap counts bytes, so a multi-byte value cannot slip past it.
|
||||
#[test]
|
||||
fn the_cap_counts_bytes_rather_than_characters() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
// Four-byte characters: a quarter of the cap in characters, but one
|
||||
// byte over it once encoded.
|
||||
let value = "𝄞".repeat(MAX_UI_STATE_VALUE_BYTES / 4 + 1);
|
||||
assert!(
|
||||
store
|
||||
.set_ui_state(UiStateKey::SidebarWidth, &value)
|
||||
.is_err(),
|
||||
"a value whose character count is under the cap but whose byte count is over it must still be refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_state_keys_round_trip_through_their_stored_names() {
|
||||
assert_eq!(
|
||||
UiStateKey::ALL.len(),
|
||||
3,
|
||||
"a new key must be added to ALL, or nothing here covers it"
|
||||
);
|
||||
for key in UiStateKey::ALL {
|
||||
assert_eq!(
|
||||
UiStateKey::from_storage_key(key.storage_key()),
|
||||
Some(key),
|
||||
"the stored name of {key} is part of the on-disk format"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
UiStateKey::from_storage_key("sidebar.wdith"),
|
||||
None,
|
||||
"a name this build does not know is unknown, never the nearest match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_hosts_is_empty_rather_than_an_error_when_none_are_saved() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
assert_eq!(
|
||||
store.remote_hosts().expect("listing"),
|
||||
Vec::new(),
|
||||
"no saved hosts is a fact, not a failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_hosts_are_listed_ordered_by_label() {
|
||||
let store = Store::open_in_memory().expect("store should initialize");
|
||||
for (id, label) in [
|
||||
("c", "zenith"),
|
||||
("a", "Build box"),
|
||||
("b", "apex"),
|
||||
("d", "apex"),
|
||||
] {
|
||||
store.save_remote_host(&a_host(id, label)).expect("save");
|
||||
}
|
||||
|
||||
let labels: Vec<(String, String)> = store
|
||||
.remote_hosts()
|
||||
.expect("listing")
|
||||
.into_iter()
|
||||
.map(|host| (host.label, host.id))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
("apex".to_owned(), "b".to_owned()),
|
||||
("apex".to_owned(), "d".to_owned()),
|
||||
("Build box".to_owned(), "a".to_owned()),
|
||||
("zenith".to_owned(), "c".to_owned()),
|
||||
],
|
||||
"labels sort case-insensitively, and a shared label is broken by identifier so the order never reshuffles"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host we cannot route to must not be silently omitted from the list.
|
||||
#[test]
|
||||
fn a_host_with_an_unreadable_transport_fails_the_listing_rather_than_vanishing() {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory");
|
||||
let path = directory.path().join("workspace.db");
|
||||
{
|
||||
let store = Store::open(&path).expect("a fresh store");
|
||||
store
|
||||
.save_remote_host(&a_host("a", "Build box"))
|
||||
.expect("save");
|
||||
}
|
||||
{
|
||||
// This build's CHECK constraint keeps an unknown transport out, so
|
||||
// the fixture has to be the schema a *newer* build would have left:
|
||||
// the constraint widened, and a row written against it. A separate
|
||||
// connection then reads the amended schema.
|
||||
let connection = Connection::open(&path).expect("the file");
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, \"transport IN ('openssh', 'tailscale-ssh')\", \"1\")
|
||||
WHERE name = 'remote_hosts';
|
||||
PRAGMA writable_schema = OFF;",
|
||||
)
|
||||
.expect("widen the constraint the way a newer schema would");
|
||||
}
|
||||
{
|
||||
let connection = Connection::open(&path).expect("the amended file");
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO remote_hosts (id, label, hostname, transport)
|
||||
VALUES ('z', 'Zebra', 'z.example', 'quantum-ssh')",
|
||||
[],
|
||||
)
|
||||
.expect("a row from a build that knows more transports than this one");
|
||||
}
|
||||
|
||||
let store = Store::open(&path).expect("reopen");
|
||||
let Err(error) = store.remote_hosts() else {
|
||||
panic!("an unknown transport must be reported, not skipped");
|
||||
};
|
||||
assert!(
|
||||
matches!(error, StorageError::UnknownTransport(ref name) if name == "quantum-ssh"),
|
||||
"expected UnknownTransport naming the value, got {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user