summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock150
-rw-r--r--Cargo.toml14
-rw-r--r--docs/ops.md7
-rw-r--r--migrations/20240904135137_index_token_issued_at.sql1
-rw-r--r--migrations/20240904153315_channel.sql20
-rw-r--r--migrations/20240911230415_no_channel_membership.sql1
-rw-r--r--migrations/20240912013151_token_last_used.sql6
-rw-r--r--migrations/20240924232919_message_serial_numbers.sql38
-rw-r--r--migrations/20240928002608_channel_lifecycle.sql57
-rw-r--r--migrations/20240928012031_channel_stored_sequence.sql60
-rw-r--r--migrations/20240929013644_token_id.sql29
-rw-r--r--migrations/20241002003606_global_sequence.sql126
-rw-r--r--migrations/20241005020942_login.sql10
-rw-r--r--migrations/20241005020958_token.sql (renamed from migrations/20240831024047_login.sql)17
-rw-r--r--migrations/20241005021009_channel.sql16
-rw-r--r--migrations/20241005021022_message.sql (renamed from migrations/20240912145249_message.sql)9
-rw-r--r--migrations/20241005021100_event_sequence.sql10
-rw-r--r--src/cli.rs15
-rw-r--r--src/db.rs42
-rw-r--r--src/db/backup.rs76
-rw-r--r--src/db/mod.rs114
-rw-r--r--src/test/fixtures/mod.rs2
22 files changed, 361 insertions, 459 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 638735f..932fb87 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 3
[[package]]
name = "addr2line"
-version = "0.24.1"
+version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375"
+checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
@@ -133,15 +133,15 @@ dependencies = [
[[package]]
name = "autocfg"
-version = "1.3.0"
+version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "axum"
-version = "0.7.6"
+version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec"
+checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae"
dependencies = [
"async-trait",
"axum-core",
@@ -173,9 +173,9 @@ dependencies = [
[[package]]
name = "axum-core"
-version = "0.4.4"
+version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00"
+checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
@@ -297,9 +297,9 @@ checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
[[package]]
name = "cc"
-version = "1.1.21"
+version = "1.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0"
+checksum = "e8d9e0b4957f635b8d3da819d0db5603620467ecf1f692d22a8c2717ce27e6d8"
dependencies = [
"shlex",
]
@@ -327,9 +327,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.5.18"
+version = "4.5.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3"
+checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615"
dependencies = [
"clap_builder",
"clap_derive",
@@ -337,9 +337,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.18"
+version = "4.5.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b"
+checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b"
dependencies = [
"anstream",
"anstyle",
@@ -555,6 +555,18 @@ dependencies = [
]
[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
name = "fastrand"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -588,9 +600,9 @@ dependencies = [
[[package]]
name = "futures"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
@@ -603,9 +615,9 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@@ -613,15 +625,15 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
@@ -641,15 +653,15 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@@ -658,21 +670,21 @@ dependencies = [
[[package]]
name = "futures-sink"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
-version = "0.3.30"
+version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@@ -709,9 +721,9 @@ dependencies = [
[[package]]
name = "gimli"
-version = "0.31.0"
+version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64"
+checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "hashbrown"
@@ -724,12 +736,18 @@ dependencies = [
]
[[package]]
+name = "hashbrown"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
+
+[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
- "hashbrown",
+ "hashbrown 0.14.5",
]
[[package]]
@@ -775,6 +793,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
+name = "hex-literal"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
+
+[[package]]
name = "hi"
version = "0.1.0"
dependencies = [
@@ -787,11 +811,13 @@ dependencies = [
"faker_rand",
"futures",
"headers",
+ "hex-literal",
"itertools",
"mime_guess",
"password-hash",
"rand",
"rand_core",
+ "rusqlite",
"rust-embed",
"serde",
"serde_json",
@@ -865,9 +891,9 @@ dependencies = [
[[package]]
name = "httparse"
-version = "1.9.4"
+version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946"
[[package]]
name = "httpdate"
@@ -945,12 +971,12 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.5.0"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
+checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
dependencies = [
"equivalent",
- "hashbrown",
+ "hashbrown 0.15.0",
]
[[package]]
@@ -1167,18 +1193,18 @@ dependencies = [
[[package]]
name = "object"
-version = "0.36.4"
+version = "0.36.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a"
+checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
-version = "1.19.0"
+version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "parking"
@@ -1345,9 +1371,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.5.5"
+version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62871f2d65009c0256aed1b9cfeeb8ac272833c404e13d53d400cd0dad7a2ac0"
+checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f"
dependencies = [
"bitflags",
]
@@ -1373,6 +1399,20 @@ dependencies = [
]
[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
name = "rust-embed"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1646,7 +1686,7 @@ dependencies = [
"futures-intrusive",
"futures-io",
"futures-util",
- "hashbrown",
+ "hashbrown 0.14.5",
"hashlink",
"hex",
"indexmap",
@@ -1837,9 +1877,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
-version = "2.0.77"
+version = "2.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
+checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
dependencies = [
"proc-macro2",
"quote",
@@ -1860,9 +1900,9 @@ checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
[[package]]
name = "tempfile"
-version = "3.12.0"
+version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64"
+checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b"
dependencies = [
"cfg-if",
"fastrand",
@@ -2066,9 +2106,9 @@ dependencies = [
[[package]]
name = "unicode-bidi"
-version = "0.3.15"
+version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893"
[[package]]
name = "unicode-ident"
@@ -2087,9 +2127,9 @@ dependencies = [
[[package]]
name = "unicode-properties"
-version = "0.1.2"
+version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524"
+checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
name = "unicode_categories"
diff --git a/Cargo.toml b/Cargo.toml
index a9cae7d..2587c80 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,21 +6,27 @@ edition = "2021"
[dependencies]
argon2 = "0.5.3"
async-trait = "0.1.83"
-axum = { version = "0.7.6", features = ["form"] }
+axum = { version = "0.7.7", features = ["form"] }
axum-extra = { version = "0.9.4", features = ["cookie", "query", "typed-header"] }
chrono = { version = "0.4.38", features = ["serde"] }
-clap = { version = "4.5.18", features = ["derive", "env"] }
-futures = "0.3.30"
+clap = { version = "4.5.19", features = ["derive", "env"] }
+futures = "0.3.31"
headers = "0.4.0"
+hex-literal = "0.4.1"
itertools = "0.13.0"
mime_guess = "2.0.5"
password-hash = { version = "0.5.0", features = ["std"] }
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["getrandom"] }
+# Pinned to maintain libsqlite3 version match between this and sqlx. See also:
+# <https://docs.rs/sqlx/latest/sqlx/sqlite/index.html>
+rusqlite = { version = "=0.32.1", features = ["backup"] }
rust-embed = "8.5.0"
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
-sqlx = { version = "0.8.2", features = ["chrono", "runtime-tokio", "sqlite"] }
+# Pinned to maintain libsqlite3 version match between this and rusqlite. See
+# also: <https://docs.rs/sqlx/latest/sqlx/sqlite/index.html>
+sqlx = { version = "=0.8.2", features = ["chrono", "runtime-tokio", "sqlite"] }
thiserror = "1.0.64"
tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] }
tokio-stream = { version = "0.1.16", features = ["sync"] }
diff --git a/docs/ops.md b/docs/ops.md
new file mode 100644
index 0000000..8f21c79
--- /dev/null
+++ b/docs/ops.md
@@ -0,0 +1,7 @@
+# Operating `hi`
+
+## Upgrades
+
+`hi` will automatically upgrade its database on startup. Before doing so, it will create a backup of your database (at `.hi.backup`, or controlled by `--backup-database-url`). If the migration process succeeds, this backup will be deleted automatically. If the migration process _fails_, however, the backup will be left in place. In addition, `hi` will attempt to restore your existing database from the backup before exiting.
+
+`hi` will not start if the backup database already exists. To restart `hi` after a failure, move the backup database aside. Once you are satisfied that `hi` has recovered successfully, you can delete it. If you need to restore the database manually, you can also copy it overtop of your database using normal filesystem tools (`cp -a .hi.backup. hi`, for example).
diff --git a/migrations/20240904135137_index_token_issued_at.sql b/migrations/20240904135137_index_token_issued_at.sql
deleted file mode 100644
index b60f691..0000000
--- a/migrations/20240904135137_index_token_issued_at.sql
+++ /dev/null
@@ -1 +0,0 @@
-create index token_issued_at on token (issued_at);
diff --git a/migrations/20240904153315_channel.sql b/migrations/20240904153315_channel.sql
deleted file mode 100644
index e62b51f..0000000
--- a/migrations/20240904153315_channel.sql
+++ /dev/null
@@ -1,20 +0,0 @@
-create table channel (
- id text
- not null
- primary key,
- name text
- not null
- unique
-);
-
-create table channel_member (
- channel text
- not null
- references channel,
- login text
- not null
- references login,
- primary key (channel, login)
-);
-
-create index channel_member_login on channel_member (login);
diff --git a/migrations/20240911230415_no_channel_membership.sql b/migrations/20240911230415_no_channel_membership.sql
deleted file mode 100644
index db5a054..0000000
--- a/migrations/20240911230415_no_channel_membership.sql
+++ /dev/null
@@ -1 +0,0 @@
-drop table channel_member;
diff --git a/migrations/20240912013151_token_last_used.sql b/migrations/20240912013151_token_last_used.sql
deleted file mode 100644
index 0b45cd9..0000000
--- a/migrations/20240912013151_token_last_used.sql
+++ /dev/null
@@ -1,6 +0,0 @@
-alter table token
-add column last_used_at text
- not null;
-
-update token
-set last_used_at = issued_at;
diff --git a/migrations/20240924232919_message_serial_numbers.sql b/migrations/20240924232919_message_serial_numbers.sql
deleted file mode 100644
index a53e4a2..0000000
--- a/migrations/20240924232919_message_serial_numbers.sql
+++ /dev/null
@@ -1,38 +0,0 @@
-create table sequenced_message (
- id text
- not null
- primary key,
- sequence bigint
- not null,
- channel text
- not null
- references channel (id),
- sender text
- not null
- references login (id),
- body text
- not null,
- sent_at text
- not null,
- unique (channel, sequence)
-);
-
-insert into sequenced_message
-select
- id,
- rank() over (
- partition by channel
- order by sent_at
- ) as sequence,
- channel,
- sender,
- body,
- sent_at
-from message;
-
-drop table message;
-
-alter table sequenced_message
-rename to message;
-
-create index message_sent_at on message (channel, sent_at);
diff --git a/migrations/20240928002608_channel_lifecycle.sql b/migrations/20240928002608_channel_lifecycle.sql
deleted file mode 100644
index bc690d7..0000000
--- a/migrations/20240928002608_channel_lifecycle.sql
+++ /dev/null
@@ -1,57 +0,0 @@
-alter table channel
-rename to old_channel;
-
--- Add new columns
-create table channel (
- id text
- not null
- primary key,
- name text
- not null
- unique,
- created_at text
- not null
-);
-
--- Transfer data from original table
-insert into channel
-select
- channel.id,
- channel.name,
- coalesce(
- min(message.sent_at),
- strftime('%FT%R:%f+00:00', 'now', 'utc')
- ) as created_at
-from old_channel as channel
- left join message on channel.id = message.channel
-group by channel.id, channel.name;
-
--- Fix up `message` foreign keys
-alter table message
-rename to old_message;
-
-create table message (
- id text
- not null
- primary key,
- sequence bigint
- not null,
- channel text
- not null
- references channel (id),
- sender text
- not null
- references login (id),
- body text
- not null,
- sent_at text
- not null,
- unique (channel, sequence)
-);
-
-insert into message
-select * from old_message;
-
--- Bury the bodies respectfully
-drop table old_message;
-drop table old_channel;
diff --git a/migrations/20240928012031_channel_stored_sequence.sql b/migrations/20240928012031_channel_stored_sequence.sql
deleted file mode 100644
index badd88d..0000000
--- a/migrations/20240928012031_channel_stored_sequence.sql
+++ /dev/null
@@ -1,60 +0,0 @@
-alter table channel
-rename to old_channel;
-
--- Add new columns
-create table channel (
- id text
- not null
- primary key,
- name text
- not null
- unique,
- created_at text
- not null,
- last_sequence bigint
- not null
-);
-
--- Transfer data from original table
-insert into channel
-select
- channel.id,
- channel.name,
- channel.created_at,
- coalesce(
- max(message.sequence),
- 0
- ) as last_sequence
-from old_channel as channel
- left join message on channel.id = message.channel
-group by channel.id, channel.name;
-
--- Fix up `message` foreign keys
-alter table message
-rename to old_message;
-
-create table message (
- id text
- not null
- primary key,
- sequence bigint
- not null,
- channel text
- not null
- references channel (id),
- sender text
- not null
- references login (id),
- body text
- not null,
- sent_at text
- not null,
- unique (channel, sequence)
-);
-
-insert into message
-select * from old_message;
-
--- Bury the bodies respectfully
-drop table old_message;
-drop table old_channel;
diff --git a/migrations/20240929013644_token_id.sql b/migrations/20240929013644_token_id.sql
deleted file mode 100644
index ad4d8b4..0000000
--- a/migrations/20240929013644_token_id.sql
+++ /dev/null
@@ -1,29 +0,0 @@
-alter table token
-rename to old_token;
-
-create table token (
- id text
- not null
- primary key,
- secret text
- not null
- unique,
- login text
- not null,
- issued_at text
- not null,
- last_used_at text
- not null,
- foreign key (login)
- references login (id)
-);
-
-insert into token
-select
- 'T' || lower(hex(randomblob(8))) as id,
- *
-from old_token;
-
-drop table old_token;
-
-create index token_issued_at on token (issued_at);
diff --git a/migrations/20241002003606_global_sequence.sql b/migrations/20241002003606_global_sequence.sql
deleted file mode 100644
index 198b585..0000000
--- a/migrations/20241002003606_global_sequence.sql
+++ /dev/null
@@ -1,126 +0,0 @@
-create table event_sequence (
- last_value bigint
- not null
-);
-
-create unique index event_sequence_singleton
-on event_sequence (0);
-
--- Attempt to assign events sent so far a globally-unique sequence number,
--- maintaining an approximation of the order they were sent in. This can
--- introduce small ordering anomalies (where the resulting sequence differs
--- from the order they were sent in) for events that were sent close in time;
--- I've gone with chronological order here as it's the closest thing we have to
--- a global ordering, and because the results will be intuitive to most users.
-create temporary table raw_event (
- type text
- not null,
- at text
- not null,
- channel text
- unique,
- message text
- unique,
- check ((channel is not null and message is null) or (message is not null and channel is null))
-);
-
-insert into raw_event (type, at, channel)
-select
- 'channel' as type,
- created_at as at,
- id as channel
-from channel;
-
-insert into raw_event (type, at, message)
-select
- 'message' as type,
- sent_at as at,
- id as message
-from message;
-
-create temporary table event (
- type text
- not null,
- sequence
- unique
- not null,
- at text
- not null,
- channel text
- unique,
- message text
- unique,
- check ((channel is not null and message is null) or (message is not null and channel is null))
-);
-
-insert into event
-select
- type,
- rank() over (order by at) - 1 as sequence,
- at,
- channel,
- message
-from raw_event;
-
-drop table raw_event;
-
-alter table channel rename to old_channel;
-alter table message rename to old_message;
-
-create table channel (
- id text
- not null
- primary key,
- name text
- unique
- not null,
- created_sequence bigint
- unique
- not null,
- created_at text
- not null
-);
-
-insert into channel
-select
- c.id,
- c.name,
- e.sequence,
- c.created_at
-from old_channel as c join event as e
- on e.channel = c.id;
-
-create table message (
- id text
- not null
- primary key,
- channel text
- not null
- references channel (id),
- sender text
- not null
- references login (id),
- sent_sequence bigint
- unique
- not null,
- sent_at text
- not null,
- body text
- not null
-);
-
-insert into message
-select
- m.id,
- m.channel,
- m.sender,
- e.sequence,
- m.sent_at,
- m.body
-from old_message as m join event as e
- on e.message = m.id;
-
-insert into event_sequence
-select coalesce(max(sequence), 0) from event;
-
-drop table event;
diff --git a/migrations/20241005020942_login.sql b/migrations/20241005020942_login.sql
new file mode 100644
index 0000000..92d0875
--- /dev/null
+++ b/migrations/20241005020942_login.sql
@@ -0,0 +1,10 @@
+create table login (
+ id text
+ not null
+ primary key,
+ name text
+ not null
+ unique,
+ password_hash text
+ not null
+);
diff --git a/migrations/20240831024047_login.sql b/migrations/20241005020958_token.sql
index 758e9c7..046638a 100644
--- a/migrations/20240831024047_login.sql
+++ b/migrations/20241005020958_token.sql
@@ -1,22 +1,19 @@
-create table login (
+create table token (
id text
not null
primary key,
- name text
- not null
- unique,
- password_hash text
- not null
-);
-
-create table token (
secret text
not null
- primary key,
+ unique,
login text
not null,
issued_at text
not null,
+ last_used_at text
+ not null,
foreign key (login)
references login (id)
);
+
+create index token_issued_at
+on token (issued_at);
diff --git a/migrations/20241005021009_channel.sql b/migrations/20241005021009_channel.sql
new file mode 100644
index 0000000..ac36884
--- /dev/null
+++ b/migrations/20241005021009_channel.sql
@@ -0,0 +1,16 @@
+create table channel (
+ id text
+ not null
+ primary key,
+ name text
+ unique
+ not null,
+ created_sequence bigint
+ unique
+ not null,
+ created_at text
+ not null
+);
+
+create index channel_created_at
+on channel (created_at);
diff --git a/migrations/20240912145249_message.sql b/migrations/20241005021022_message.sql
index ce9db0d..189612c 100644
--- a/migrations/20240912145249_message.sql
+++ b/migrations/20241005021022_message.sql
@@ -8,10 +8,15 @@ create table message (
sender text
not null
references login (id),
- body text
+ sent_sequence bigint
+ unique
not null,
sent_at text
+ not null,
+ body text
not null
);
-create index message_sent_at on message (channel, sent_at);
+create index message_sent_at
+on message (sent_at);
+
diff --git a/migrations/20241005021100_event_sequence.sql b/migrations/20241005021100_event_sequence.sql
new file mode 100644
index 0000000..ac6821d
--- /dev/null
+++ b/migrations/20241005021100_event_sequence.sql
@@ -0,0 +1,10 @@
+create table event_sequence (
+ last_value bigint
+ not null
+);
+
+create unique index event_sequence_singleton
+on event_sequence (0);
+
+insert into event_sequence
+values (0);
diff --git a/src/cli.rs b/src/cli.rs
index 8b39451..620ba9d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -49,6 +49,10 @@ pub struct Args {
/// Sqlite URL or path for the `hi` database
#[arg(short, long, env, default_value = "sqlite://.hi")]
database_url: String,
+
+ /// Sqlite URL or path for a backup of the `hi` database during upgrades
+ #[arg(short = 'D', long, env, default_value = "sqlite://.hi.backup")]
+ backup_database_url: String,
}
impl Args {
@@ -99,8 +103,8 @@ impl Args {
(self.address.as_str(), self.port)
}
- async fn pool(&self) -> sqlx::Result<SqlitePool> {
- db::prepare(&self.database_url).await
+ async fn pool(&self) -> Result<SqlitePool, db::Error> {
+ db::prepare(&self.database_url, &self.backup_database_url).await
}
}
@@ -127,9 +131,6 @@ fn started_msg(listener: &net::TcpListener) -> io::Result<String> {
pub enum Error {
/// Failure due to `io::Error`. See [`io::Error`].
IoError(#[from] io::Error),
- /// Failure due to a database error. See [`sqlx::Error`].
- DatabaseError(#[from] sqlx::Error),
- /// Failure due to a database migration error. See
- /// [`sqlx::migrate::MigrateError`].
- MigrateError(#[from] sqlx::migrate::MigrateError),
+ /// Failure due to a database initialization error. See [`db::Error`].
+ Database(#[from] db::Error),
}
diff --git a/src/db.rs b/src/db.rs
deleted file mode 100644
index 93a1169..0000000
--- a/src/db.rs
+++ /dev/null
@@ -1,42 +0,0 @@
-use std::str::FromStr;
-
-use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
-
-pub async fn prepare(url: &str) -> sqlx::Result<SqlitePool> {
- let pool = create(url).await?;
- sqlx::migrate!().run(&pool).await?;
- Ok(pool)
-}
-
-async fn create(database_url: &str) -> sqlx::Result<SqlitePool> {
- let options = SqliteConnectOptions::from_str(database_url)?
- .create_if_missing(true)
- .optimize_on_close(true, /* analysis_limit */ None);
-
- let pool = SqlitePoolOptions::new().connect_with(options).await?;
- Ok(pool)
-}
-
-pub trait NotFound {
- type Ok;
- fn not_found<E, F>(self, map: F) -> Result<Self::Ok, E>
- where
- E: From<sqlx::Error>,
- F: FnOnce() -> E;
-}
-
-impl<T> NotFound for Result<T, sqlx::Error> {
- type Ok = T;
-
- fn not_found<E, F>(self, map: F) -> Result<T, E>
- where
- E: From<sqlx::Error>,
- F: FnOnce() -> E,
- {
- match self {
- Err(sqlx::Error::RowNotFound) => Err(map()),
- Err(other) => Err(other.into()),
- Ok(value) => Ok(value),
- }
- }
-}
diff --git a/src/db/backup.rs b/src/db/backup.rs
new file mode 100644
index 0000000..bb36aea
--- /dev/null
+++ b/src/db/backup.rs
@@ -0,0 +1,76 @@
+use rusqlite::{
+ backup::{self},
+ Connection,
+};
+use sqlx::sqlite::{LockedSqliteHandle, SqlitePool};
+
+pub struct Builder<'p> {
+ from: &'p SqlitePool,
+}
+
+impl<'p> Builder<'p> {
+ pub fn to(self, to: &'p SqlitePool) -> Backup<'p> {
+ Backup {
+ from: self.from,
+ to,
+ }
+ }
+}
+
+pub struct Backup<'p> {
+ from: &'p SqlitePool,
+ to: &'p SqlitePool,
+}
+
+impl<'p> Backup<'p> {
+ pub fn from(from: &'p SqlitePool) -> Builder<'p> {
+ Builder { from }
+ }
+}
+
+impl<'p> Backup<'p> {
+ pub async fn backup(&mut self) -> Result<(), Error> {
+ let mut to = self.to.acquire().await?;
+ let mut to = Self::connection(&mut to.lock_handle().await?)?;
+ let mut from = self.from.acquire().await?;
+ let from = Self::connection(&mut from.lock_handle().await?)?;
+
+ let backup = backup::Backup::new(&from, &mut to)?;
+ loop {
+ use rusqlite::backup::StepResult::{Busy, Done, Locked, More};
+ match backup.step(-1)? {
+ Done => break Ok(()),
+ // In the system as actually written, yielding does nothing: this is the
+ // only task actually in flight at the time. However, that may change; if
+ // it does, we _definitely_ want to give other tasks an opportunity to
+ // make progress before we retry.
+ More => tokio::task::consume_budget().await,
+ // If another task is actually using the DB, then it _definitely_ needs
+ // an opportunity to make progress before we retry.
+ Busy | Locked => tokio::task::yield_now().await,
+ // As of this writing, this arm is formally unreachable: all of the enum
+ // constants that actually exist in rusqlite are matched above. However,
+ // they've reserved the prerogative to add additional arms without a
+ // breaking change by making the type as non-exhaustive; if we get one,
+ // stop immediately as there's no way to guess what the correct handling
+ // will be, a priori.
+ other => panic!("unexpected backup step result: {other:?}"),
+ }
+ }
+ }
+
+ fn connection(handle: &mut LockedSqliteHandle) -> Result<Connection, rusqlite::Error> {
+ let handle = handle.as_raw_handle();
+ let connection = unsafe { Connection::from_handle(handle.as_ptr()) }?;
+
+ Ok(connection)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Sqlx(#[from] sqlx::Error),
+ #[error(transparent)]
+ Rusqlite(#[from] rusqlite::Error),
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
new file mode 100644
index 0000000..bbaec7d
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1,114 @@
+mod backup;
+
+use std::str::FromStr;
+
+use hex_literal::hex;
+use sqlx::{
+ migrate::{Migrate as _, MigrateDatabase as _},
+ sqlite::{Sqlite, SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
+};
+
+pub async fn prepare(url: &str, backup_url: &str) -> Result<SqlitePool, Error> {
+ if backup_url != "sqlite::memory:" && Sqlite::database_exists(backup_url).await? {
+ return Err(Error::BackupExists(backup_url.into()));
+ }
+
+ let pool = create(url).await?;
+
+ // First migration of original migration series, from commit
+ // 9bd6d9862b1c243def02200bca2cfbf578ad2a2f or earlier.
+ reject_migration(&pool, "20240831024047", "login", &hex!("9949D238C4099295EC4BEE734BFDA8D87513B2973DFB895352A11AB01DD46CB95314B7F1B3431B77E3444A165FE3DC28")).await?;
+
+ let backup_pool = create(backup_url).await?;
+ backup::Backup::from(&pool)
+ .to(&backup_pool)
+ .backup()
+ .await?;
+
+ if let Err(migrate_error) = sqlx::migrate!().run(&pool).await {
+ if let Err(restore_error) = backup::Backup::from(&backup_pool).to(&pool).backup().await {
+ Err(Error::Restore(restore_error, migrate_error))?;
+ } else {
+ Err(migrate_error)?;
+ };
+ }
+
+ Sqlite::drop_database(backup_url).await?;
+ Ok(pool)
+}
+
+async fn create(database_url: &str) -> sqlx::Result<SqlitePool> {
+ let options = SqliteConnectOptions::from_str(database_url)?
+ .create_if_missing(true)
+ .optimize_on_close(true, /* analysis_limit */ None);
+
+ let pool = SqlitePoolOptions::new().connect_with(options).await?;
+ Ok(pool)
+}
+
+async fn reject_migration(
+ pool: &SqlitePool,
+ version: &str,
+ description: &str,
+ checksum: &[u8],
+) -> Result<(), Error> {
+ let mut conn = pool.acquire().await?;
+ conn.ensure_migrations_table().await?;
+ let applied = conn.list_applied_migrations().await?;
+
+ for migration in applied {
+ if migration.checksum == checksum {
+ return Err(Error::Rejected(version.into(), description.into()));
+ }
+ }
+
+ Ok(())
+}
+
+/// Errors occurring during database setup.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// Failure due to a database error. See [`sqlx::Error`].
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ /// Failure because an existing database backup already exists.
+ #[error("backup from a previous failed migration already exists: {0}")]
+ BackupExists(String),
+ /// Failure due to a database backup error. See [`backup::Error`].
+ #[error(transparent)]
+ Backup(#[from] backup::Error),
+ #[error("backing out failed migration also failed: {0} ({1})")]
+ Restore(backup::Error, sqlx::migrate::MigrateError),
+ /// Failure due to a database migration error. See
+ /// [`sqlx::migrate::MigrateError`].
+ #[error(transparent)]
+ Migration(#[from] sqlx::migrate::MigrateError),
+ /// Failure because the database contains a migration from an unsupported
+ /// schema version.
+ #[error("database contains rejected migration {0}:{1}, move it aside")]
+ Rejected(String, String),
+}
+
+pub trait NotFound {
+ type Ok;
+ fn not_found<E, F>(self, map: F) -> Result<Self::Ok, E>
+ where
+ E: From<sqlx::Error>,
+ F: FnOnce() -> E;
+}
+
+impl<T> NotFound for Result<T, sqlx::Error> {
+ type Ok = T;
+
+ fn not_found<E, F>(self, map: F) -> Result<T, E>
+ where
+ E: From<sqlx::Error>,
+ F: FnOnce() -> E,
+ {
+ match self {
+ Err(sqlx::Error::RowNotFound) => Err(map()),
+ Err(other) => Err(other.into()),
+ Ok(value) => Ok(value),
+ }
+ }
+}
diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs
index c5efa9b..41f7e13 100644
--- a/src/test/fixtures/mod.rs
+++ b/src/test/fixtures/mod.rs
@@ -11,7 +11,7 @@ pub mod login;
pub mod message;
pub async fn scratch_app() -> App {
- let pool = db::prepare("sqlite::memory:")
+ let pool = db::prepare("sqlite::memory:", "sqlite::memory:")
.await
.expect("setting up in-memory sqlite database");
App::from(pool)