Persist chat history to Postgres via durable JetStream consumer

DATABASE_URL required at startup; schema self-initializes.
Compose gains a postgres service for standalone dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:14:26 +02:00
parent 6b97ec3b0f
commit ec3771f2e7
10 changed files with 696 additions and 21 deletions
+3
View File
@@ -3,6 +3,9 @@
# NATS server the chat server publishes/subscribes on. # NATS server the chat server publishes/subscribes on.
NATS_URL=nats://127.0.0.1:4222 NATS_URL=nats://127.0.0.1:4222
# Postgres archive for message history (written by the JetStream consumer).
DATABASE_URL=postgres://cnats:cnats@127.0.0.1:5432/cnats
# Base URL of your Kanidm instance (no trailing slash). # Base URL of your Kanidm instance (no trailing slash).
KANIDM_URL=https://idm.example.com KANIDM_URL=https://idm.example.com
Generated
+417 -11
View File
@@ -11,6 +11,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -102,6 +108,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -260,6 +275,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@@ -340,6 +361,7 @@ dependencies = [
"openidconnect", "openidconnect",
"serde", "serde",
"serde_json", "serde_json",
"sqlx",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
@@ -521,6 +543,30 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crossbeam-queue"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.22" version = "0.8.22"
@@ -732,6 +778,9 @@ name = "either"
version = "1.16.0" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "either_of" name = "either_of"
@@ -795,6 +844,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "etcetera"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.1"
@@ -838,6 +898,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -889,6 +955,17 @@ dependencies = [
"futures-util", "futures-util",
] ]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.32" version = "0.3.32"
@@ -1042,12 +1119,38 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -1072,6 +1175,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "html-escape" name = "html-escape"
version = "0.2.14" version = "0.2.14"
@@ -1178,7 +1290,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots 1.0.8",
] ]
[[package]] [[package]]
@@ -1674,6 +1786,18 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"bitflags",
"libc",
"plain",
"redox_syscall 0.9.0",
]
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1740,6 +1864,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@@ -2022,7 +2156,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link", "windows-link",
] ]
@@ -2101,6 +2235,12 @@ dependencies = [
"spki", "spki",
] ]
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -2452,6 +2592,15 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_syscall"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "ref-cast" name = "ref-cast"
version = "1.0.25" version = "1.0.25"
@@ -2536,7 +2685,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots 1.0.8",
] ]
[[package]] [[package]]
@@ -3129,6 +3278,9 @@ name = "smallvec"
version = "1.15.2" version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -3156,12 +3308,143 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "sqlx"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-postgres",
]
[[package]]
name = "sqlx-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"bytes",
"crc",
"crossbeam-queue",
"either",
"event-listener",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap 2.14.0",
"log",
"memchr",
"once_cell",
"percent-encoding",
"rustls",
"serde",
"serde_json",
"sha2",
"smallvec",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "sqlx-macros"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn",
]
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2",
"sqlx-core",
"sqlx-postgres",
"syn",
"tokio",
"url",
]
[[package]]
name = "sqlx-postgres"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags",
"byteorder",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.6",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.18",
"tracing",
"whoami",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -3399,6 +3682,17 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tokio-tungstenite" name = "tokio-tungstenite"
version = "0.29.0" version = "0.29.0"
@@ -3725,12 +4019,33 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.3" version = "1.13.3"
@@ -3825,6 +4140,12 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.126" version = "0.2.126"
@@ -3935,6 +4256,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.8" version = "1.0.8"
@@ -3944,6 +4274,16 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
]
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -4012,13 +4352,22 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -4030,34 +4379,67 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -4070,24 +4452,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
+7
View File
@@ -21,6 +21,12 @@ tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["fs", "trace"], optional = true } tower-http = { version = "0.6", features = ["fs", "trace"], optional = true }
tower-sessions = { version = "0.14", optional = true } tower-sessions = { version = "0.14", optional = true }
async-nats = { version = "0.38", optional = true } async-nats = { version = "0.38", optional = true }
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
], optional = true }
openidconnect = { version = "4", optional = true } openidconnect = { version = "4", optional = true }
futures = { version = "0.3", optional = true } futures = { version = "0.3", optional = true }
chrono = { version = "0.4", features = ["serde"], optional = true } chrono = { version = "0.4", features = ["serde"], optional = true }
@@ -59,6 +65,7 @@ ssr = [
"dep:tower-http", "dep:tower-http",
"dep:tower-sessions", "dep:tower-sessions",
"dep:async-nats", "dep:async-nats",
"dep:sqlx",
"dep:openidconnect", "dep:openidconnect",
"dep:futures", "dep:futures",
"dep:chrono", "dep:chrono",
+17 -5
View File
@@ -8,6 +8,8 @@ Axum) where every room is a **NATS subject** and sign-in is **Kanidm SSO**
browser ──(server fn POST)──▶ axum ──publish──▶ NATS chat.room.<room> browser ──(server fn POST)──▶ axum ──publish──▶ NATS chat.room.<room>
browser ◀──(SSE /sse/<room>)── axum ◀─subscribe── NATS chat.room.<room> browser ◀──(SSE /sse/<room>)── axum ◀─subscribe── NATS chat.room.<room>
browser ◀──(302 /auth/*)────── axum ◀──OIDC──▶ Kanidm browser ◀──(302 /auth/*)────── axum ◀──OIDC──▶ Kanidm
axum ◀─JetStream (CHAT / cnats-postgres)─ NATS
└──INSERT──▶ Postgres (history archive)
``` ```
The browser never talks to NATS directly: the server publishes on behalf of the The browser never talks to NATS directly: the server publishes on behalf of the
@@ -61,6 +63,7 @@ cp .env.example .env # then edit
| Variable | Meaning | | Variable | Meaning |
| --- | --- | | --- | --- |
| `NATS_URL` | NATS server, default `nats://127.0.0.1:4222` | | `NATS_URL` | NATS server, default `nats://127.0.0.1:4222` |
| `DATABASE_URL` | Postgres for message history, default `postgres://cnats:cnats@127.0.0.1:5432/cnats` |
| `KANIDM_URL` | Base URL of Kanidm, e.g. `https://idm.example.com` | | `KANIDM_URL` | Base URL of Kanidm, e.g. `https://idm.example.com` |
| `OAUTH2_CLIENT_ID` | The Kanidm oauth2 client name (`cnats` above) | | `OAUTH2_CLIENT_ID` | The Kanidm oauth2 client name (`cnats` above) |
| `OAUTH2_CLIENT_SECRET` | Output of `show-basic-secret` | | `OAUTH2_CLIENT_SECRET` | Output of `show-basic-secret` |
@@ -70,18 +73,23 @@ cp .env.example .env # then edit
## Run ## Run
```bash ```bash
docker compose up -d nats docker compose up -d nats postgres
cargo leptos watch # dev, auto-reload on http://127.0.0.1:3000 cargo leptos watch # dev, auto-reload on http://127.0.0.1:3000
cargo leptos build --release # production build (binary + target/site) cargo leptos build --release # production build (binary + target/site)
``` ```
Or run the whole stack (app image included) with `docker compose up`.
Open two browser windows, sign in, and chat — or join from the CLI: Open two browser windows, sign in, and chat — or join from the CLI:
```bash ```bash
nats sub 'chat.room.*' nats sub 'chat.room.*'
nats pub chat.room.lobby '{"id":"cli-1","room":"lobby","username":"cli","display_name":"CLI","text":"hello from the bus","time":"12:00:00"}' nats pub chat.room.lobby '{"id":"cli-1","room":"lobby","username":"cli","display_name":"CLI","text":"hello from the bus","time":"12:00:00","ts":0}'
``` ```
CLI-published messages are archived too — the JetStream consumer sees
everything on `chat.room.*`, not just what this app publishes.
## Layout ## Layout
``` ```
@@ -94,6 +102,7 @@ src/
server/ server/
oidc.rs Kanidm OIDC login/callback/logout handlers oidc.rs Kanidm OIDC login/callback/logout handlers
sse.rs NATS → browser SSE bridge (one subscription per client) sse.rs NATS → browser SSE bridge (one subscription per client)
store.rs JetStream → Postgres archive + room history queries
style/main.css the console theme style/main.css the console theme
``` ```
@@ -102,9 +111,12 @@ style/main.css the console theme
- Sessions are in-memory (`tower-sessions` `MemoryStore`): restart logs - Sessions are in-memory (`tower-sessions` `MemoryStore`): restart logs
everyone out, and it is single-instance. Swap in a Redis/SQL store for everyone out, and it is single-instance. Swap in a Redis/SQL store for
multiple replicas. multiple replicas.
- Messages are not persisted; you only see traffic while connected. NATS - History: a durable JetStream pull consumer (`CHAT` stream, `cnats-postgres`
JetStream (the compose file starts NATS with `-js`) would give history via a consumer) archives `chat.room.*` into Postgres; joining a room backfills the
durable stream over `chat.room.*`. last 100 messages. Inserts are idempotent on message id (JetStream is
at-least-once). The `CHAT` stream keeps default limits — cap it with
`nats stream edit CHAT --max-age=…` if the bus is chatty, since Postgres
already holds the archive.
- The OIDC client verifies ID-token signature, nonce, and CSRF state, and - The OIDC client verifies ID-token signature, nonce, and CSRF state, and
requires PKCE — but there is no token refresh; the app session lives requires PKCE — but there is no token refresh; the app session lives
independently of the Kanidm token lifetime. independently of the Kanidm token lifetime.
+24 -1
View File
@@ -8,10 +8,14 @@ services:
env_file: .env env_file: .env
environment: environment:
NATS_URL: nats://nats:4222 NATS_URL: nats://nats:4222
DATABASE_URL: postgres://cnats:cnats@postgres:5432/cnats
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
- nats nats:
condition: service_started
postgres:
condition: service_healthy
nats: nats:
image: nats:2.10-alpine image: nats:2.10-alpine
@@ -19,3 +23,22 @@ services:
ports: ports:
- "4222:4222" # client connections - "4222:4222" # client connections
- "8222:8222" # monitoring UI (http://localhost:8222) - "8222:8222" # monitoring UI (http://localhost:8222)
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: cnats
POSTGRES_PASSWORD: cnats
POSTGRES_DB: cnats
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cnats -d cnats"]
interval: 2s
timeout: 3s
retries: 15
volumes:
pgdata:
+25 -2
View File
@@ -8,6 +8,8 @@ use leptos_router::{
use crate::auth::{current_user, User}; use crate::auth::{current_user, User};
use crate::chat::{is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM, ROOMS}; use crate::chat::{is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM, ROOMS};
#[cfg(feature = "hydrate")]
use crate::chat::room_history;
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
view! { view! {
@@ -144,7 +146,7 @@ fn ChatShell(room: Memo<String>, user: User) -> impl IntoView {
{ {
let es_handle = StoredValue::new_local(None::<web_sys::EventSource>); let es_handle = StoredValue::new_local(None::<web_sys::EventSource>);
Effect::new(move |_| { Effect::new(move |_| {
let room = room.get(); let room_name = room.get();
es_handle.update_value(|es| { es_handle.update_value(|es| {
if let Some(es) = es.take() { if let Some(es) = es.take() {
es.close(); es.close();
@@ -152,7 +154,28 @@ fn ChatShell(room: Memo<String>, user: User) -> impl IntoView {
}); });
messages.set(Vec::new()); messages.set(Vec::new());
status.set(BusStatus::Connecting); status.set(BusStatus::Connecting);
es_handle.set_value(open_event_source(&room, messages, status)); es_handle.set_value(open_event_source(&room_name, messages, status));
// Backfill from the Postgres archive; live SSE messages may land
// first, so merge by id (history first, then unseen live ones).
leptos::task::spawn_local(async move {
let Ok(history) = room_history(room_name.clone()).await else {
return;
};
// The user may have switched rooms while we were fetching.
if room.get_untracked() != room_name {
return;
}
messages.update(|live| {
let mut merged = history;
for m in live.drain(..) {
if !merged.iter().any(|h| h.id == m.id) {
merged.push(m);
}
}
*live = merged;
});
});
}); });
on_cleanup(move || { on_cleanup(move || {
es_handle.update_value(|es| { es_handle.update_value(|es| {
+35 -1
View File
@@ -30,8 +30,14 @@ pub struct ChatMessage {
pub text: String, pub text: String,
/// Pre-formatted UTC wall-clock time, e.g. "14:03:27". /// Pre-formatted UTC wall-clock time, e.g. "14:03:27".
pub time: String, pub time: String,
/// Publish instant as UTC epoch milliseconds; orders history.
#[serde(default)]
pub ts: i64,
} }
/// How many messages `room_history` backfills when joining a room.
pub const HISTORY_LIMIT: i64 = 100;
/// Publishes a message to the room's NATS subject. Requires a signed-in /// Publishes a message to the room's NATS subject. Requires a signed-in
/// session; the sender identity always comes from the session, never from /// session; the sender identity always comes from the session, never from
/// the client. /// the client.
@@ -58,13 +64,15 @@ pub async fn send_message(room: String, text: String) -> Result<(), ServerFnErro
}; };
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
let now = chrono::Utc::now();
let msg = ChatMessage { let msg = ChatMessage {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
room: room.clone(), room: room.clone(),
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
text, text,
time: chrono::Utc::now().format("%H:%M:%S").to_string(), time: now.format("%H:%M:%S").to_string(),
ts: now.timestamp_millis(),
}; };
let payload = serde_json::to_vec(&msg).map_err(|e| ServerFnError::new(e.to_string()))?; let payload = serde_json::to_vec(&msg).map_err(|e| ServerFnError::new(e.to_string()))?;
state state
@@ -74,3 +82,29 @@ pub async fn send_message(room: String, text: String) -> Result<(), ServerFnErro
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(()) Ok(())
} }
/// Returns the most recent messages for a room (oldest first), read from the
/// Postgres archive maintained by the JetStream consumer.
#[server]
pub async fn room_history(room: String) -> Result<Vec<ChatMessage>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
if !is_valid_room(&room) {
return Err(ServerFnError::new("unknown room"));
}
let session: tower_sessions::Session = leptos_axum::extract().await?;
if session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.is_none()
{
return Err(ServerFnError::new("not signed in"));
}
let state = expect_context::<AppState>();
crate::server::store::recent(&state.pool, &room, HISTORY_LIMIT)
.await
.map_err(|e| ServerFnError::new(format!("history unavailable: {e}")))
}
+14 -1
View File
@@ -10,7 +10,7 @@ async fn main() -> anyhow::Result<()> {
Router, Router,
}; };
use cnats::app::{shell, App}; use cnats::app::{shell, App};
use cnats::server::{oidc, sse, AppState}; use cnats::server::{oidc, sse, store, AppState};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use std::sync::Arc; use std::sync::Arc;
@@ -34,12 +34,25 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(%nats_url, "connecting to NATS"); tracing::info!(%nats_url, "connecting to NATS");
let nats = async_nats::connect(&nats_url).await?; let nats = async_nats::connect(&nats_url).await?;
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://cnats:cnats@127.0.0.1:5432/cnats".to_string());
tracing::info!("connecting to postgres");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
store::init_schema(&pool).await?;
// Archive chat.room.* into Postgres via a durable JetStream consumer.
tokio::spawn(store::run_consumer(nats.clone(), pool.clone()));
let oidc_state = Arc::new(oidc::Oidc::from_env().await?); let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
let state = AppState { let state = AppState {
leptos_options: leptos_options.clone(), leptos_options: leptos_options.clone(),
nats, nats,
oidc: oidc_state, oidc: oidc_state,
pool,
}; };
// Dev-friendly defaults: in-memory sessions, secure cookies only when // Dev-friendly defaults: in-memory sessions, secure cookies only when
+2
View File
@@ -1,5 +1,6 @@
pub mod oidc; pub mod oidc;
pub mod sse; pub mod sse;
pub mod store;
use axum::extract::FromRef; use axum::extract::FromRef;
use leptos::prelude::LeptosOptions; use leptos::prelude::LeptosOptions;
@@ -10,6 +11,7 @@ pub struct AppState {
pub leptos_options: LeptosOptions, pub leptos_options: LeptosOptions,
pub nats: async_nats::Client, pub nats: async_nats::Client,
pub oidc: Arc<oidc::Oidc>, pub oidc: Arc<oidc::Oidc>,
pub pool: sqlx::PgPool,
} }
impl FromRef<AppState> for LeptosOptions { impl FromRef<AppState> for LeptosOptions {
+152
View File
@@ -0,0 +1,152 @@
//! Message archive: a durable JetStream pull consumer drains `chat.room.*`
//! into Postgres, so history survives restarts and includes messages
//! published by any client on the bus (not just this app).
use std::time::Duration;
use async_nats::jetstream;
use futures::StreamExt;
use sqlx::PgPool;
use crate::chat::ChatMessage;
const STREAM_NAME: &str = "CHAT";
const CONSUMER_NAME: &str = "cnats-postgres";
pub async fn init_schema(pool: &PgPool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
room TEXT NOT NULL,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
text TEXT NOT NULL,
time TEXT NOT NULL,
ts BIGINT NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS messages_room_ts ON messages (room, ts DESC, id)",
)
.execute(pool)
.await?;
Ok(())
}
/// Runs forever; (re)creates the stream/consumer and retries on any failure,
/// so a NATS or Postgres outage never takes the chat server down.
pub async fn run_consumer(nats: async_nats::Client, pool: PgPool) {
loop {
if let Err(err) = consume(&nats, &pool).await {
tracing::error!("archive consumer failed: {err:#}; retrying in 5s");
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
async fn consume(nats: &async_nats::Client, pool: &PgPool) -> anyhow::Result<()> {
let js = jetstream::new(nats.clone());
let stream = js
.get_or_create_stream(jetstream::stream::Config {
name: STREAM_NAME.to_string(),
subjects: vec!["chat.room.*".to_string()],
..Default::default()
})
.await
.map_err(|e| anyhow::anyhow!("get_or_create_stream: {e}"))?;
let consumer = stream
.get_or_create_consumer(
CONSUMER_NAME,
jetstream::consumer::pull::Config {
durable_name: Some(CONSUMER_NAME.to_string()),
..Default::default()
},
)
.await
.map_err(|e| anyhow::anyhow!("get_or_create_consumer: {e}"))?;
tracing::info!(stream = STREAM_NAME, consumer = CONSUMER_NAME, "archiving to postgres");
let mut messages = consumer
.messages()
.await
.map_err(|e| anyhow::anyhow!("consumer messages: {e}"))?;
while let Some(msg) = messages.next().await {
let msg = msg.map_err(|e| anyhow::anyhow!("pull next: {e}"))?;
match serde_json::from_slice::<ChatMessage>(&msg.payload) {
Ok(chat) => insert(pool, &chat).await?,
// Malformed payloads (e.g. hand-typed CLI publishes) are logged
// and acked so they don't wedge the consumer.
Err(err) => tracing::warn!("skipping unparseable message: {err}"),
}
msg.ack()
.await
.map_err(|e| anyhow::anyhow!("ack: {e}"))?;
}
Ok(())
}
async fn insert(pool: &PgPool, m: &ChatMessage) -> anyhow::Result<()> {
// Idempotent on message id: JetStream is at-least-once, so redeliveries
// after an unacked crash must not duplicate rows.
sqlx::query(
"INSERT INTO messages (id, room, username, display_name, text, time, ts)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING",
)
.bind(&m.id)
.bind(&m.room)
.bind(&m.username)
.bind(&m.display_name)
.bind(&m.text)
.bind(&m.time)
.bind(m.ts)
.execute(pool)
.await?;
Ok(())
}
/// The latest `limit` messages for a room, oldest first.
pub async fn recent(pool: &PgPool, room: &str, limit: i64) -> anyhow::Result<Vec<ChatMessage>> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
room: String,
username: String,
display_name: String,
text: String,
time: String,
ts: i64,
}
let mut rows: Vec<Row> = sqlx::query_as(
"SELECT id, room, username, display_name, text, time, ts
FROM messages
WHERE room = $1
ORDER BY ts DESC, id
LIMIT $2",
)
.bind(room)
.bind(limit)
.fetch_all(pool)
.await?;
rows.reverse();
Ok(rows
.into_iter()
.map(|r| ChatMessage {
id: r.id,
room: r.room,
username: r.username,
display_name: r.display_name,
text: r.text,
time: r.time,
ts: r.ts,
})
.collect())
}