diff options
22 files changed, 361 insertions, 459 deletions
@@ -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" @@ -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); @@ -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) |
