Add versioned DB migrations, startup error dialog and native basics
rusqlite_migration with a VACUUM INTO backup before upgrading; M2 adds GST registration, snapshot, cancellation and draft columns and backfills snapshots for existing invoices. Startup failures now show a native dialog instead of exiting silently. Adds single-instance and window-state plugins, minimum window 960x600, embedBootstrapper, DMABUF workaround on Linux and xbps dependency updates.
This commit is contained in:
+428
-7
@@ -1,7 +1,29 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::Path;
|
||||
use rusqlite_migration::{Migrations, M};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The full schema. Every statement is idempotent so it doubles as the migration step.
|
||||
/// Number of pre-migration backups kept in the backup directory.
|
||||
const MAX_BACKUPS: usize = 10;
|
||||
|
||||
/// Prefix shared by every pre-migration backup file name.
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||
const LATEST_VERSION: i64 = 2;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("database error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("database migration failed: {0}")]
|
||||
Migration(#[from] rusqlite_migration::Error),
|
||||
#[error("file system error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Version 1: the original schema. Every statement is idempotent, so databases
|
||||
/// created before migrations existed (user_version 0, all tables present) adopt it safely.
|
||||
const SCHEMA: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
@@ -99,17 +121,129 @@ CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
"#;
|
||||
|
||||
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
/// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts.
|
||||
const M2: &str = r#"
|
||||
ALTER TABLE app_settings ADD COLUMN gst_registration TEXT NOT NULL DEFAULT 'unregistered';
|
||||
ALTER TABLE app_settings ADD COLUMN default_hsn_sac TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE app_settings ADD COLUMN signatory_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE app_settings ADD COLUMN signatory_designation TEXT NOT NULL DEFAULT '';
|
||||
UPDATE app_settings SET gst_registration = 'regular' WHERE vendor_gstin <> '';
|
||||
-- An unregistered supplier may not charge GST, so new invoices default to no tax.
|
||||
UPDATE app_settings SET default_tax_type = 'none' WHERE gst_registration = 'unregistered';
|
||||
|
||||
ALTER TABLE invoices ADD COLUMN doc_type TEXT NOT NULL DEFAULT 'invoice';
|
||||
ALTER TABLE invoices ADD COLUMN reverse_charge INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE invoices ADD COLUMN vendor_snapshot TEXT;
|
||||
ALTER TABLE invoices ADD COLUMN render_prefs TEXT;
|
||||
ALTER TABLE invoices ADD COLUMN snapshot_origin TEXT NOT NULL DEFAULT 'issued';
|
||||
ALTER TABLE invoices ADD COLUMN cancelled_at TEXT;
|
||||
ALTER TABLE invoices ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE invoices ADD COLUMN archived_pdf_sha256 TEXT;
|
||||
|
||||
UPDATE invoices SET
|
||||
vendor_snapshot = (
|
||||
SELECT json_object(
|
||||
'vendorName', vendor_name,
|
||||
'vendorAddress', vendor_address,
|
||||
'vendorEmail', vendor_email,
|
||||
'vendorPhone', vendor_phone,
|
||||
'vendorPan', vendor_pan,
|
||||
'vendorGstin', vendor_gstin,
|
||||
'vendorStateCode', vendor_state_code,
|
||||
'logoPath', logo_path,
|
||||
'signaturePath', signature_path,
|
||||
'gstRegistration', gst_registration,
|
||||
'signatoryName', signatory_name,
|
||||
'signatoryDesignation', signatory_designation
|
||||
) FROM app_settings WHERE id = 1
|
||||
),
|
||||
snapshot_origin = 'backfilled',
|
||||
doc_type = CASE WHEN tax_type <> 'none' THEN 'tax_invoice' ELSE 'invoice' END;
|
||||
|
||||
ALTER TABLE invoice_items ADD COLUMN hsn_sac TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_drafts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
fn migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2)])
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
/// Existing databases are backed up into `backup_dir` before any migration runs.
|
||||
pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
||||
let mut conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||
if version < LATEST_VERSION && has_user_tables(&conn)? {
|
||||
backup_before_migration(&conn, backup_dir, version)?;
|
||||
}
|
||||
|
||||
migrations().to_latest(&mut conn)?;
|
||||
seed(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Copy the database to `<backup_dir>/voiced-pre-v{version}-{YYYYmmdd-HHMMSS}.db`
|
||||
/// and drop all but the newest `MAX_BACKUPS` backups.
|
||||
fn backup_before_migration(
|
||||
conn: &Connection,
|
||||
backup_dir: &Path,
|
||||
version: i64,
|
||||
) -> Result<PathBuf, DbError> {
|
||||
fs::create_dir_all(backup_dir)?;
|
||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
||||
// A failed prune must not block startup; the backup itself already succeeded.
|
||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Keep only the `keep` newest pre-migration backups, ordered by the timestamp
|
||||
/// embedded in the file name (the schema version prefix is ignored).
|
||||
fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
|
||||
let mut backups: Vec<(String, PathBuf)> = Vec::new();
|
||||
for entry in fs::read_dir(backup_dir)? {
|
||||
let path = entry?.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(stem) = name
|
||||
.strip_prefix(BACKUP_PREFIX)
|
||||
.and_then(|n| n.strip_suffix(".db"))
|
||||
{
|
||||
// "{version}-{YYYYmmdd}-{HHMMSS}" -> "{YYYYmmdd}-{HHMMSS}"
|
||||
let stamp = stem.split_once('-').map_or(stem, |(_, t)| t);
|
||||
backups.push((stamp.to_string(), path));
|
||||
}
|
||||
}
|
||||
backups.sort();
|
||||
let excess = backups.len().saturating_sub(keep);
|
||||
for (_, path) in backups.into_iter().take(excess) {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert the first-run defaults the very first time the app is opened.
|
||||
/// Vendor details are pre-filled from the owner's existing invoice.
|
||||
/// Vendor details are pre-filled from the owner's existing invoice. The vendor
|
||||
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
|
||||
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let settings_count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||
@@ -119,7 +253,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'cgst_sgst', 30, 'INR', 0, 'g10')"#,
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||
params![
|
||||
"Test Vendor",
|
||||
"1 Test Road, Bangalore - 560001",
|
||||
@@ -173,3 +307,290 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// The schema as it shipped before migrations existed (user_version 0).
|
||||
const V1_SCHEMA: &str = r#"
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
vendor_name TEXT NOT NULL DEFAULT '',
|
||||
vendor_address TEXT NOT NULL DEFAULT '',
|
||||
vendor_email TEXT NOT NULL DEFAULT '',
|
||||
vendor_phone TEXT NOT NULL DEFAULT '',
|
||||
vendor_pan TEXT NOT NULL DEFAULT '',
|
||||
vendor_gstin TEXT NOT NULL DEFAULT '',
|
||||
vendor_state_code TEXT NOT NULL DEFAULT '29',
|
||||
logo_path TEXT,
|
||||
signature_path TEXT,
|
||||
default_bank_id INTEGER,
|
||||
default_tax_rate REAL NOT NULL DEFAULT 18,
|
||||
default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst',
|
||||
payment_terms_days INTEGER NOT NULL DEFAULT 30,
|
||||
currency TEXT NOT NULL DEFAULT 'INR',
|
||||
onboarded INTEGER NOT NULL DEFAULT 0,
|
||||
theme TEXT NOT NULL DEFAULT 'g10'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bank_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
bank_name TEXT NOT NULL DEFAULT '',
|
||||
account_name TEXT NOT NULL DEFAULT '',
|
||||
account_no TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
ifsc TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
prefix TEXT NOT NULL,
|
||||
padding INTEGER NOT NULL DEFAULT 3,
|
||||
next_number INTEGER NOT NULL DEFAULT 1,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
gstin TEXT NOT NULL DEFAULT '',
|
||||
state_code TEXT NOT NULL DEFAULT '',
|
||||
po_number TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
number TEXT NOT NULL UNIQUE,
|
||||
series_id INTEGER,
|
||||
invoice_date TEXT NOT NULL,
|
||||
due_date TEXT NOT NULL DEFAULT '',
|
||||
client_id INTEGER,
|
||||
client_name TEXT NOT NULL DEFAULT '',
|
||||
client_address TEXT NOT NULL DEFAULT '',
|
||||
client_gstin TEXT NOT NULL DEFAULT '',
|
||||
po_number TEXT NOT NULL DEFAULT '',
|
||||
place_of_supply_state_code TEXT NOT NULL DEFAULT '',
|
||||
subtotal REAL NOT NULL DEFAULT 0,
|
||||
discount REAL NOT NULL DEFAULT 0,
|
||||
tax_type TEXT NOT NULL DEFAULT 'none',
|
||||
tax_rate REAL NOT NULL DEFAULT 0,
|
||||
cgst_amount REAL NOT NULL DEFAULT 0,
|
||||
sgst_amount REAL NOT NULL DEFAULT 0,
|
||||
igst_amount REAL NOT NULL DEFAULT 0,
|
||||
total REAL NOT NULL DEFAULT 0,
|
||||
amount_in_words TEXT NOT NULL DEFAULT '',
|
||||
bank_account_id INTEGER,
|
||||
bank_snapshot TEXT NOT NULL DEFAULT '',
|
||||
signature_path TEXT,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'issued',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'fixed',
|
||||
rate REAL NOT NULL DEFAULT 0,
|
||||
unit TEXT NOT NULL DEFAULT 'unit',
|
||||
quantity REAL NOT NULL DEFAULT 1,
|
||||
amount REAL NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
"#;
|
||||
|
||||
fn user_version(conn: &Connection) -> i64 {
|
||||
conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
fn backups_in(dir: &Path) -> Vec<PathBuf> {
|
||||
match fs::read_dir(dir) {
|
||||
Ok(rd) => rd.map(|e| e.unwrap().path()).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_are_valid_and_latest_matches() {
|
||||
migrations().validate().unwrap();
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_database_is_seeded_without_backup() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backups = dir.path().join("backups");
|
||||
let conn = open(&dir.path().join("voiced.db"), &backups).unwrap();
|
||||
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
let (tax_type, gst_reg, name): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT default_tax_type, gst_registration, vendor_name FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tax_type, "none");
|
||||
assert_eq!(gst_reg, "unregistered");
|
||||
assert_eq!(name, "Test Vendor");
|
||||
assert!(backups_in(&backups).is_empty());
|
||||
}
|
||||
|
||||
fn build_v1_database(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO app_settings (id, vendor_name, vendor_gstin, default_tax_type, onboarded)
|
||||
VALUES (1, 'Acme Studio', '29ABCDE1234F1Z5', 'cgst_sgst', 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO bank_accounts (label, bank_name, account_no, is_default)
|
||||
VALUES ('Primary', 'Test Bank', '12345', 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||
VALUES ('AC/2026-', 3, 2, 1, '2026-01-01T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, tax_type, tax_rate, total, created_at, updated_at)
|
||||
VALUES ('AC/2026-001', '2026-01-02', 'cgst_sgst', 18, 118, 'now', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_items (invoice_id, description, rate, quantity, amount)
|
||||
VALUES (1, 'Design work', 100, 1, 100)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(user_version(&conn), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_database_upgrades_with_backup_and_backfill() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("voiced.db");
|
||||
let backups = dir.path().join("backups");
|
||||
build_v1_database(&db_path);
|
||||
|
||||
let conn = open(&db_path, &backups).unwrap();
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
|
||||
// Backup exists and is a valid, un-migrated copy.
|
||||
let files = backups_in(&backups);
|
||||
assert_eq!(files.len(), 1);
|
||||
let name = files[0].file_name().unwrap().to_str().unwrap();
|
||||
assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}");
|
||||
let backup = Connection::open(&files[0]).unwrap();
|
||||
assert_eq!(user_version(&backup), 0);
|
||||
let n: i64 = backup
|
||||
.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
|
||||
// Settings backfill.
|
||||
let (gst_reg, tax_type, vendor): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT gst_registration, default_tax_type, vendor_name FROM app_settings WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(gst_reg, "regular");
|
||||
assert_eq!(tax_type, "cgst_sgst");
|
||||
assert_eq!(vendor, "Acme Studio");
|
||||
|
||||
// Invoice backfill.
|
||||
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn
|
||||
.query_row(
|
||||
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(origin, "backfilled");
|
||||
assert_eq!(doc_type, "tax_invoice");
|
||||
assert_eq!(number, "AC/2026-001");
|
||||
assert_eq!(total, 118.0);
|
||||
let json: serde_json::Value = serde_json::from_str(&snapshot.expect("snapshot")).unwrap();
|
||||
assert_eq!(json["vendorName"], "Acme Studio");
|
||||
assert_eq!(json["vendorGstin"], "29ABCDE1234F1Z5");
|
||||
assert_eq!(json["gstRegistration"], "regular");
|
||||
|
||||
// Old data intact, new columns defaulted.
|
||||
let (desc, hsn): (String, String) = conn
|
||||
.query_row("SELECT description, hsn_sac FROM invoice_items", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(desc, "Design work");
|
||||
assert_eq!(hsn, "");
|
||||
let (bank, next): (String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT (SELECT bank_name FROM bank_accounts), (SELECT next_number FROM invoice_series)",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bank, "Test Bank");
|
||||
assert_eq!(next, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_up_to_date_database_makes_no_backup() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("voiced.db");
|
||||
let backups = dir.path().join("backups");
|
||||
build_v1_database(&db_path);
|
||||
|
||||
drop(open(&db_path, &backups).unwrap());
|
||||
assert_eq!(backups_in(&backups).len(), 1);
|
||||
drop(open(&db_path, &backups).unwrap());
|
||||
assert_eq!(backups_in(&backups).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_keeps_the_newest_ten() {
|
||||
let dir = tempdir().unwrap();
|
||||
for i in 0..14 {
|
||||
// Mixed version prefixes: ordering must follow the timestamp only.
|
||||
let version = if i % 2 == 0 { 2 } else { 10 };
|
||||
let name = format!("{BACKUP_PREFIX}{version}-202601{:02}-120000.db", i + 1);
|
||||
fs::write(dir.path().join(name), b"x").unwrap();
|
||||
}
|
||||
fs::write(dir.path().join("unrelated.db"), b"x").unwrap();
|
||||
|
||||
prune_backups(dir.path(), MAX_BACKUPS).unwrap();
|
||||
|
||||
let mut names: Vec<String> = backups_in(dir.path())
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with(BACKUP_PREFIX))
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names.len(), MAX_BACKUPS);
|
||||
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
|
||||
assert!(dir.path().join("unrelated.db").exists());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user