Compare commits
10
Commits
ed85af4a4f
...
9bff0c6fdf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bff0c6fdf | ||
|
|
1035fd179f | ||
|
|
6d91fd9c36 | ||
|
|
a09ac32bef | ||
|
|
9dcb0d6dc8 | ||
|
|
23fab7a5ff | ||
|
|
ae9064ee76 | ||
|
|
a1e2effe96 | ||
|
|
b57dbd0fa0 | ||
|
|
2358ca29c7 |
Generated
+583
-22
@@ -2,6 +2,30 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -134,6 +158,12 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -152,6 +182,27 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "4.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -244,6 +295,15 @@ version = "2.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-channel"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
@@ -370,6 +430,23 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"libredox",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -402,6 +479,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fsevent-sys"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -519,7 +605,19 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -530,7 +628,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -831,6 +929,26 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -847,6 +965,26 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||||
|
dependencies = [
|
||||||
|
"kqueue-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue-sys"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -880,7 +1018,7 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.7.3",
|
"redox_syscall 0.7.3",
|
||||||
@@ -909,16 +1047,25 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"brotli",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"futures",
|
"futures",
|
||||||
|
"http-body",
|
||||||
"maud",
|
"maud",
|
||||||
|
"notify",
|
||||||
|
"pin-project",
|
||||||
|
"postgres-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-postgres",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -937,6 +1084,15 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -989,6 +1145,18 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -996,7 +1164,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify"
|
||||||
|
version = "6.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"filetime",
|
||||||
|
"fsevent-sys",
|
||||||
|
"inotify",
|
||||||
|
"kqueue",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"mio 0.8.11",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1011,7 +1207,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1046,6 +1242,24 @@ dependencies = [
|
|||||||
"libm",
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-system-configuration"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -1096,6 +1310,45 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1141,6 +1394,35 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postgres-protocol"
|
||||||
|
version = "0.6.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"fallible-iterator",
|
||||||
|
"hmac",
|
||||||
|
"md-5",
|
||||||
|
"memchr",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"sha2",
|
||||||
|
"stringprep",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postgres-types"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"fallible-iterator",
|
||||||
|
"postgres-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -1210,6 +1492,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1223,8 +1511,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1234,7 +1532,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1246,13 +1554,22 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1261,9 +1578,26 @@ version = "0.7.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -1277,7 +1611,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1296,6 +1630,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1362,6 +1705,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1396,6 +1748,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1419,9 +1780,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1559,7 +1926,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -1580,7 +1947,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
@@ -1590,7 +1957,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami 1.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1601,7 +1968,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -1618,7 +1985,7 @@ dependencies = [
|
|||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -1627,7 +1994,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami 1.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1725,6 +2092,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1758,7 +2134,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio 1.1.1",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
@@ -1778,6 +2154,32 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-postgres"
|
||||||
|
version = "0.7.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"fallible-iterator",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"parking_lot",
|
||||||
|
"percent-encoding",
|
||||||
|
"phf",
|
||||||
|
"pin-project-lite",
|
||||||
|
"postgres-protocol",
|
||||||
|
"postgres-types",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"whoami 2.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -1789,6 +2191,60 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1847,6 +2303,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1918,6 +2404,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -1930,12 +2422,31 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.14.7+wasi-0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||||
|
dependencies = [
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.2+wasi-0.2.9"
|
version = "1.0.2+wasi-0.2.9"
|
||||||
@@ -1960,6 +2471,15 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasite"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||||
|
dependencies = [
|
||||||
|
"wasi 0.14.7+wasi-0.2.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.114"
|
version = "0.2.114"
|
||||||
@@ -2033,12 +2553,22 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.91"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -2046,7 +2576,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libredox",
|
"libredox",
|
||||||
"wasite",
|
"wasite 0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "whoami"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"libredox",
|
||||||
|
"objc2-system-configuration",
|
||||||
|
"wasite 1.0.2",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2257,6 +2809,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -2315,7 +2876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+29
-2
@@ -3,14 +3,20 @@ name = "livevue-rs"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# Run the todo example with: cargo run --example todo
|
# Run the todo example with: cargo run --example todo
|
||||||
|
# Run the pg_replication example with: cargo run --example pg_replication --features pg_replication
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Opt-in feature: PostgreSQL logical replication for reactive cache invalidation.
|
||||||
|
# Adds tokio-postgres and bytes as dependencies.
|
||||||
|
pg_replication = ["dep:tokio-postgres", "dep:postgres-protocol"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres"] }
|
||||||
maud = { version = "0.26", features = ["axum"] }
|
maud = { version = "0.26", features = ["axum"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
@@ -19,6 +25,27 @@ tokio-stream = "0.1"
|
|||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
|
|
||||||
|
# Config file parsing
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Brotli streaming compression (configurable window size + quality)
|
||||||
|
brotli = "6"
|
||||||
|
pin-project = "1"
|
||||||
|
bytes = "1"
|
||||||
|
http-body = "1"
|
||||||
|
|
||||||
|
# Config file watching (inotify on Linux, kqueue on macOS, FSEvents on macOS)
|
||||||
|
notify = "6"
|
||||||
|
|
||||||
|
# Optional: PostgreSQL logical replication
|
||||||
|
tokio-postgres = { version = "0.7", optional = true }
|
||||||
|
postgres-protocol = { version = "0.6", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
chrono = { version = "0.4", features = ["clock"] }
|
chrono = { version = "0.4", features = ["clock"] }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "pg_replication"
|
||||||
|
path = "examples/pg_replication/main.rs"
|
||||||
|
required-features = ["pg_replication"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: livevue_postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: livevue
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./pg_hba.conf:/etc/postgresql/pg_hba.conf
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- wal_level=logical
|
||||||
|
- -c
|
||||||
|
- max_replication_slots=10
|
||||||
|
- -c
|
||||||
|
- max_wal_senders=10
|
||||||
|
- -c
|
||||||
|
- hba_file=/etc/postgresql/pg_hba.conf
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d livevue"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
//! # pg_replication example
|
||||||
|
//!
|
||||||
|
//! A live message board backed by PostgreSQL.
|
||||||
|
//!
|
||||||
|
//! Run:
|
||||||
|
//! ```sh
|
||||||
|
//! docker compose up -d # start the PG container (see docker-compose.yml)
|
||||||
|
//! cargo run --example pg_replication --features pg_replication
|
||||||
|
//! ```
|
||||||
|
//! Then open http://localhost:3001 in two browser tabs and watch them stay in sync.
|
||||||
|
//!
|
||||||
|
//! ## How it works
|
||||||
|
//!
|
||||||
|
//! 1. The render function queries the `messages` table directly via `sqlx::PgPool`
|
||||||
|
//! and calls `cx.subscribe(pg_table_key("public", "messages"))` to tell the
|
||||||
|
//! framework "re-render this connection whenever `pg:public.messages` is
|
||||||
|
//! invalidated".
|
||||||
|
//!
|
||||||
|
//! 2. `PgReplicationListener` connects to PostgreSQL over the logical replication
|
||||||
|
//! protocol, buffers changes per transaction, and on each COMMIT publishes
|
||||||
|
//! `CacheKey::Channel { key: "pg:public.messages" }` to the store's broadcast
|
||||||
|
//! channel.
|
||||||
|
//!
|
||||||
|
//! 3. The fanout task picks up the event, finds all connections subscribed to that
|
||||||
|
//! key, and triggers a re-render for each — sending fresh HTML over SSE.
|
||||||
|
//!
|
||||||
|
//! No polling. No websocket boilerplate. Just PostgreSQL WAL + SSE.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Json, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use maud::{html, Markup, DOCTYPE};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use livevue_rs::{
|
||||||
|
pg_replication::{pg_table_key, PgReplicationConfig, PgReplicationListener},
|
||||||
|
server::{ingest_signals, patch_signal, AppState, SharedState},
|
||||||
|
spawn_fanout, RenderContext, Store,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetch all messages, newest first.
|
||||||
|
pub struct ListMessages;
|
||||||
|
|
||||||
|
livevue_rs::pg_query_all!(ListMessages {
|
||||||
|
sql: "SELECT id, body FROM messages ORDER BY id DESC",
|
||||||
|
params: [],
|
||||||
|
output: Message,
|
||||||
|
cache: |_| pg_table_key("public", "messages"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Domain types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct Message {
|
||||||
|
id: i64,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Database helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn setup_db(pool: &sqlx::PgPool) -> anyhow::Result<()> {
|
||||||
|
// Enable logical replication for this table (requires superuser or
|
||||||
|
// rds_superuser on managed PG).
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
body TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create the publication if it doesn't exist.
|
||||||
|
// `IF NOT EXISTS` was added in PG 14 — fall back gracefully on older versions.
|
||||||
|
let exists: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'livevue_pub')",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
sqlx::query("CREATE PUBLICATION livevue_pub FOR TABLE messages")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
println!("Created publication livevue_pub");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Shared render state — anything the render function needs beyond the store.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RenderState {
|
||||||
|
pg: sqlx::PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_page(cx: &mut RenderContext, rs: &RenderState) -> anyhow::Result<Markup> {
|
||||||
|
// Fetch messages and auto-subscribe to the WAL invalidation key.
|
||||||
|
let messages = cx.run_pg(&rs.pg, ListMessages).await?;
|
||||||
|
|
||||||
|
let new_msg = cx.signal("newMsg", livevue_rs::global());
|
||||||
|
let _conn_id_signal = cx.signal("connId", livevue_rs::global());
|
||||||
|
|
||||||
|
Ok(html! {
|
||||||
|
div #app {
|
||||||
|
h1 { "Live Message Board" }
|
||||||
|
p.subtitle {
|
||||||
|
"Messages are stored in PostgreSQL. "
|
||||||
|
"Add one and watch every open tab update in real time via WAL replication."
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New message form ─────────────────────────────────────────
|
||||||
|
div.compose {
|
||||||
|
input
|
||||||
|
type="text"
|
||||||
|
placeholder="Type a message…"
|
||||||
|
data-bind=(new_msg.name)
|
||||||
|
data-on:keydown="{if (event.key === 'Enter') @post('/action/post_message')}"
|
||||||
|
;
|
||||||
|
button
|
||||||
|
data-on:click="@post('/action/post_message')"
|
||||||
|
{ "Send" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message list ─────────────────────────────────────────────
|
||||||
|
@if messages.is_empty() {
|
||||||
|
p.empty { "No messages yet. Be the first!" }
|
||||||
|
} @else {
|
||||||
|
ul.messages {
|
||||||
|
@for msg in &messages {
|
||||||
|
li {
|
||||||
|
span.id { "#" (msg.id) }
|
||||||
|
span.body { (msg.body) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full HTML shell for the initial page load.
|
||||||
|
fn page_shell(conn_id: Uuid, signals_json: &str, inner: &Markup) -> String {
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
html lang="en" {
|
||||||
|
head {
|
||||||
|
meta charset="utf-8";
|
||||||
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||||
|
title { "pg_replication example — livevue-rs" }
|
||||||
|
style {
|
||||||
|
(r#"
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h1 { margin-bottom: 0.25rem; }
|
||||||
|
.subtitle { color: #666; font-size: 0.9rem; margin-top: 0; }
|
||||||
|
.compose { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
|
||||||
|
.compose input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
|
||||||
|
.compose button { padding: 0.5rem 1rem; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
|
||||||
|
.compose button:disabled { background: #aaa; cursor: default; }
|
||||||
|
ul.messages { list-style: none; padding: 0; margin: 0; }
|
||||||
|
ul.messages li { padding: 0.75rem; border-bottom: 1px solid #eee; display: flex; gap: 0.75rem; }
|
||||||
|
.id { color: #999; font-size: 0.85rem; min-width: 2.5rem; }
|
||||||
|
.empty { color: #999; }
|
||||||
|
"#)
|
||||||
|
}
|
||||||
|
// DataStar SDK (loaded from CDN for the example)
|
||||||
|
script
|
||||||
|
type="module"
|
||||||
|
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js"
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
body
|
||||||
|
data-signals=(signals_json)
|
||||||
|
data-init=(format!("@get('/sse?conn={conn_id}')"))
|
||||||
|
{
|
||||||
|
(inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn index_handler(State(state): State<(SharedState, RenderState)>) -> impl IntoResponse {
|
||||||
|
let (app_state, rs) = &state;
|
||||||
|
let conn_id = Uuid::new_v4();
|
||||||
|
let signals_json = &format!(r#"{{"newMsg":"","connId":"{}"}}"#, conn_id);
|
||||||
|
app_state.connections.insert(conn_id, {
|
||||||
|
// We need a sender for the connection, but the SSE handler will replace
|
||||||
|
// it. Use a dummy channel that immediately drops.
|
||||||
|
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||||
|
tx
|
||||||
|
});
|
||||||
|
app_state
|
||||||
|
.connections
|
||||||
|
.update_signals(&conn_id, signals_json.to_string());
|
||||||
|
|
||||||
|
let mut cx =
|
||||||
|
RenderContext::new(conn_id, livevue_rs::SignalStore::from_json(serde_json::json!({"newMsg": "", "connId": conn_id.to_string()})), Default::default(), app_state.store.clone());
|
||||||
|
|
||||||
|
let inner = render_page(&mut cx, rs).await.unwrap_or_else(|e| {
|
||||||
|
html! { p { "Render error: " (e) } }
|
||||||
|
});
|
||||||
|
|
||||||
|
Html(page_shell(conn_id, signals_json, &inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE handler is provided by the framework; it needs SharedState only.
|
||||||
|
// We wrap it to adapt the two-tuple state.
|
||||||
|
async fn sse_adapter(
|
||||||
|
State(state): State<(SharedState, RenderState)>,
|
||||||
|
query: axum::extract::Query<livevue_rs::server::SseParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
livevue_rs::sse_handler(State(state.0), query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_message_handler(
|
||||||
|
State(state): State<(SharedState, RenderState)>,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let conn_id = body
|
||||||
|
.get("connId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or_else(Uuid::new_v4);
|
||||||
|
|
||||||
|
let signals = ingest_signals(&state.0, conn_id, body);
|
||||||
|
let msg: String = signals
|
||||||
|
.get("newMsg")
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.trim().to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if msg.is_empty() {
|
||||||
|
return StatusCode::BAD_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query("INSERT INTO messages (body) VALUES ($1)")
|
||||||
|
.bind(&msg)
|
||||||
|
.execute(&state.1.pg)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("failed to insert message: {e}");
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the stored signals to reset the input field
|
||||||
|
patch_signal(&state.0, &conn_id, "newMsg", serde_json::json!(""));
|
||||||
|
|
||||||
|
// Note: we do NOT call store.publish() here — the PgReplicationListener
|
||||||
|
// will detect the INSERT through the WAL stream and fire the invalidation
|
||||||
|
// automatically. This demonstrates the key value of the module: mutations
|
||||||
|
// from *any* source (other services, psql, migrations) are reflected
|
||||||
|
// without any extra wiring.
|
||||||
|
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
fn router(app_state: SharedState, render_state: RenderState) -> Router {
|
||||||
|
let combined = (app_state, render_state);
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index_handler))
|
||||||
|
.route("/sse", get(sse_adapter))
|
||||||
|
.route("/action/post_message", post(post_message_handler))
|
||||||
|
.with_state(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive("livevue_rs=debug".parse().unwrap())
|
||||||
|
.add_directive("pg_replication=debug".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Database setup
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/livevue".into());
|
||||||
|
|
||||||
|
let pg = sqlx::PgPool::connect(&database_url).await?;
|
||||||
|
setup_db(&pg).await?;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Framework store (SQLite in-memory, unused for queries here)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The Store provides the broadcast channel and KV store that the framework
|
||||||
|
// needs. The embedded SQLite pool is not used in this example — queries go
|
||||||
|
// directly to PostgreSQL via the captured `pg` pool in the render closure.
|
||||||
|
let sqlite = sqlx::SqlitePool::connect("sqlite::memory:").await?;
|
||||||
|
let store = Store::new(sqlite);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Render function
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let render_state = RenderState { pg: pg.clone() };
|
||||||
|
let rs_clone = render_state.clone();
|
||||||
|
|
||||||
|
let render_fn: livevue_rs::RenderFn = Arc::new(move |mut cx| {
|
||||||
|
let rs = rs_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let signals_json = cx.signals.to_json_string();
|
||||||
|
let conn_id = cx.connection_id;
|
||||||
|
|
||||||
|
let inner = render_page(&mut cx, &rs).await?;
|
||||||
|
let keys = cx.take_subscription_keys();
|
||||||
|
|
||||||
|
// Wrap in the full page shell so DataStar can morph the entire document.
|
||||||
|
let html = page_shell(conn_id, &signals_json, &inner);
|
||||||
|
Ok((html, keys))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Application state + fanout
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let state = AppState::new(store.clone(), render_fn);
|
||||||
|
spawn_fanout(state.clone());
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// PG replication listener
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The replication protocol requires a dedicated connection.
|
||||||
|
|
||||||
|
let replication_store = store.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
// Rebuild the config each iteration — PgReplicationConfig is not
|
||||||
|
// Clone, and start() takes ownership, so we reconstruct from the
|
||||||
|
// env-var values cached in the outer scope.
|
||||||
|
let c = PgReplicationConfig {
|
||||||
|
host: std::env::var("PG_HOST").unwrap_or_else(|_| "localhost".into()),
|
||||||
|
port: std::env::var("PG_PORT").ok()
|
||||||
|
.and_then(|p| p.parse().ok()).unwrap_or(5432),
|
||||||
|
user: std::env::var("PG_USER").unwrap_or_else(|_| "postgres".into()),
|
||||||
|
password: std::env::var("PG_PASSWORD").unwrap_or_else(|_| "postgres".into()),
|
||||||
|
database: std::env::var("PG_DATABASE").unwrap_or_else(|_| "livevue".into()),
|
||||||
|
slot_name: "livevue_slot".into(),
|
||||||
|
publication_name: "livevue_pub".into(),
|
||||||
|
start_lsn: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = PgReplicationListener::new(c, replication_store.clone()).start().await {
|
||||||
|
tracing::error!("pg replication listener error: {e:#}");
|
||||||
|
}
|
||||||
|
tracing::info!("pg replication listener reconnecting in 5 s…");
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HTTP server
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let app = router(state, render_state);
|
||||||
|
let listener_tcp = tokio::net::TcpListener::bind("0.0.0.0:3001").await?;
|
||||||
|
println!("pg_replication example listening on http://localhost:3001");
|
||||||
|
axum::serve(listener_tcp, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# docker/pg_hba.conf
|
||||||
|
local all all trust
|
||||||
|
host all all 0.0.0.0/0 trust
|
||||||
|
host replication all 0.0.0.0/0 trust
|
||||||
+37
-54
@@ -1,9 +1,10 @@
|
|||||||
use livevue_rs::{
|
use livevue_rs::{
|
||||||
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
ComponentTree, RenderContext, SharedConfig, SharedState, SignalStore,
|
||||||
ingest_signals, sse_handler,
|
brotli_compression, ingest_signals, sse_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::extract::{Query as AxumQuery, State};
|
use axum::extract::{Query as AxumQuery, State};
|
||||||
|
use axum::middleware;
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
@@ -14,7 +15,7 @@ use uuid::Uuid;
|
|||||||
// Router
|
// Router
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn router(state: SharedState) -> Router {
|
pub fn router(state: SharedState, config: SharedConfig) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(initial_page_load))
|
.route("/", get(initial_page_load))
|
||||||
.route("/about", get(initial_about_load))
|
.route("/about", get(initial_about_load))
|
||||||
@@ -22,6 +23,10 @@ pub fn router(state: SharedState) -> Router {
|
|||||||
.route("/action/add_todo", post(action_add_todo))
|
.route("/action/add_todo", post(action_add_todo))
|
||||||
.route("/action/toggle_todo", post(action_toggle_todo))
|
.route("/action/toggle_todo", post(action_toggle_todo))
|
||||||
.route("/action/delete_todo", post(action_delete_todo))
|
.route("/action/delete_todo", post(action_delete_todo))
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
config,
|
||||||
|
brotli_compression,
|
||||||
|
))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,50 +160,30 @@ struct ActionIdParams {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ingest signals, run a mutation, then publish an invalidation event so the
|
|
||||||
/// fanout task handles the rerender. Returns 202 Accepted.
|
|
||||||
async fn action_then_publish(
|
|
||||||
state: &AppState,
|
|
||||||
body: serde_json::Value,
|
|
||||||
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
|
||||||
invalidation_key: CacheKey,
|
|
||||||
) -> StatusCode {
|
|
||||||
let signals = ingest_signals(state, extract_conn_and_tree(&SignalStore::from_json(body.clone())).0, body);
|
|
||||||
let _ = signals; // signals are stored; mutation drives the rerender
|
|
||||||
|
|
||||||
if let Err(e) = mutation.await {
|
|
||||||
eprintln!("Action mutation error: {}", e);
|
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.store.publish(invalidation_key);
|
|
||||||
StatusCode::ACCEPTED
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn action_add_todo(
|
async fn action_add_todo(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
||||||
|
ingest_signals(&state, conn_id, body.clone());
|
||||||
|
|
||||||
let title = body
|
let title = body
|
||||||
.get("newTodo")
|
.get("newTodo")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
let db = state.store.db.clone();
|
|
||||||
|
|
||||||
action_then_publish(
|
if title.is_empty() {
|
||||||
&state,
|
return StatusCode::ACCEPTED;
|
||||||
body,
|
}
|
||||||
async move {
|
|
||||||
if !title.is_empty() {
|
if let Err(e) = state.store.exec(crate::queries::CreateTodo { title }).await {
|
||||||
crate::queries::create_todo(&db, &title).await?;
|
eprintln!("action_add_todo error: {e}");
|
||||||
}
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
Ok(())
|
}
|
||||||
},
|
|
||||||
CacheKey::Table { table: "todos" },
|
StatusCode::ACCEPTED
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn action_toggle_todo(
|
async fn action_toggle_todo(
|
||||||
@@ -206,16 +191,15 @@ async fn action_toggle_todo(
|
|||||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let db = state.store.db.clone();
|
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
||||||
let id = params.id;
|
ingest_signals(&state, conn_id, body);
|
||||||
|
|
||||||
action_then_publish(
|
if let Err(e) = state.store.exec(crate::queries::ToggleTodo { id: params.id }).await {
|
||||||
&state,
|
eprintln!("action_toggle_todo error: {e}");
|
||||||
body,
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
async move { crate::queries::toggle_todo(&db, id).await },
|
}
|
||||||
CacheKey::Table { table: "todos" },
|
|
||||||
)
|
StatusCode::ACCEPTED
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn action_delete_todo(
|
async fn action_delete_todo(
|
||||||
@@ -223,14 +207,13 @@ async fn action_delete_todo(
|
|||||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let db = state.store.db.clone();
|
let conn_id = extract_conn_and_tree(&SignalStore::from_json(body.clone())).0;
|
||||||
let id = params.id;
|
ingest_signals(&state, conn_id, body);
|
||||||
|
|
||||||
action_then_publish(
|
if let Err(e) = state.store.exec(crate::queries::DeleteTodo { id: params.id }).await {
|
||||||
&state,
|
eprintln!("action_delete_todo error: {e}");
|
||||||
body,
|
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
async move { crate::queries::delete_todo(&db, id).await },
|
}
|
||||||
CacheKey::Table { table: "todos" },
|
|
||||||
)
|
StatusCode::ACCEPTED
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
+12
-5
@@ -3,7 +3,7 @@ mod queries;
|
|||||||
mod todo_list;
|
mod todo_list;
|
||||||
mod about;
|
mod about;
|
||||||
|
|
||||||
use livevue_rs::{AppState, CacheKey, RenderContext, Store, spawn_fanout};
|
use livevue_rs::{AppState, RenderContext, Store, load_and_watch_config, spawn_fanout};
|
||||||
use crate::app::{app_wrapper, resolve_and_render};
|
use crate::app::{app_wrapper, resolve_and_render};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -68,11 +68,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Configuration (livevue.toml, hot-reloaded on change or SIGHUP)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let config = load_and_watch_config("livevue.toml")?;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Application state
|
// Application state
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
let state = AppState::new(store.clone(), render_fn);
|
let state = AppState::new(store.clone(), render_fn, config.clone());
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Fanout task
|
// Fanout task
|
||||||
@@ -103,9 +109,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Server
|
// Server
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
let app = app::router(state);
|
let bind_addr = config.read().await.server.bind_addr();
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let app = app::router(state, config);
|
||||||
println!("Todo example listening on http://localhost:3000");
|
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
|
||||||
|
println!("Todo example listening on http://{bind_addr}");
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+29
-23
@@ -40,36 +40,42 @@ livevue_rs::query_one!(GetTodo {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Write operations (plain sqlx, not through Query trait)
|
// Write operations (mutation structs via mutation_exec!)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Mutations don't go through cx.run() because they're not reads and don't
|
// Each mutation struct implements Query with publish_key() set to the cache
|
||||||
// produce subscription keys. They're called directly by action handlers,
|
// key it invalidates. Use Store::exec() in action handlers — it executes the
|
||||||
// which then trigger a full rerender.
|
// mutation and publishes the key automatically.
|
||||||
|
|
||||||
/// Insert a new todo. Returns the new row's id.
|
/// Insert a new todo.
|
||||||
pub async fn create_todo(db: &sqlx::SqlitePool, title: &str) -> anyhow::Result<i64> {
|
pub struct CreateTodo {
|
||||||
let result = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, false)")
|
pub title: String,
|
||||||
.bind(title)
|
|
||||||
.execute(db)
|
|
||||||
.await?;
|
|
||||||
Ok(result.last_insert_rowid())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
livevue_rs::mutation_exec!(CreateTodo {
|
||||||
|
sql: "INSERT INTO todos (title, completed) VALUES (?, false)",
|
||||||
|
params: [|s: &CreateTodo| s.title.clone()],
|
||||||
|
publish: |_| CacheKey::Table { table: "todos" },
|
||||||
|
});
|
||||||
|
|
||||||
/// Toggle a todo's completed status.
|
/// Toggle a todo's completed status.
|
||||||
pub async fn toggle_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
|
pub struct ToggleTodo {
|
||||||
sqlx::query("UPDATE todos SET completed = NOT completed WHERE id = ?")
|
pub id: i64,
|
||||||
.bind(id)
|
|
||||||
.execute(db)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
livevue_rs::mutation_exec!(ToggleTodo {
|
||||||
|
sql: "UPDATE todos SET completed = NOT completed WHERE id = ?",
|
||||||
|
params: [|s: &ToggleTodo| s.id],
|
||||||
|
publish: |_| CacheKey::Table { table: "todos" },
|
||||||
|
});
|
||||||
|
|
||||||
/// Delete a todo by id.
|
/// Delete a todo by id.
|
||||||
pub async fn delete_todo(db: &sqlx::SqlitePool, id: i64) -> anyhow::Result<()> {
|
pub struct DeleteTodo {
|
||||||
sqlx::query("DELETE FROM todos WHERE id = ?")
|
pub id: i64,
|
||||||
.bind(id)
|
|
||||||
.execute(db)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
livevue_rs::mutation_exec!(DeleteTodo {
|
||||||
|
sql: "DELETE FROM todos WHERE id = ?",
|
||||||
|
params: [|s: &DeleteTodo| s.id],
|
||||||
|
publish: |_| CacheKey::Table { table: "todos" },
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# livevue.toml — livevue-rs server configuration
|
||||||
|
#
|
||||||
|
# This file is optional. The server starts with compiled-in defaults when it
|
||||||
|
# is absent. When present, it is loaded on startup and then watched for
|
||||||
|
# changes automatically (inotify on Linux, kqueue on macOS, FSEvents on macOS).
|
||||||
|
#
|
||||||
|
# To force a reload without restarting: kill -HUP <pid>
|
||||||
|
#
|
||||||
|
# Note: [server] changes (host / port) require a full restart because the TCP
|
||||||
|
# listener is bound once at startup. All brotli settings apply to new
|
||||||
|
# connections immediately after a reload.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[server]
|
||||||
|
|
||||||
|
# Address to bind. "0.0.0.0" listens on all interfaces.
|
||||||
|
host = "0.0.0.0"
|
||||||
|
|
||||||
|
# TCP port.
|
||||||
|
port = 3000
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Brotli streaming compression
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Brotli is the compression algorithm of choice for Datastar SSE streams.
|
||||||
|
# Unlike gzip (which compresses each HTTP response independently), brotli
|
||||||
|
# maintains a *sliding window* across the entire SSE byte stream. All events
|
||||||
|
# on a connection share a single encoder context, so later events — which
|
||||||
|
# often contain similar HTML structure — get compressed with reference to
|
||||||
|
# everything that was sent earlier. Compression ratios improve progressively
|
||||||
|
# over the lifetime of a long-lived connection.
|
||||||
|
#
|
||||||
|
# The browser sends "Accept-Encoding: br" if it supports brotli. The
|
||||||
|
# middleware checks this and only activates compression for those clients.
|
||||||
|
|
||||||
|
[brotli]
|
||||||
|
|
||||||
|
# Set to false to disable brotli and send SSE events uncompressed.
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Encoder quality: 0 (fastest, worst ratio) … 11 (slowest, best ratio).
|
||||||
|
#
|
||||||
|
# For real-time SSE, 4–6 gives a good latency/ratio tradeoff. Values above 9
|
||||||
|
# incur significant CPU cost with diminishing returns for typical HTML
|
||||||
|
# payloads. Default: 5.
|
||||||
|
quality = 5
|
||||||
|
|
||||||
|
# Sliding window size as log₂ of bytes (brotli's `lgwin` parameter).
|
||||||
|
#
|
||||||
|
# The window is how much previously compressed data the encoder can
|
||||||
|
# reference. Larger ⇒ better compression, more memory per connection.
|
||||||
|
#
|
||||||
|
# lgwin │ Window size
|
||||||
|
# ──────┼────────────
|
||||||
|
# 10 │ 1 KB
|
||||||
|
# 16 │ 64 KB
|
||||||
|
# 20 │ 1 MB
|
||||||
|
# 22 │ 4 MB ← default
|
||||||
|
# 24 │ 16 MB
|
||||||
|
#
|
||||||
|
# For a server with many concurrent SSE connections, consider whether the
|
||||||
|
# memory cost (window_size bytes × number of connections) is acceptable.
|
||||||
|
# Reduce to 20 or 16 under memory pressure.
|
||||||
|
window_size = 22
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! Streaming brotli compression middleware for axum.
|
||||||
|
//!
|
||||||
|
//! # Why a custom encoder instead of `tower-http`'s `CompressionLayer`?
|
||||||
|
//!
|
||||||
|
//! `tower-http` does support brotli (via `async-compression`), but it does not
|
||||||
|
//! expose the `lgwin` (window size) parameter. For Datastar SSE streams the
|
||||||
|
//! window size is the key tuning knob: all SSE events on a connection travel
|
||||||
|
//! through a **single persistent brotli encoder**, so later events benefit from
|
||||||
|
//! the full history of earlier ones. A 4 MB window (`lgwin = 22`) means the
|
||||||
|
//! encoder can reference up to 4 MB of previously transmitted HTML when
|
||||||
|
//! compressing a new fragment — dramatically better ratios than per-chunk gzip.
|
||||||
|
//!
|
||||||
|
//! This module implements a [`BrotliBody`] wrapper and an axum
|
||||||
|
//! [`brotli_compression`] middleware that honour the [`BrotliConfig`] (quality
|
||||||
|
//! + window size) loaded from `livevue.toml`.
|
||||||
|
//!
|
||||||
|
//! # Behaviour
|
||||||
|
//!
|
||||||
|
//! - Only activates when the client sends `Accept-Encoding: br`.
|
||||||
|
//! - Reads config from `SharedConfig` on **each new request**, so a hot-reload
|
||||||
|
//! applies to the next connection without a restart.
|
||||||
|
//! - Calls `encoder.flush()` after every body frame so SSE clients receive
|
||||||
|
//! data promptly (brotli metablock boundary = decodable checkpoint).
|
||||||
|
//! - The encoder's sliding-window context is preserved across flushes, giving
|
||||||
|
//! progressively better compression as the SSE stream grows.
|
||||||
|
|
||||||
|
use crate::config::SharedConfig;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH};
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::Response;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http_body::Body as HttpBody;
|
||||||
|
use pin_project::pin_project;
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DrainWriter — a `Write` impl whose output can be drained incrementally
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A `std::io::Write` target that accumulates bytes in a `Vec<u8>`.
|
||||||
|
///
|
||||||
|
/// After flushing the brotli encoder we call [`DrainWriter::drain`] to collect
|
||||||
|
/// only the bytes written since the last drain. This lets us send one
|
||||||
|
/// compressed chunk per SSE event while keeping the encoder (and its sliding
|
||||||
|
/// window) alive across the entire connection.
|
||||||
|
struct DrainWriter(Vec<u8>);
|
||||||
|
|
||||||
|
impl std::io::Write for DrainWriter {
|
||||||
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.0.extend_from_slice(data);
|
||||||
|
Ok(data.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op: the actual flushing is driven by `CompressorWriter::flush()`.
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrainWriter {
|
||||||
|
fn drain(&mut self) -> Bytes {
|
||||||
|
Bytes::from(self.0.drain(..).collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BrotliBody — streaming Body wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// An `http_body::Body` that brotli-compresses a wrapped [`axum::body::Body`].
|
||||||
|
///
|
||||||
|
/// The encoder is constructed once at stream creation and persists for the
|
||||||
|
/// lifetime of the connection, maintaining its sliding-window context across
|
||||||
|
/// every SSE event. [`flush()`] is called after each data frame to create a
|
||||||
|
/// metablock boundary so the browser can decode partial data immediately.
|
||||||
|
///
|
||||||
|
/// [`flush()`]: std::io::Write::flush
|
||||||
|
#[pin_project]
|
||||||
|
pub struct BrotliBody {
|
||||||
|
#[pin]
|
||||||
|
inner: Body,
|
||||||
|
/// `None` once we have consumed the encoder to finalize the stream.
|
||||||
|
encoder: Option<brotli::CompressorWriter<DrainWriter>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrotliBody {
|
||||||
|
pub fn new(inner: Body, quality: u32, lgwin: u32) -> Self {
|
||||||
|
let params = brotli::enc::BrotliEncoderParams {
|
||||||
|
quality: quality as i32,
|
||||||
|
lgwin: lgwin as i32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Buffer size (4 KiB) is the encoder's internal I/O buffer — smaller
|
||||||
|
// means lower latency per flush, which matters for SSE.
|
||||||
|
let encoder =
|
||||||
|
brotli::CompressorWriter::with_params(DrainWriter(Vec::new()), 4096, ¶ms);
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
encoder: Some(encoder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpBody for BrotliBody {
|
||||||
|
type Data = Bytes;
|
||||||
|
type Error = axum::Error;
|
||||||
|
|
||||||
|
fn poll_frame(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||||
|
let this = self.project();
|
||||||
|
|
||||||
|
match this.inner.poll_frame(cx) {
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Got a data frame — compress it and yield immediately.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
Poll::Ready(Some(Ok(frame))) => {
|
||||||
|
if let Ok(data) = frame.into_data() {
|
||||||
|
match this.encoder {
|
||||||
|
Some(enc) => {
|
||||||
|
if let Err(e) = enc.write_all(&data) {
|
||||||
|
return Poll::Ready(Some(Err(axum::Error::new(e))));
|
||||||
|
}
|
||||||
|
if let Err(e) = enc.flush() {
|
||||||
|
return Poll::Ready(Some(Err(axum::Error::new(e))));
|
||||||
|
}
|
||||||
|
let compressed = enc.get_mut().drain();
|
||||||
|
Poll::Ready(Some(Ok(http_body::Frame::data(compressed))))
|
||||||
|
}
|
||||||
|
None => Poll::Ready(None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Trailers / unknown frame type — pass through as-is.
|
||||||
|
// (Re-construct the original frame; trailers don't need compression.)
|
||||||
|
Poll::Ready(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Inner body is exhausted — finalise the encoder.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
Poll::Ready(None) => {
|
||||||
|
if let Some(enc) = this.encoder.take() {
|
||||||
|
// into_inner() flushes any pending bytes and writes the
|
||||||
|
// final brotli stream terminator, returning the inner
|
||||||
|
// DrainWriter directly (not wrapped in Result).
|
||||||
|
let mut drain = enc.into_inner();
|
||||||
|
let final_bytes = drain.drain();
|
||||||
|
if !final_bytes.is_empty() {
|
||||||
|
return Poll::Ready(Some(Ok(http_body::Frame::data(final_bytes))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Poll::Ready(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Pass-through.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// axum middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Axum middleware that wraps SSE (and any other) responses with streaming
|
||||||
|
/// brotli compression, obeying `Accept-Encoding: br`.
|
||||||
|
///
|
||||||
|
/// Reads [`BrotliConfig`] from [`SharedConfig`] on each new request, so
|
||||||
|
/// quality and window-size changes take effect for new connections immediately
|
||||||
|
/// after a config reload.
|
||||||
|
///
|
||||||
|
/// # Usage
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// use axum::{middleware, Router};
|
||||||
|
/// use livevue_rs::brotli_layer::brotli_compression;
|
||||||
|
///
|
||||||
|
/// let app = Router::new()
|
||||||
|
/// /* ... routes ... */
|
||||||
|
/// .layer(middleware::from_fn_with_state(config.clone(), brotli_compression));
|
||||||
|
/// ```
|
||||||
|
pub async fn brotli_compression(
|
||||||
|
State(config): State<SharedConfig>,
|
||||||
|
request: axum::http::Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// Check whether the client signals brotli support.
|
||||||
|
let accepts_brotli = request
|
||||||
|
.headers()
|
||||||
|
.get(ACCEPT_ENCODING)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|v| v.contains("br"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let response = next.run(request).await;
|
||||||
|
|
||||||
|
if !accepts_brotli {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the brotli config; new values apply to future connections.
|
||||||
|
let (enabled, quality, window_size) = {
|
||||||
|
let cfg = config.read().await;
|
||||||
|
(cfg.brotli.enabled, cfg.brotli.quality, cfg.brotli.window_size)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut parts, body) = response.into_parts();
|
||||||
|
|
||||||
|
// Remove Content-Length — the compressed length will differ.
|
||||||
|
parts.headers.remove(CONTENT_LENGTH);
|
||||||
|
// Advertise brotli encoding.
|
||||||
|
parts
|
||||||
|
.headers
|
||||||
|
.insert(CONTENT_ENCODING, HeaderValue::from_static("br"));
|
||||||
|
|
||||||
|
let brotli_body = BrotliBody::new(body, quality, window_size);
|
||||||
|
Response::from_parts(parts, Body::new(brotli_body))
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Top-level configuration for a livevue-rs server.
|
||||||
|
///
|
||||||
|
/// Loaded from `livevue.toml` (or any TOML file). All sections are optional;
|
||||||
|
/// missing fields fall back to their documented defaults.
|
||||||
|
///
|
||||||
|
/// Reload at runtime by sending `SIGHUP` to the process, or simply save the
|
||||||
|
/// file — the embedded file watcher will pick it up automatically.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct LiveVueConfig {
|
||||||
|
/// HTTP server bind settings.
|
||||||
|
pub server: ServerConfig,
|
||||||
|
/// Brotli streaming compression for SSE responses.
|
||||||
|
pub brotli: BrotliConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LiveVueConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
server: ServerConfig::default(),
|
||||||
|
brotli: BrotliConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP server bind settings.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Address to bind on. Default: `"0.0.0.0"`.
|
||||||
|
pub host: String,
|
||||||
|
/// TCP port to listen on. Default: `3000`.
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "0.0.0.0".into(),
|
||||||
|
port: 3000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
/// Returns `"<host>:<port>"` suitable for `TcpListener::bind`.
|
||||||
|
pub fn bind_addr(&self) -> String {
|
||||||
|
format!("{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brotli streaming compression settings.
|
||||||
|
///
|
||||||
|
/// # Why brotli for SSE?
|
||||||
|
///
|
||||||
|
/// Brotli is stateful: its encoder keeps a sliding window of previously seen
|
||||||
|
/// data. Because SSE events for a single connection are a continuous byte
|
||||||
|
/// stream (not independent requests), later events benefit from the context
|
||||||
|
/// of all earlier ones. HTML fragments are highly repetitive, so compression
|
||||||
|
/// ratios improve dramatically over the lifetime of a connection — exactly
|
||||||
|
/// the opposite of per-request gzip.
|
||||||
|
///
|
||||||
|
/// The `window_size` parameter (`lgwin`) controls how much history the encoder
|
||||||
|
/// remembers. Larger values give better compression at the cost of more memory
|
||||||
|
/// per connection (2^lgwin bytes).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct BrotliConfig {
|
||||||
|
/// Enable brotli compression on SSE responses. Default: `true`.
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Encoder quality, 0 (fastest / worst ratio) to 11 (slowest / best).
|
||||||
|
///
|
||||||
|
/// Quality 4–6 is a good balance for real-time streaming. Default: `5`.
|
||||||
|
pub quality: u32,
|
||||||
|
|
||||||
|
/// Sliding window size as log₂ of bytes (`lgwin`), valid range 10–24.
|
||||||
|
///
|
||||||
|
/// | lgwin | Window |
|
||||||
|
/// |-------|----------|
|
||||||
|
/// | 10 | 1 KB |
|
||||||
|
/// | 16 | 64 KB |
|
||||||
|
/// | 20 | 1 MB |
|
||||||
|
/// | 22 | 4 MB |
|
||||||
|
/// | 24 | 16 MB |
|
||||||
|
///
|
||||||
|
/// Larger windows give better compression ratios for long-lived SSE
|
||||||
|
/// streams but cost more memory per connection. Default: `22` (4 MB).
|
||||||
|
pub window_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BrotliConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
quality: 5,
|
||||||
|
window_size: 22,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared handle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Thread-safe, hot-reloadable config handle.
|
||||||
|
pub type SharedConfig = Arc<RwLock<LiveVueConfig>>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse a `LiveVueConfig` from a TOML file.
|
||||||
|
///
|
||||||
|
/// Returns the default config (with a log message) if the file does not exist,
|
||||||
|
/// so the server starts without requiring a config file.
|
||||||
|
pub fn load_config(path: &Path) -> anyhow::Result<LiveVueConfig> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => {
|
||||||
|
let cfg: LiveVueConfig = toml::from_str(&content)?;
|
||||||
|
tracing::info!("loaded config from {:?}", path);
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
tracing::info!(
|
||||||
|
"config file {:?} not found, using defaults",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
Ok(LiveVueConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config and start background reload tasks.
|
||||||
|
///
|
||||||
|
/// Returns a `SharedConfig` (`Arc<RwLock<LiveVueConfig>>`). The config is
|
||||||
|
/// automatically reloaded whenever:
|
||||||
|
///
|
||||||
|
/// - The file is modified or replaced (via inotify / kqueue / FSEvents).
|
||||||
|
/// - The process receives `SIGHUP` (Unix only).
|
||||||
|
///
|
||||||
|
/// Changes to `server.port` / `server.host` require a restart to take effect
|
||||||
|
/// (the TCP listener is bound once at startup). All other settings — brotli
|
||||||
|
/// quality, window size, enabled flag — apply to **new connections** as soon
|
||||||
|
/// as the config is reloaded.
|
||||||
|
pub fn load_and_watch_config(path: impl AsRef<Path>) -> anyhow::Result<SharedConfig> {
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
let config = Arc::new(RwLock::new(load_config(&path)?));
|
||||||
|
|
||||||
|
spawn_file_watcher(path.clone(), config.clone());
|
||||||
|
spawn_sighup_reloader(path, config.clone());
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Background reload tasks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn spawn_file_watcher(path: PathBuf, config: SharedConfig) {
|
||||||
|
use notify::{RecursiveMode, Watcher};
|
||||||
|
|
||||||
|
let (tx, mut rx) =
|
||||||
|
tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(16);
|
||||||
|
|
||||||
|
let mut watcher = match notify::recommended_watcher(move |res| {
|
||||||
|
// blocking_send is fine here: we're inside the notify callback thread
|
||||||
|
let _ = tx.blocking_send(res);
|
||||||
|
}) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("config file watcher could not be created: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the file doesn't exist yet, watch the parent directory so we catch
|
||||||
|
// it being created.
|
||||||
|
let watch_target = if path.exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
path.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = watcher.watch(&watch_target, RecursiveMode::NonRecursive) {
|
||||||
|
tracing::warn!("could not watch {:?}: {e}", watch_target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Keep the watcher alive for the duration of this task.
|
||||||
|
let _watcher = watcher;
|
||||||
|
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match event {
|
||||||
|
Ok(evt) => {
|
||||||
|
let relevant = evt.paths.iter().any(|p| p == &path);
|
||||||
|
let modified =
|
||||||
|
evt.kind.is_modify() || evt.kind.is_create();
|
||||||
|
if relevant && modified {
|
||||||
|
reload_config(&path, &config).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("config watcher error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_sighup_reloader(path: PathBuf, config: SharedConfig) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
|
|
||||||
|
let mut sighup = match signal(SignalKind::hangup()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("could not register SIGHUP handler: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sighup.recv().await;
|
||||||
|
tracing::info!(
|
||||||
|
"SIGHUP received — reloading config from {:?}",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
reload_config(&path, &config).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_config(path: &Path, config: &SharedConfig) {
|
||||||
|
match load_config(path) {
|
||||||
|
Ok(new_cfg) => {
|
||||||
|
*config.write().await = new_cfg;
|
||||||
|
tracing::info!("config reloaded from {:?}", path);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("config reload failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-25
@@ -6,23 +6,26 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RenderTrigger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Signal sent to a connection task to request a rerender.
|
||||||
|
pub enum RenderTrigger {
|
||||||
|
Invalidated,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Connection state
|
// Connection state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Per-connection state stored in the manager.
|
/// Per-connection state stored in the manager.
|
||||||
pub struct ConnectionState {
|
pub struct ConnectionState {
|
||||||
/// Sender half of the per-connection SSE channel.
|
/// Sends render triggers to the connection task.
|
||||||
///
|
pub trigger_tx: mpsc::Sender<RenderTrigger>,
|
||||||
/// The fanout task sends fully-constructed axum `Event`s here; the SSE
|
/// Sends new SSE pipe senders to the connection task on reconnect.
|
||||||
/// handler streams them directly to the client without re-wrapping.
|
pub pipe_tx: mpsc::Sender<mpsc::Sender<Event>>,
|
||||||
pub sse_sender: mpsc::Sender<Event>,
|
/// Last known signals JSON, updated by action handlers.
|
||||||
|
|
||||||
/// The full signal JSON from the most recent render for this connection.
|
|
||||||
///
|
|
||||||
/// Stored so the fanout task can reconstruct a `SignalStore` and build a
|
|
||||||
/// `RenderContext` for server-initiated rerenders, without a client
|
|
||||||
/// request carrying the signals.
|
|
||||||
pub last_signals_json: Option<String>,
|
pub last_signals_json: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +38,8 @@ pub struct ConnectionState {
|
|||||||
/// Accessed from:
|
/// Accessed from:
|
||||||
/// - `GET /sse` handler (insert)
|
/// - `GET /sse` handler (insert)
|
||||||
/// - `POST /action` handler (update signals)
|
/// - `POST /action` handler (update signals)
|
||||||
/// - SSE drop cleanup (remove)
|
/// - Fanout task (get trigger sender for rerender)
|
||||||
/// - Fanout task (read sender + signals for rerender)
|
/// - Connection task (cleanup on grace period expiry)
|
||||||
pub struct ConnectionManager {
|
pub struct ConnectionManager {
|
||||||
connections: DashMap<Uuid, ConnectionState>,
|
connections: DashMap<Uuid, ConnectionState>,
|
||||||
}
|
}
|
||||||
@@ -48,26 +51,29 @@ impl ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a new connection with its SSE sender.
|
/// Register a new connection with its trigger and pipe senders.
|
||||||
pub fn insert(&self, id: Uuid, sender: mpsc::Sender<Event>) {
|
pub fn insert(&self, id: Uuid, trigger_tx: mpsc::Sender<RenderTrigger>, pipe_tx: mpsc::Sender<mpsc::Sender<Event>>) {
|
||||||
self.connections.insert(
|
self.connections.insert(
|
||||||
id,
|
id,
|
||||||
ConnectionState {
|
ConnectionState {
|
||||||
sse_sender: sender,
|
trigger_tx,
|
||||||
|
pipe_tx,
|
||||||
last_signals_json: None,
|
last_signals_json: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the SSE sender for a connection.
|
/// Get the render trigger sender for a connection.
|
||||||
pub fn get_sender(&self, id: &Uuid) -> Option<mpsc::Sender<Event>> {
|
pub fn get_trigger_sender(&self, id: &Uuid) -> Option<mpsc::Sender<RenderTrigger>> {
|
||||||
self.connections.get(id).map(|c| c.sse_sender.clone())
|
self.connections.get(id).map(|c| c.trigger_tx.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the pipe sender for a connection (used to hand off a new SSE pipe on reconnect).
|
||||||
|
pub fn get_pipe_sender(&self, id: &Uuid) -> Option<mpsc::Sender<mpsc::Sender<Event>>> {
|
||||||
|
self.connections.get(id).map(|c| c.pipe_tx.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the last signals JSON for a connection.
|
/// Get the last signals JSON for a connection.
|
||||||
///
|
|
||||||
/// Called by the fanout task to reconstruct signal state for a
|
|
||||||
/// server-initiated rerender.
|
|
||||||
pub fn get_signals(&self, id: &Uuid) -> Option<String> {
|
pub fn get_signals(&self, id: &Uuid) -> Option<String> {
|
||||||
self.connections
|
self.connections
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -75,15 +81,13 @@ impl ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update the stored signals for a connection.
|
/// Update the stored signals for a connection.
|
||||||
///
|
|
||||||
/// Called by `POST /action` after ingesting the client's signal blob.
|
|
||||||
pub fn update_signals(&self, id: &Uuid, signals_json: String) {
|
pub fn update_signals(&self, id: &Uuid, signals_json: String) {
|
||||||
if let Some(mut conn) = self.connections.get_mut(id) {
|
if let Some(mut conn) = self.connections.get_mut(id) {
|
||||||
conn.last_signals_json = Some(signals_json);
|
conn.last_signals_json = Some(signals_json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a connection (called on SSE pipe drop).
|
/// Remove a connection.
|
||||||
pub fn remove(&self, id: &Uuid) -> Option<(Uuid, ConnectionState)> {
|
pub fn remove(&self, id: &Uuid) -> Option<(Uuid, ConnectionState)> {
|
||||||
self.connections.remove(id)
|
self.connections.remove(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,57 @@ impl RenderContext {
|
|||||||
self.store.kv.get(&key)
|
self.store.kv.get(&key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// PostgreSQL queries
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Execute a PostgreSQL query, automatically registering its subscription key.
|
||||||
|
///
|
||||||
|
/// This is the pg equivalent of `cx.run()`. The query's `cache_key()` is
|
||||||
|
/// pushed into the subscription set so the connection rerenders whenever
|
||||||
|
/// the WAL listener publishes that key.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let messages = cx.run_pg(&rs.pg, ListMessages).await?;
|
||||||
|
/// // No manual cx.subscribe() needed.
|
||||||
|
/// ```
|
||||||
|
#[cfg(feature = "pg_replication")]
|
||||||
|
pub async fn run_pg<Q: crate::pg_query::PgQuery>(
|
||||||
|
&mut self,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
query: Q,
|
||||||
|
) -> anyhow::Result<Q::Output> {
|
||||||
|
let key = query.cache_key();
|
||||||
|
if key != CacheKey::None {
|
||||||
|
self.subscription_keys.push(key);
|
||||||
|
}
|
||||||
|
query.execute(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Manual subscriptions
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Manually register a subscription key without executing a query.
|
||||||
|
///
|
||||||
|
/// Use this to subscribe to externally-sourced invalidation events that
|
||||||
|
/// don't go through `cx.run()` — for example, events published by the
|
||||||
|
/// PostgreSQL replication module:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// use livevue_rs::pg_replication::pg_table_key;
|
||||||
|
///
|
||||||
|
/// cx.subscribe(pg_table_key("public", "posts"));
|
||||||
|
/// let posts = sqlx::query_as::<_, Post>("SELECT id, body FROM posts")
|
||||||
|
/// .fetch_all(&pg_pool)
|
||||||
|
/// .await?;
|
||||||
|
/// ```
|
||||||
|
pub fn subscribe(&mut self, key: CacheKey) {
|
||||||
|
if key != CacheKey::None {
|
||||||
|
self.subscription_keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Post-render access
|
// Post-render access
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
+23
-1
@@ -5,11 +5,33 @@ pub mod context;
|
|||||||
pub mod connection;
|
pub mod connection;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod config;
|
||||||
|
pub mod brotli_layer;
|
||||||
|
|
||||||
|
/// PostgreSQL logical replication module.
|
||||||
|
///
|
||||||
|
/// Enabled via the `pg_replication` Cargo feature:
|
||||||
|
/// ```toml
|
||||||
|
/// livevue-rs = { features = ["pg_replication"] }
|
||||||
|
/// ```
|
||||||
|
#[cfg(feature = "pg_replication")]
|
||||||
|
pub mod pg_replication;
|
||||||
|
|
||||||
|
/// PostgreSQL query trait and macros (`pg_query_one!`, `pg_query_all!`).
|
||||||
|
///
|
||||||
|
/// Use `cx.run_pg(pool, query)` in render functions to execute a `PgQuery`
|
||||||
|
/// and auto-subscribe to its cache key, exactly like `cx.run()` for SQLite.
|
||||||
|
#[cfg(feature = "pg_replication")]
|
||||||
|
pub mod pg_query;
|
||||||
|
|
||||||
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
|
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
|
||||||
pub use query::{CacheKey, Query};
|
pub use query::{CacheKey, Query};
|
||||||
pub use store::{Store, KVStore};
|
pub use store::{Store, KVStore};
|
||||||
|
#[cfg(feature = "pg_replication")]
|
||||||
|
pub use pg_query::PgQuery;
|
||||||
pub use context::{RenderContext, ComponentTree, ComponentNode};
|
pub use context::{RenderContext, ComponentTree, ComponentNode};
|
||||||
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry};
|
pub use connection::{ConnectionManager, ConnectionState, SubscriptionRegistry, RenderTrigger};
|
||||||
pub use sse::{format_patch_elements, format_patch_signals};
|
pub use sse::{format_patch_elements, format_patch_signals};
|
||||||
pub use server::{AppState, SharedState, RenderFn, sse_handler, spawn_fanout, ingest_signals, ActionParams, SseParams};
|
pub use server::{AppState, SharedState, RenderFn, sse_handler, spawn_fanout, ingest_signals, ActionParams, SseParams};
|
||||||
|
pub use config::{LiveVueConfig, ServerConfig, BrotliConfig, SharedConfig, load_config, load_and_watch_config};
|
||||||
|
pub use brotli_layer::brotli_compression;
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use crate::query::CacheKey;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PgQuery trait
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The data-access abstraction for PostgreSQL-backed queries.
|
||||||
|
///
|
||||||
|
/// Mirrors the SQLite [`Query`](crate::Query) trait but binds to
|
||||||
|
/// `sqlx::PgPool`. Use `cx.run_pg(pool, query)` in render functions — it
|
||||||
|
/// calls `cache_key()` to register the subscription automatically, exactly
|
||||||
|
/// like `cx.run()` does for SQLite queries.
|
||||||
|
///
|
||||||
|
/// Pg mutations do **not** implement this trait and do **not** call
|
||||||
|
/// `publish_key()` — the WAL replication listener fires invalidation events
|
||||||
|
/// automatically for any write from any source.
|
||||||
|
///
|
||||||
|
/// The `pg_query_one!` / `pg_query_all!` macros generate impls of this trait.
|
||||||
|
pub trait PgQuery: Send + Sync {
|
||||||
|
type Output: Send;
|
||||||
|
|
||||||
|
fn cache_key(&self) -> CacheKey;
|
||||||
|
|
||||||
|
fn execute<'a>(
|
||||||
|
&'a self,
|
||||||
|
db: &'a sqlx::PgPool,
|
||||||
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<Self::Output>> + Send + 'a>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pg_query_one! — fetch exactly one row from PostgreSQL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generate a `PgQuery` impl that fetches a single row from PostgreSQL.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// pub struct GetMessage { pub id: i64 }
|
||||||
|
///
|
||||||
|
/// pg_query_one!(GetMessage {
|
||||||
|
/// sql: "SELECT id, body FROM messages WHERE id = $1",
|
||||||
|
/// params: [|s: &GetMessage| s.id],
|
||||||
|
/// output: Message,
|
||||||
|
/// cache: |s: &GetMessage| pg_table_key("public", "messages"),
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
#[cfg(feature = "pg_replication")]
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! pg_query_one {
|
||||||
|
($name:ident {
|
||||||
|
sql: $sql:expr,
|
||||||
|
params: [$($param:expr),* $(,)?],
|
||||||
|
output: $output:ty,
|
||||||
|
cache: $cache:expr $(,)?
|
||||||
|
}) => {
|
||||||
|
impl $crate::PgQuery for $name {
|
||||||
|
type Output = $output;
|
||||||
|
|
||||||
|
fn cache_key(&self) -> $crate::CacheKey {
|
||||||
|
($cache)(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute<'a>(
|
||||||
|
&'a self,
|
||||||
|
db: &'a sqlx::PgPool,
|
||||||
|
) -> ::std::pin::Pin<
|
||||||
|
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
|
||||||
|
> {
|
||||||
|
let zelf = self;
|
||||||
|
Box::pin(async move {
|
||||||
|
let row = sqlx::query_as::<_, $output>($sql)
|
||||||
|
$(.bind(($param)(zelf)))*
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pg_query_all! — fetch zero or more rows from PostgreSQL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generate a `PgQuery` impl that fetches multiple rows from PostgreSQL.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// pub struct ListMessages;
|
||||||
|
///
|
||||||
|
/// pg_query_all!(ListMessages {
|
||||||
|
/// sql: "SELECT id, body FROM messages ORDER BY id DESC",
|
||||||
|
/// params: [],
|
||||||
|
/// output: Message,
|
||||||
|
/// cache: |_| pg_table_key("public", "messages"),
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
#[cfg(feature = "pg_replication")]
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! pg_query_all {
|
||||||
|
($name:ident {
|
||||||
|
sql: $sql:expr,
|
||||||
|
params: [$($param:expr),* $(,)?],
|
||||||
|
output: $output:ty,
|
||||||
|
cache: $cache:expr $(,)?
|
||||||
|
}) => {
|
||||||
|
impl $crate::PgQuery for $name {
|
||||||
|
type Output = Vec<$output>;
|
||||||
|
|
||||||
|
fn cache_key(&self) -> $crate::CacheKey {
|
||||||
|
($cache)(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute<'a>(
|
||||||
|
&'a self,
|
||||||
|
db: &'a sqlx::PgPool,
|
||||||
|
) -> ::std::pin::Pin<
|
||||||
|
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
|
||||||
|
> {
|
||||||
|
let zelf = self;
|
||||||
|
Box::pin(async move {
|
||||||
|
let rows = sqlx::query_as::<_, $output>($sql)
|
||||||
|
$(.bind(($param)(zelf)))*
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
use super::wal_parser::{ColumnValue, LogicalMessage, MessageParser, RelationInfo, TupleData};
|
||||||
|
use crate::query::CacheKey;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-transaction buffer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Accumulates the set of invalidation keys for one PostgreSQL transaction.
|
||||||
|
///
|
||||||
|
/// We use a `HashSet` for table-level keys so that a transaction inserting
|
||||||
|
/// 10 000 rows into one table still produces exactly **one**
|
||||||
|
/// `CacheKey::Channel { key: "pg:public.todos" }` event. Row-level keys are
|
||||||
|
/// collected separately for callers that need fine-grained precision.
|
||||||
|
struct TxBuffer {
|
||||||
|
xid: u32,
|
||||||
|
/// Deduplicated `(schema, table)` pairs touched in this transaction.
|
||||||
|
table_keys: HashSet<(String, String)>,
|
||||||
|
/// `(schema, table, id)` tuples for changes with a detectable integer PK.
|
||||||
|
row_keys: Vec<(String, String, i64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TxBuffer {
|
||||||
|
fn new(xid: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
xid,
|
||||||
|
table_keys: HashSet::new(),
|
||||||
|
row_keys: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_table(&mut self, schema: &str, table: &str) {
|
||||||
|
self.table_keys.insert((schema.to_owned(), table.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_row(&mut self, schema: &str, table: &str, id: i64) {
|
||||||
|
self.row_keys.push((schema.to_owned(), table.to_owned(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WalEmitter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Processes pgoutput logical replication messages and publishes
|
||||||
|
/// [`CacheKey`] invalidation events to the framework's broadcast channel.
|
||||||
|
///
|
||||||
|
/// ## Transaction atomicity
|
||||||
|
///
|
||||||
|
/// Changes are buffered per XID. On `COMMIT`, all invalidation events for
|
||||||
|
/// that transaction are published in a tight loop so the fanout task receives
|
||||||
|
/// them as a burst. The transaction's WAL data is **never** partially visible:
|
||||||
|
/// the emitter waits for the `Commit` message before publishing anything.
|
||||||
|
///
|
||||||
|
/// ## Event granularity
|
||||||
|
///
|
||||||
|
/// For every transaction two kinds of events are emitted:
|
||||||
|
///
|
||||||
|
/// * **Table-level** (`"pg:{schema}.{table}"`) — deduplicated; at most one per
|
||||||
|
/// distinct table touched. Components that track whole-table queries should
|
||||||
|
/// subscribe with [`pg_table_key`](super::pg_table_key).
|
||||||
|
///
|
||||||
|
/// * **Row-level** (`"pg:{schema}.{table}:{id}"`) — emitted when the change
|
||||||
|
/// has a single-column integer replica-identity key. Components that track
|
||||||
|
/// individual rows can subscribe with [`pg_row_key`](super::pg_row_key).
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let emitter = WalEmitter::new(store.events.clone());
|
||||||
|
/// // Inside the replication loop:
|
||||||
|
/// emitter.process_raw(&wal_bytes).await?;
|
||||||
|
/// ```
|
||||||
|
pub struct WalEmitter {
|
||||||
|
parser: MessageParser,
|
||||||
|
events: broadcast::Sender<CacheKey>,
|
||||||
|
/// XID of the currently open transaction (pgoutput v1 is sequential).
|
||||||
|
current_xid: Option<u32>,
|
||||||
|
/// Buffer for the current transaction; `None` when outside a transaction.
|
||||||
|
buffer: Option<TxBuffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WalEmitter {
|
||||||
|
/// Create a new emitter that publishes to `events`.
|
||||||
|
///
|
||||||
|
/// Pass `store.events.clone()` so the emitter integrates directly with the
|
||||||
|
/// framework's fanout task.
|
||||||
|
pub fn new(events: broadcast::Sender<CacheKey>) -> Self {
|
||||||
|
Self {
|
||||||
|
parser: MessageParser::new(),
|
||||||
|
events,
|
||||||
|
current_xid: None,
|
||||||
|
buffer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public entry point
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse and process all pgoutput messages in a raw XLogData payload.
|
||||||
|
///
|
||||||
|
/// Call this for every `XLogData` message received from the replication
|
||||||
|
/// stream. The method is `&mut self` because the parser maintains relation
|
||||||
|
/// metadata across calls.
|
||||||
|
pub async fn process_raw(&mut self, data: &[u8]) -> Result<(), String> {
|
||||||
|
let mut buf = BytesMut::from(data);
|
||||||
|
while !buf.is_empty() {
|
||||||
|
match self.parser.parse_message(&mut buf)? {
|
||||||
|
Some(msg) => self.process_message(msg)?,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Message dispatch
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn process_message(&mut self, msg: LogicalMessage) -> Result<(), String> {
|
||||||
|
match msg {
|
||||||
|
LogicalMessage::Begin { xid, .. } => {
|
||||||
|
self.current_xid = Some(xid);
|
||||||
|
self.buffer = Some(TxBuffer::new(xid));
|
||||||
|
}
|
||||||
|
|
||||||
|
LogicalMessage::Commit { .. } => {
|
||||||
|
if let Some(buf) = self.buffer.take() {
|
||||||
|
self.emit(buf);
|
||||||
|
}
|
||||||
|
self.current_xid = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogicalMessage::Insert { rel_oid, new_tuple } => {
|
||||||
|
self.record_change(rel_oid, &new_tuple);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogicalMessage::Update { rel_oid, new_tuple, key_tuple, .. } => {
|
||||||
|
// Prefer the key_tuple for row-level id extraction if available
|
||||||
|
// (it holds the *old* key when the PK is being changed).
|
||||||
|
let id_source = key_tuple.as_ref().unwrap_or(&new_tuple);
|
||||||
|
let id = self.extract_pk_id(rel_oid, id_source);
|
||||||
|
self.record_change_with_id(rel_oid, &new_tuple, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogicalMessage::Delete { rel_oid, key_tuple, old_tuple } => {
|
||||||
|
let tuple = key_tuple.as_ref().or(old_tuple.as_ref());
|
||||||
|
let id = tuple.and_then(|t| self.extract_pk_id(rel_oid, t));
|
||||||
|
// We still need the relation name; use an empty TupleData as placeholder.
|
||||||
|
if let Some(info) = self.relation_info(rel_oid) {
|
||||||
|
let schema = info.0;
|
||||||
|
let table = info.1;
|
||||||
|
if let Some(buf) = &mut self.buffer {
|
||||||
|
buf.record_table(&schema, &table);
|
||||||
|
if let Some(id) = id {
|
||||||
|
buf.record_row(&schema, &table, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogicalMessage::Truncate { rel_oids, .. } => {
|
||||||
|
let pairs: Vec<(String, String)> = rel_oids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&oid| self.relation_info(oid))
|
||||||
|
.collect();
|
||||||
|
if let Some(buf) = &mut self.buffer {
|
||||||
|
for (schema, table) in pairs {
|
||||||
|
buf.record_table(&schema, &table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relation messages are consumed by the parser (internal state).
|
||||||
|
// Type and Message are not relevant for cache invalidation.
|
||||||
|
LogicalMessage::Relation(_)
|
||||||
|
| LogicalMessage::Type { .. }
|
||||||
|
| LogicalMessage::Message { .. } => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Record a table + optional row key for a DML change.
|
||||||
|
fn record_change(&mut self, rel_oid: u32, tuple: &TupleData) {
|
||||||
|
let id = self.extract_pk_id(rel_oid, tuple);
|
||||||
|
self.record_change_with_id(rel_oid, tuple, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_change_with_id(&mut self, rel_oid: u32, _tuple: &TupleData, id: Option<i64>) {
|
||||||
|
if let Some(info) = self.relation_info(rel_oid) {
|
||||||
|
let (schema, table) = info;
|
||||||
|
if let Some(buf) = &mut self.buffer {
|
||||||
|
buf.record_table(&schema, &table);
|
||||||
|
if let Some(id) = id {
|
||||||
|
buf.record_row(&schema, &table, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a relation OID to `(schema, table)`. Clones so the parser
|
||||||
|
/// borrow is released before we mutably borrow `self.buffer`.
|
||||||
|
fn relation_info(&self, rel_oid: u32) -> Option<(String, String)> {
|
||||||
|
self.parser
|
||||||
|
.get_relation(rel_oid)
|
||||||
|
.map(|r| (r.namespace.clone(), r.name.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to extract a single-column integer primary key from `tuple`.
|
||||||
|
///
|
||||||
|
/// Returns `None` for composite keys, non-integer keys, or when the
|
||||||
|
/// relation has no replica-identity columns flagged (`flags & 1`).
|
||||||
|
fn extract_pk_id(&self, rel_oid: u32, tuple: &TupleData) -> Option<i64> {
|
||||||
|
let rel = self.parser.get_relation(rel_oid)?;
|
||||||
|
extract_single_int_pk(rel, tuple)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Emission
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Publish all invalidation events for a committed transaction.
|
||||||
|
fn emit(&self, buf: TxBuffer) {
|
||||||
|
tracing::debug!(
|
||||||
|
xid = buf.xid,
|
||||||
|
tables = buf.table_keys.len(),
|
||||||
|
rows = buf.row_keys.len(),
|
||||||
|
"pg_replication: emitting cache invalidation"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table-level events (deduplicated by HashSet).
|
||||||
|
for (schema, table) in &buf.table_keys {
|
||||||
|
self.events
|
||||||
|
.send(CacheKey::Channel {
|
||||||
|
key: format!("pg:{schema}.{table}"),
|
||||||
|
})
|
||||||
|
.ok(); // ok() = ignore when no receivers are subscribed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row-level events.
|
||||||
|
for (schema, table, id) in &buf.row_keys {
|
||||||
|
self.events
|
||||||
|
.send(CacheKey::Channel {
|
||||||
|
key: format!("pg:{schema}.{table}:{id}"),
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row-level PK extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Extract an integer PK only when the relation has exactly one column
|
||||||
|
/// flagged as part of the replica identity key and its value is parseable
|
||||||
|
/// as `i64`.
|
||||||
|
fn extract_single_int_pk(rel: &RelationInfo, tuple: &TupleData) -> Option<i64> {
|
||||||
|
let key_cols: Vec<usize> = rel
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, col)| col.flags & 1 != 0)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if key_cols.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = key_cols[0];
|
||||||
|
match tuple.values.get(idx)? {
|
||||||
|
ColumnValue::Text(s) => s.parse::<i64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_receivers_does_not_panic() {
|
||||||
|
let (tx, _rx) = broadcast::channel(16);
|
||||||
|
drop(_rx); // no receivers
|
||||||
|
let mut emitter = WalEmitter::new(tx);
|
||||||
|
// Publishing to a channel with no receivers should silently succeed.
|
||||||
|
emitter
|
||||||
|
.emit(TxBuffer { xid: 1, table_keys: Default::default(), row_keys: vec![] });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outside_transaction_dml_is_ignored() {
|
||||||
|
let (tx, _rx) = broadcast::channel(16);
|
||||||
|
let mut emitter = WalEmitter::new(tx);
|
||||||
|
// No Begin sent → buffer is None → Insert is silently dropped.
|
||||||
|
assert!(emitter.buffer.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! PostgreSQL logical replication module for livevue-rs.
|
||||||
|
//!
|
||||||
|
//! Connects to a PostgreSQL `pgoutput` logical replication stream, buffers
|
||||||
|
//! changes per transaction, and publishes [`CacheKey`] invalidation events
|
||||||
|
//! to the framework's broadcast channel on every committed transaction.
|
||||||
|
//!
|
||||||
|
//! # Quick start
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use livevue_rs::pg_replication::{PgReplicationConfig, PgReplicationListener, pg_table_key};
|
||||||
|
//!
|
||||||
|
//! let config = PgReplicationConfig {
|
||||||
|
//! host: "localhost".into(),
|
||||||
|
//! port: 5432,
|
||||||
|
//! user: "postgres".into(),
|
||||||
|
//! password: "postgres".into(),
|
||||||
|
//! database: "mydb".into(),
|
||||||
|
//! slot_name: "livevue_slot".into(),
|
||||||
|
//! publication_name: "livevue_pub".into(),
|
||||||
|
//! start_lsn: None,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! let listener = PgReplicationListener::new(config, store.clone());
|
||||||
|
//! tokio::spawn(async move {
|
||||||
|
//! if let Err(e) = listener.start().await {
|
||||||
|
//! tracing::error!("replication listener exited: {e}");
|
||||||
|
//! }
|
||||||
|
//! });
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # CacheKey convention
|
||||||
|
//!
|
||||||
|
//! | Granularity | Channel key format | Example |
|
||||||
|
//! |-------------|------------------------------|-----------------------------|
|
||||||
|
//! | Table-level | `"pg:{schema}.{table}"` | `"pg:public.posts"` |
|
||||||
|
//! | Row-level | `"pg:{schema}.{table}:{id}"` | `"pg:public.posts:42"` |
|
||||||
|
//!
|
||||||
|
//! Use [`pg_table_key`] / [`pg_row_key`] in `cx.subscribe()` in render functions.
|
||||||
|
//!
|
||||||
|
//! # Feature flag
|
||||||
|
//!
|
||||||
|
//! Enable with `features = ["pg_replication"]` in your `Cargo.toml`.
|
||||||
|
|
||||||
|
mod wal_parser;
|
||||||
|
mod emitter;
|
||||||
|
mod proto;
|
||||||
|
|
||||||
|
pub use wal_parser::{
|
||||||
|
ColumnInfo, ColumnValue, LogicalMessage, MessageParser, RelationInfo, TupleData,
|
||||||
|
};
|
||||||
|
pub use emitter::WalEmitter;
|
||||||
|
|
||||||
|
use crate::query::CacheKey;
|
||||||
|
use crate::store::Store;
|
||||||
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Key helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// [`CacheKey`] matching table-level events from the PG replication module.
|
||||||
|
///
|
||||||
|
/// Call `cx.subscribe(pg_table_key("public", "messages"))` in a render
|
||||||
|
/// function to re-render whenever any row in `messages` changes.
|
||||||
|
pub fn pg_table_key(schema: &str, table: &str) -> CacheKey {
|
||||||
|
CacheKey::Channel { key: format!("pg:{schema}.{table}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`CacheKey`] matching row-level events from the PG replication module.
|
||||||
|
///
|
||||||
|
/// Row-level events are only emitted when the row has a single-column
|
||||||
|
/// integer replica-identity key.
|
||||||
|
pub fn pg_row_key(schema: &str, table: &str, id: i64) -> CacheKey {
|
||||||
|
CacheKey::Channel { key: format!("pg:{schema}.{table}:{id}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Configuration for a [`PgReplicationListener`].
|
||||||
|
pub struct PgReplicationConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub user: String,
|
||||||
|
pub password: String,
|
||||||
|
pub database: String,
|
||||||
|
|
||||||
|
/// Logical replication slot name. Auto-created with the `pgoutput`
|
||||||
|
/// plugin if it does not already exist.
|
||||||
|
pub slot_name: String,
|
||||||
|
|
||||||
|
/// Publication name. Must be created in PostgreSQL first:
|
||||||
|
/// ```sql
|
||||||
|
/// CREATE PUBLICATION livevue_pub FOR ALL TABLES;
|
||||||
|
/// ```
|
||||||
|
pub publication_name: String,
|
||||||
|
|
||||||
|
/// LSN to start from. `None` = start from the slot's confirmed LSN
|
||||||
|
/// (only future changes).
|
||||||
|
pub start_lsn: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Listener
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Streams PostgreSQL WAL changes and publishes [`CacheKey`] events to the
|
||||||
|
/// framework's broadcast channel.
|
||||||
|
///
|
||||||
|
/// Spawn as a long-running background task after calling `AppState::new` and
|
||||||
|
/// `spawn_fanout`. Wrap in a retry loop for automatic reconnection.
|
||||||
|
pub struct PgReplicationListener {
|
||||||
|
config: PgReplicationConfig,
|
||||||
|
store: Arc<Store>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PgReplicationListener {
|
||||||
|
pub fn new(config: PgReplicationConfig, store: Arc<Store>) -> Self {
|
||||||
|
Self { config, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect and stream WAL until an error or stream close.
|
||||||
|
pub async fn start(self) -> anyhow::Result<()> {
|
||||||
|
use anyhow::Context as _;
|
||||||
|
|
||||||
|
// --- connect in replication mode ---
|
||||||
|
let mut conn = proto::RawPgConn::connect(
|
||||||
|
&self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
&self.config.user,
|
||||||
|
&self.config.password,
|
||||||
|
&self.config.database,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("connect to PostgreSQL (replication mode)")?;
|
||||||
|
|
||||||
|
// --- ensure replication slot exists ---
|
||||||
|
let slot = &self.config.slot_name;
|
||||||
|
let create_sql = format!(
|
||||||
|
"CREATE_REPLICATION_SLOT {slot} LOGICAL pgoutput NOEXPORT_SNAPSHOT"
|
||||||
|
);
|
||||||
|
match conn.simple_query(&create_sql).await {
|
||||||
|
Ok(_) => tracing::info!(slot, "pg_replication: created replication slot"),
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("42710") || msg.contains("already exists") {
|
||||||
|
tracing::debug!(slot, "pg_replication: slot already exists");
|
||||||
|
} else {
|
||||||
|
return Err(e).context("create replication slot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- start logical replication ---
|
||||||
|
let lsn = self.config.start_lsn.unwrap_or(0);
|
||||||
|
let lsn_str = format!("{:X}/{:X}", (lsn >> 32) as u32, lsn as u32);
|
||||||
|
let pub_name = &self.config.publication_name;
|
||||||
|
let start_sql = format!(
|
||||||
|
"START_REPLICATION SLOT {slot} LOGICAL {lsn_str} \
|
||||||
|
(proto_version '1', publication_names '{pub_name}')"
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.start_replication(&start_sql)
|
||||||
|
.await
|
||||||
|
.context("START_REPLICATION")?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
slot, publication = pub_name, lsn = lsn_str,
|
||||||
|
"pg_replication: replication stream started"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- stream processing loop ---
|
||||||
|
let mut emitter = WalEmitter::new(self.store.events.clone());
|
||||||
|
let mut last_lsn: u64 = lsn;
|
||||||
|
let mut last_status_at = Instant::now();
|
||||||
|
const STATUS_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let frame = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
conn.read_copy_data(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match frame {
|
||||||
|
Err(_timeout) => {}
|
||||||
|
|
||||||
|
Ok(Err(e)) => return Err(e).context("replication stream read"),
|
||||||
|
|
||||||
|
Ok(Ok(None)) => {
|
||||||
|
tracing::info!("pg_replication: replication stream ended");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Ok(Some(data))) => {
|
||||||
|
if data.is_empty() { continue; }
|
||||||
|
match data[0] {
|
||||||
|
// XLogData: 'w' + u64 wal_start(8) + u64 wal_end(8) + i64 time(8) + WAL
|
||||||
|
b'w' if data.len() >= 25 => {
|
||||||
|
last_lsn = u64::from_be_bytes(
|
||||||
|
data[9..17].try_into().expect("8 byte slice"),
|
||||||
|
);
|
||||||
|
if let Err(e) = emitter.process_raw(&data[25..]).await {
|
||||||
|
tracing::warn!("pg_replication: WAL parse warning: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PrimaryKeepalive: 'k' + u64 wal_end(8) + i64 time(8) + u8 reply_req
|
||||||
|
b'k' if data.len() >= 18 => {
|
||||||
|
if data[17] != 0 {
|
||||||
|
conn.send_standby_status(last_lsn)
|
||||||
|
.await
|
||||||
|
.context("send standby status (keepalive reply)")?;
|
||||||
|
last_status_at = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
tracing::debug!(
|
||||||
|
"pg_replication: unknown replication msg 0x{:02x}", other
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_status_at.elapsed() >= STATUS_INTERVAL {
|
||||||
|
conn.send_standby_status(last_lsn)
|
||||||
|
.await
|
||||||
|
.context("send periodic standby status")?;
|
||||||
|
last_status_at = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// StandbyStatusUpdate payload for acknowledging processed WAL.
|
||||||
|
pub(crate) fn standby_status_payload(lsn: u64) -> Bytes {
|
||||||
|
let ack = lsn.saturating_add(1);
|
||||||
|
let mut buf = BytesMut::with_capacity(34);
|
||||||
|
buf.put_u8(b'r');
|
||||||
|
buf.put_u64(ack); // write LSN
|
||||||
|
buf.put_u64(ack); // flush LSN
|
||||||
|
buf.put_u64(ack); // apply LSN
|
||||||
|
buf.put_i64(pg_epoch_micros());
|
||||||
|
buf.put_u8(0); // no reply requested
|
||||||
|
buf.freeze()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current time as microseconds since the PostgreSQL epoch (2000-01-01 UTC).
|
||||||
|
pub(crate) fn pg_epoch_micros() -> i64 {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let micros = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_micros() as i64;
|
||||||
|
micros - 946_684_800_000_000
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! Raw PostgreSQL wire-protocol connection for logical replication.
|
||||||
|
//!
|
||||||
|
//! `tokio-postgres` 0.7 does not expose a `copy_both_simple` API suitable for
|
||||||
|
//! the replication stream. This module implements the minimum subset of the
|
||||||
|
//! PostgreSQL wire protocol needed to:
|
||||||
|
//!
|
||||||
|
//! 1. Connect with `replication=database` startup parameter.
|
||||||
|
//! 2. Perform MD5 or cleartext password authentication.
|
||||||
|
//! 3. Send simple queries (for `CREATE_REPLICATION_SLOT` and `START_REPLICATION`).
|
||||||
|
//! 4. Enter `COPY BOTH` mode and stream `CopyData` frames.
|
||||||
|
//! 5. Send `StandbyStatusUpdate` CopyData frames back to the server.
|
||||||
|
//!
|
||||||
|
//! `postgres-protocol` is used for building most frontend messages and for
|
||||||
|
//! MD5 hash computation. The backend parser is bypassed for `CopyBothResponse`
|
||||||
|
//! (tag `'W'` / 0x57) which is not present in that crate's public API.
|
||||||
|
|
||||||
|
use super::standby_status_payload;
|
||||||
|
use anyhow::{bail, Context as _};
|
||||||
|
use bytes::{Buf, Bytes, BytesMut};
|
||||||
|
use postgres_protocol::authentication::md5_hash;
|
||||||
|
use postgres_protocol::message::frontend;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RawPgConn
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A raw PostgreSQL connection suitable for replication streaming.
|
||||||
|
pub struct RawPgConn {
|
||||||
|
stream: TcpStream,
|
||||||
|
/// Accumulates bytes read from the server until a complete frame arrives.
|
||||||
|
read_buf: BytesMut,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawPgConn {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Connect to a PostgreSQL server in **logical replication** mode.
|
||||||
|
///
|
||||||
|
/// Sends a startup message with `replication=database` so the backend
|
||||||
|
/// enters walsender mode. Handles MD5 and cleartext password auth.
|
||||||
|
pub async fn connect(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
database: &str,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
let stream = TcpStream::connect(&addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("TCP connect to {addr}"))?;
|
||||||
|
|
||||||
|
let mut conn = RawPgConn {
|
||||||
|
stream,
|
||||||
|
read_buf: BytesMut::with_capacity(4096),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Startup message -----------------------------------------------
|
||||||
|
// Parameters include `replication=database` which instructs PostgreSQL
|
||||||
|
// to accept replication commands on this connection.
|
||||||
|
let mut startup_buf = BytesMut::new();
|
||||||
|
frontend::startup_message(
|
||||||
|
[
|
||||||
|
("user", user),
|
||||||
|
("database", database),
|
||||||
|
("replication", "database"),
|
||||||
|
("application_name", "livevue-rs"),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.copied(),
|
||||||
|
&mut startup_buf,
|
||||||
|
)?;
|
||||||
|
conn.stream.write_all(&startup_buf).await?;
|
||||||
|
conn.stream.flush().await?;
|
||||||
|
|
||||||
|
// --- Authentication loop -------------------------------------------
|
||||||
|
loop {
|
||||||
|
let (tag, body) = conn.read_frame().await?;
|
||||||
|
match tag {
|
||||||
|
b'R' => {
|
||||||
|
// Authentication request
|
||||||
|
conn.handle_auth_request(&body, user, password).await?;
|
||||||
|
}
|
||||||
|
b'K' => {
|
||||||
|
// BackendKeyData — ignore
|
||||||
|
}
|
||||||
|
b'S' => {
|
||||||
|
// ParameterStatus — ignore
|
||||||
|
}
|
||||||
|
b'Z' => {
|
||||||
|
// ReadyForQuery — handshake complete
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
b'E' => {
|
||||||
|
let msg = parse_error_message(&body);
|
||||||
|
bail!("PostgreSQL startup error: {msg}");
|
||||||
|
}
|
||||||
|
b'N' => {
|
||||||
|
// NoticeResponse — ignore
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
tracing::debug!("startup: unexpected tag 0x{:02x}", other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Simple query (used for CREATE_REPLICATION_SLOT etc.)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Send a simple query and drain responses until `ReadyForQuery`.
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the server sends an `ErrorResponse`.
|
||||||
|
pub async fn simple_query(&mut self, query: &str) -> anyhow::Result<()> {
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
frontend::query(query, &mut buf)?;
|
||||||
|
self.stream.write_all(&buf).await?;
|
||||||
|
self.stream.flush().await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (tag, body) = self.read_frame().await?;
|
||||||
|
match tag {
|
||||||
|
b'Z' => break, // ReadyForQuery
|
||||||
|
b'E' => {
|
||||||
|
let msg = parse_error_message(&body);
|
||||||
|
bail!("query error: {msg}");
|
||||||
|
}
|
||||||
|
_ => {} // CommandComplete, RowDescription, DataRow, NoticeResponse …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Enter COPY BOTH mode for replication streaming
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Send `START_REPLICATION` and absorb the `CopyBothResponse`.
|
||||||
|
///
|
||||||
|
/// After this call the connection is in COPY BOTH mode. Use
|
||||||
|
/// [`read_copy_data`] / [`send_standby_status`] to exchange messages.
|
||||||
|
pub async fn start_replication(&mut self, query: &str) -> anyhow::Result<()> {
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
frontend::query(query, &mut buf)?;
|
||||||
|
self.stream.write_all(&buf).await?;
|
||||||
|
self.stream.flush().await?;
|
||||||
|
|
||||||
|
// Expect CopyBothResponse ('W') followed by the replication stream.
|
||||||
|
// postgres-protocol doesn't parse 'W', so we handle it manually.
|
||||||
|
loop {
|
||||||
|
let (tag, _body) = self.read_frame().await?;
|
||||||
|
match tag {
|
||||||
|
b'W' => {
|
||||||
|
// CopyBothResponse — body contains column-format info we
|
||||||
|
// don't need for the replication stream. Just break out.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
b'E' => {
|
||||||
|
let msg = parse_error_message(&_body);
|
||||||
|
bail!("START_REPLICATION error: {msg}");
|
||||||
|
}
|
||||||
|
b'N' => {} // NoticeResponse — skip
|
||||||
|
other => {
|
||||||
|
tracing::debug!("start_replication: unexpected tag 0x{:02x}", other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Replication stream I/O
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Read one `CopyData` payload from the replication stream.
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// - `Ok(Some(bytes))` — a CopyData payload (XLogData or PrimaryKeepalive).
|
||||||
|
/// - `Ok(None)` — the server sent `CopyDone` or `ErrorResponse` (stream end).
|
||||||
|
/// - `Err(_)` — I/O or protocol error.
|
||||||
|
pub async fn read_copy_data(&mut self) -> anyhow::Result<Option<Bytes>> {
|
||||||
|
let (tag, body) = self.read_frame().await?;
|
||||||
|
match tag {
|
||||||
|
b'd' => Ok(Some(body)),
|
||||||
|
b'c' => Ok(None), // CopyDone
|
||||||
|
b'E' => {
|
||||||
|
let msg = parse_error_message(&body);
|
||||||
|
bail!("replication error: {msg}");
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
tracing::debug!("copy stream: unexpected tag 0x{:02x}", other);
|
||||||
|
Ok(Some(Bytes::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a `StandbyStatusUpdate` CopyData frame to acknowledge `lsn`.
|
||||||
|
pub async fn send_standby_status(&mut self, lsn: u64) -> anyhow::Result<()> {
|
||||||
|
let payload = standby_status_payload(lsn);
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
frontend::CopyData::new(&payload[..])?.write(&mut buf);
|
||||||
|
self.stream.write_all(&buf).await?;
|
||||||
|
self.stream.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Low-level frame I/O
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Read one complete PostgreSQL message frame.
|
||||||
|
///
|
||||||
|
/// Frame format: `tag (u8) | length (i32, BE, includes itself) | body`.
|
||||||
|
/// Returns `(tag, body_bytes)`.
|
||||||
|
async fn read_frame(&mut self) -> anyhow::Result<(u8, Bytes)> {
|
||||||
|
// Ensure we have at least 5 bytes (1 tag + 4 length).
|
||||||
|
while self.read_buf.len() < 5 {
|
||||||
|
let n = self.stream.read_buf(&mut self.read_buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
bail!("server closed connection unexpectedly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag = self.read_buf[0];
|
||||||
|
// Length field is i32 big-endian, includes the 4-length bytes but NOT
|
||||||
|
// the tag byte. Total frame length = 1 (tag) + length.
|
||||||
|
let body_len = (i32::from_be_bytes(
|
||||||
|
self.read_buf[1..5].try_into().expect("4 byte slice"),
|
||||||
|
) as usize)
|
||||||
|
.saturating_sub(4); // subtract the 4 length bytes themselves
|
||||||
|
|
||||||
|
let total = 5 + body_len; // tag(1) + length(4) + body
|
||||||
|
while self.read_buf.len() < total {
|
||||||
|
let n = self.stream.read_buf(&mut self.read_buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
bail!("server closed connection while reading message body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.read_buf.advance(5); // consume tag + length
|
||||||
|
let body = self.read_buf.split_to(body_len).freeze();
|
||||||
|
Ok((tag, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Authentication
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn handle_auth_request(
|
||||||
|
&mut self,
|
||||||
|
body: &[u8],
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if body.len() < 4 {
|
||||||
|
bail!("AuthenticationRequest body too short");
|
||||||
|
}
|
||||||
|
let auth_type = i32::from_be_bytes(body[0..4].try_into().expect("4 bytes"));
|
||||||
|
|
||||||
|
match auth_type {
|
||||||
|
0 => {
|
||||||
|
// AuthenticationOk — nothing to do
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// AuthenticationCleartextPassword
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
frontend::password_message(password.as_bytes(), &mut buf)?;
|
||||||
|
self.stream.write_all(&buf).await?;
|
||||||
|
self.stream.flush().await?;
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
// AuthenticationMD5Password — salt is bytes 4..8
|
||||||
|
if body.len() < 8 {
|
||||||
|
bail!("MD5 auth: body too short for salt");
|
||||||
|
}
|
||||||
|
let salt: [u8; 4] = body[4..8].try_into().expect("4 byte salt");
|
||||||
|
let hashed = md5_hash(user.as_bytes(), password.as_bytes(), salt);
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
frontend::password_message(hashed.as_bytes(), &mut buf)?;
|
||||||
|
self.stream.write_all(&buf).await?;
|
||||||
|
self.stream.flush().await?;
|
||||||
|
}
|
||||||
|
10 => {
|
||||||
|
// AuthenticationSASL (SCRAM-SHA-256) — not implemented here.
|
||||||
|
// For Docker dev setups, use `md5` or `trust` in pg_hba.conf.
|
||||||
|
bail!(
|
||||||
|
"SCRAM-SHA-256 authentication is not supported by the \
|
||||||
|
replication module. Configure PostgreSQL to use `md5` \
|
||||||
|
or `trust` for this user in pg_hba.conf."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
bail!("unsupported authentication type: {other}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Extract a human-readable message from an `ErrorResponse` body.
|
||||||
|
///
|
||||||
|
/// Fields are null-terminated key-value pairs. We extract the 'M' (message)
|
||||||
|
/// and 'D' (detail) fields.
|
||||||
|
fn parse_error_message(body: &[u8]) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < body.len() {
|
||||||
|
let field_type = body[i];
|
||||||
|
i += 1;
|
||||||
|
if field_type == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Find the null terminator of the field value.
|
||||||
|
let start = i;
|
||||||
|
while i < body.len() && body[i] != 0 {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let value = String::from_utf8_lossy(&body[start..i]).into_owned();
|
||||||
|
i += 1; // skip null terminator
|
||||||
|
if matches!(field_type, b'M' | b'D' | b'C') {
|
||||||
|
parts.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
"unknown PostgreSQL error".into()
|
||||||
|
} else {
|
||||||
|
parts.join(" — ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
use bytes::{Buf, BytesMut};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// PostgreSQL logical replication binary protocol parser (pgoutput, version 1).
|
||||||
|
///
|
||||||
|
/// Parses the raw WAL bytes delivered by `START_REPLICATION … (proto_version '1')`.
|
||||||
|
/// In protocol version 1 the transaction ID (XID) is **only** present in `Begin`
|
||||||
|
/// messages; DML messages (Insert / Update / Delete / Truncate) carry no XID.
|
||||||
|
/// The [`WalEmitter`](super::emitter::WalEmitter) tracks the current XID via the
|
||||||
|
/// `Begin`/`Commit` boundary.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColumnInfo {
|
||||||
|
/// Bit 0: column is part of the replica identity key.
|
||||||
|
pub flags: u8,
|
||||||
|
pub name: String,
|
||||||
|
pub type_oid: u32,
|
||||||
|
pub type_modifier: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RelationInfo {
|
||||||
|
pub oid: u32,
|
||||||
|
pub namespace: String,
|
||||||
|
pub name: String,
|
||||||
|
/// `d` = default (PK), `n` = nothing, `f` = full row, `i` = index.
|
||||||
|
pub replica_identity: u8,
|
||||||
|
pub columns: Vec<ColumnInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tuple data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ColumnValue {
|
||||||
|
Null,
|
||||||
|
UnchangedToasted,
|
||||||
|
Text(String),
|
||||||
|
Binary(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TupleData {
|
||||||
|
pub values: Vec<ColumnValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logical messages (pgoutput v1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LogicalMessage {
|
||||||
|
/// Transaction start. `xid` identifies the transaction; DML messages that
|
||||||
|
/// follow do not carry their own XID.
|
||||||
|
Begin {
|
||||||
|
/// Final LSN of the transaction (equals the Commit LSN).
|
||||||
|
final_lsn: u64,
|
||||||
|
/// Commit timestamp (microseconds since PG epoch 2000-01-01).
|
||||||
|
timestamp: i64,
|
||||||
|
/// Transaction ID.
|
||||||
|
xid: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Schema descriptor sent before the first DML that references a relation
|
||||||
|
/// in a session (and after schema changes). Stored internally by the parser.
|
||||||
|
Relation(RelationInfo),
|
||||||
|
|
||||||
|
/// Row inserted into `rel_oid`.
|
||||||
|
Insert {
|
||||||
|
rel_oid: u32,
|
||||||
|
new_tuple: TupleData,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Row updated in `rel_oid`.
|
||||||
|
///
|
||||||
|
/// `key_tuple` is present when REPLICA IDENTITY is the primary key
|
||||||
|
/// (default). `old_tuple` is present for REPLICA IDENTITY FULL.
|
||||||
|
Update {
|
||||||
|
rel_oid: u32,
|
||||||
|
key_tuple: Option<TupleData>,
|
||||||
|
old_tuple: Option<TupleData>,
|
||||||
|
new_tuple: TupleData,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Row deleted from `rel_oid`.
|
||||||
|
Delete {
|
||||||
|
rel_oid: u32,
|
||||||
|
key_tuple: Option<TupleData>,
|
||||||
|
old_tuple: Option<TupleData>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Transaction committed successfully.
|
||||||
|
Commit {
|
||||||
|
flags: u8,
|
||||||
|
commit_lsn: u64,
|
||||||
|
end_lsn: u64,
|
||||||
|
timestamp: i64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// One or more tables truncated.
|
||||||
|
Truncate {
|
||||||
|
num_relations: u32,
|
||||||
|
options: u8,
|
||||||
|
rel_oids: Vec<u32>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Custom data-type descriptor (pgoutput emits these before first use).
|
||||||
|
Type {
|
||||||
|
type_oid: u32,
|
||||||
|
namespace: String,
|
||||||
|
type_name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Application-level logical message emitted via `pg_logical_emit_message`.
|
||||||
|
Message {
|
||||||
|
/// Bit 0: message is transactional.
|
||||||
|
flags: u8,
|
||||||
|
lsn: u64,
|
||||||
|
prefix: String,
|
||||||
|
content: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Stateful pgoutput v1 parser.
|
||||||
|
///
|
||||||
|
/// Maintains a cache of `RelationInfo` objects keyed by OID so that
|
||||||
|
/// Insert/Update/Delete messages can be decoded without extra round-trips.
|
||||||
|
pub struct MessageParser {
|
||||||
|
relations: HashMap<u32, RelationInfo>,
|
||||||
|
_types: HashMap<u32, (String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
relations: HashMap::new(),
|
||||||
|
_types: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public entry point
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse the next logical message from `buf`.
|
||||||
|
///
|
||||||
|
/// `Relation` messages are absorbed into internal state and return `None`
|
||||||
|
/// so callers only see semantically meaningful messages.
|
||||||
|
pub fn parse_message(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match buf[0] as char {
|
||||||
|
'B' => self.parse_begin(buf),
|
||||||
|
'C' => self.parse_commit(buf),
|
||||||
|
'R' => {
|
||||||
|
self.parse_relation(buf)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
'I' => self.parse_insert(buf),
|
||||||
|
'U' => self.parse_update(buf),
|
||||||
|
'D' => self.parse_delete(buf),
|
||||||
|
'T' => self.parse_truncate(buf),
|
||||||
|
'Y' => self.parse_type(buf),
|
||||||
|
'M' => self.parse_message_msg(buf),
|
||||||
|
other => Err(format!(
|
||||||
|
"unknown pgoutput message type: '{}' (0x{:02x})",
|
||||||
|
other, buf[0]
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a relation by OID (returns `None` if not yet seen).
|
||||||
|
pub fn get_relation(&self, oid: u32) -> Option<&RelationInfo> {
|
||||||
|
self.relations.get(&oid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Low-level readers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn read_u8(buf: &mut BytesMut) -> Result<u8, String> {
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Err("unexpected EOF reading u8".into());
|
||||||
|
}
|
||||||
|
Ok(buf.get_u8())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i16(buf: &mut BytesMut) -> Result<i16, String> {
|
||||||
|
if buf.len() < 2 {
|
||||||
|
return Err("unexpected EOF reading i16".into());
|
||||||
|
}
|
||||||
|
Ok(buf.get_i16())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(buf: &mut BytesMut) -> Result<u32, String> {
|
||||||
|
if buf.len() < 4 {
|
||||||
|
return Err("unexpected EOF reading u32".into());
|
||||||
|
}
|
||||||
|
Ok(buf.get_u32())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i32(buf: &mut BytesMut) -> Result<i32, String> {
|
||||||
|
if buf.len() < 4 {
|
||||||
|
return Err("unexpected EOF reading i32".into());
|
||||||
|
}
|
||||||
|
Ok(buf.get_i32())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(buf: &mut BytesMut) -> Result<u64, String> {
|
||||||
|
if buf.len() < 8 {
|
||||||
|
return Err("unexpected EOF reading u64".into());
|
||||||
|
}
|
||||||
|
Ok(buf.get_u64())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i64(buf: &mut BytesMut) -> Result<i64, String> {
|
||||||
|
if buf.len() < 8 {
|
||||||
|
return Err("unexpected EOF reading i64".into());
|
||||||
|
}
|
||||||
|
Ok(buf.get_i64())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a null-terminated UTF-8 string.
|
||||||
|
fn read_string(buf: &mut BytesMut) -> Result<String, String> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
loop {
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Err("unexpected EOF reading null-terminated string".into());
|
||||||
|
}
|
||||||
|
let b = buf.get_u8();
|
||||||
|
if b == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes.push(b);
|
||||||
|
}
|
||||||
|
String::from_utf8(bytes).map_err(|e| format!("invalid UTF-8 in string: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Message parsers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn parse_begin(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'B'
|
||||||
|
let final_lsn = Self::read_u64(buf)?;
|
||||||
|
let timestamp = Self::read_i64(buf)?;
|
||||||
|
let xid = Self::read_u32(buf)?;
|
||||||
|
Ok(Some(LogicalMessage::Begin { final_lsn, timestamp, xid }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_commit(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'C'
|
||||||
|
let flags = Self::read_u8(buf)?;
|
||||||
|
let commit_lsn = Self::read_u64(buf)?;
|
||||||
|
let end_lsn = Self::read_u64(buf)?;
|
||||||
|
let timestamp = Self::read_i64(buf)?;
|
||||||
|
Ok(Some(LogicalMessage::Commit { flags, commit_lsn, end_lsn, timestamp }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_relation(&mut self, buf: &mut BytesMut) -> Result<(), String> {
|
||||||
|
buf.get_u8(); // 'R'
|
||||||
|
let rel_oid = Self::read_u32(buf)?;
|
||||||
|
let namespace = Self::read_string(buf)?;
|
||||||
|
let name = Self::read_string(buf)?;
|
||||||
|
let replica_identity = Self::read_u8(buf)?;
|
||||||
|
let num_columns = Self::read_i16(buf)? as usize;
|
||||||
|
|
||||||
|
let mut columns = Vec::with_capacity(num_columns);
|
||||||
|
for _ in 0..num_columns {
|
||||||
|
let flags = Self::read_u8(buf)?;
|
||||||
|
let col_name = Self::read_string(buf)?;
|
||||||
|
let type_oid = Self::read_u32(buf)?;
|
||||||
|
let type_modifier = Self::read_i32(buf)?;
|
||||||
|
columns.push(ColumnInfo { flags, name: col_name, type_oid, type_modifier });
|
||||||
|
}
|
||||||
|
|
||||||
|
self.relations.insert(
|
||||||
|
rel_oid,
|
||||||
|
RelationInfo { oid: rel_oid, namespace, name, replica_identity, columns },
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_insert(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'I'
|
||||||
|
// pgoutput v1: Relation OID comes directly after type byte (no XID).
|
||||||
|
let rel_oid = Self::read_u32(buf)?;
|
||||||
|
|
||||||
|
let marker = Self::read_u8(buf)?;
|
||||||
|
if marker != b'N' {
|
||||||
|
return Err(format!("INSERT: expected 'N' marker, got '{}'", marker as char));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_tuple = self.parse_tuple_data(buf, rel_oid)?;
|
||||||
|
Ok(Some(LogicalMessage::Insert { rel_oid, new_tuple }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_update(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'U'
|
||||||
|
// pgoutput v1: no XID in Update.
|
||||||
|
let rel_oid = Self::read_u32(buf)?;
|
||||||
|
|
||||||
|
let mut key_tuple = None;
|
||||||
|
let mut old_tuple = None;
|
||||||
|
|
||||||
|
if !buf.is_empty() {
|
||||||
|
match buf[0] as char {
|
||||||
|
'K' => {
|
||||||
|
buf.get_u8();
|
||||||
|
key_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
|
||||||
|
}
|
||||||
|
'O' => {
|
||||||
|
buf.get_u8();
|
||||||
|
old_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
|
||||||
|
}
|
||||||
|
_ => {} // no optional tuple, proceed to 'N'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = Self::read_u8(buf)?;
|
||||||
|
if marker != b'N' {
|
||||||
|
return Err(format!("UPDATE: expected 'N' marker, got '{}'", marker as char));
|
||||||
|
}
|
||||||
|
let new_tuple = self.parse_tuple_data(buf, rel_oid)?;
|
||||||
|
|
||||||
|
Ok(Some(LogicalMessage::Update { rel_oid, key_tuple, old_tuple, new_tuple }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_delete(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'D'
|
||||||
|
// pgoutput v1: no XID in Delete.
|
||||||
|
let rel_oid = Self::read_u32(buf)?;
|
||||||
|
|
||||||
|
let mut key_tuple = None;
|
||||||
|
let mut old_tuple = None;
|
||||||
|
|
||||||
|
if !buf.is_empty() {
|
||||||
|
match buf[0] as char {
|
||||||
|
'K' => {
|
||||||
|
buf.get_u8();
|
||||||
|
key_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
|
||||||
|
}
|
||||||
|
'O' => {
|
||||||
|
buf.get_u8();
|
||||||
|
old_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("DELETE: expected 'K' or 'O', got '{}'", other));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(LogicalMessage::Delete { rel_oid, key_tuple, old_tuple }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_truncate(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'T'
|
||||||
|
// pgoutput v1: no XID in Truncate.
|
||||||
|
let num_relations = Self::read_u32(buf)?;
|
||||||
|
let options = Self::read_u8(buf)?;
|
||||||
|
|
||||||
|
let mut rel_oids = Vec::with_capacity(num_relations as usize);
|
||||||
|
for _ in 0..num_relations {
|
||||||
|
rel_oids.push(Self::read_u32(buf)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(LogicalMessage::Truncate { num_relations, options, rel_oids }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_type(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'Y'
|
||||||
|
// pgoutput v1: no XID in Type.
|
||||||
|
let type_oid = Self::read_u32(buf)?;
|
||||||
|
let namespace = Self::read_string(buf)?;
|
||||||
|
let type_name = Self::read_string(buf)?;
|
||||||
|
|
||||||
|
self._types.insert(type_oid, (namespace.clone(), type_name.clone()));
|
||||||
|
|
||||||
|
Ok(Some(LogicalMessage::Type { type_oid, namespace, type_name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_message_msg(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
|
||||||
|
buf.get_u8(); // 'M'
|
||||||
|
// pgoutput v1: no XID in Message.
|
||||||
|
let flags = Self::read_u8(buf)?;
|
||||||
|
let lsn = Self::read_u64(buf)?;
|
||||||
|
let prefix = Self::read_string(buf)?;
|
||||||
|
let content_len = Self::read_i32(buf)? as usize;
|
||||||
|
|
||||||
|
if buf.len() < content_len {
|
||||||
|
return Err(format!(
|
||||||
|
"Message: need {} bytes for content, have {}",
|
||||||
|
content_len,
|
||||||
|
buf.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let content = buf.split_to(content_len).to_vec();
|
||||||
|
|
||||||
|
Ok(Some(LogicalMessage::Message { flags, lsn, prefix, content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Tuple decoder
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn parse_tuple_data(&self, buf: &mut BytesMut, rel_oid: u32) -> Result<TupleData, String> {
|
||||||
|
let num_columns = Self::read_i16(buf)? as usize;
|
||||||
|
|
||||||
|
let relation = self
|
||||||
|
.relations
|
||||||
|
.get(&rel_oid)
|
||||||
|
.ok_or_else(|| format!("unknown relation OID {rel_oid} — missing Relation message?"))?;
|
||||||
|
|
||||||
|
if num_columns != relation.columns.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"tuple has {} columns but relation '{}' has {}",
|
||||||
|
num_columns,
|
||||||
|
relation.name,
|
||||||
|
relation.columns.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut values = Vec::with_capacity(num_columns);
|
||||||
|
for _ in 0..num_columns {
|
||||||
|
let marker = Self::read_u8(buf)?;
|
||||||
|
let value = match marker {
|
||||||
|
b'n' => ColumnValue::Null,
|
||||||
|
b'u' => ColumnValue::UnchangedToasted,
|
||||||
|
b't' => {
|
||||||
|
let len = Self::read_i32(buf)? as usize;
|
||||||
|
if buf.len() < len {
|
||||||
|
return Err(format!(
|
||||||
|
"text column: need {} bytes, have {}",
|
||||||
|
len,
|
||||||
|
buf.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let data = buf.split_to(len).to_vec();
|
||||||
|
let text = String::from_utf8(data)
|
||||||
|
.map_err(|e| format!("non-UTF-8 text column: {e}"))?;
|
||||||
|
ColumnValue::Text(text)
|
||||||
|
}
|
||||||
|
b'b' => {
|
||||||
|
let len = Self::read_i32(buf)? as usize;
|
||||||
|
if buf.len() < len {
|
||||||
|
return Err(format!(
|
||||||
|
"binary column: need {} bytes, have {}",
|
||||||
|
len,
|
||||||
|
buf.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ColumnValue::Binary(buf.split_to(len).to_vec())
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown column marker '{}'",
|
||||||
|
other as char
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TupleData { values })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MessageParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_starts_empty() {
|
||||||
|
let p = MessageParser::new();
|
||||||
|
assert!(p.relations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_buf_returns_none() {
|
||||||
|
let mut p = MessageParser::new();
|
||||||
|
let mut buf = BytesMut::new();
|
||||||
|
assert!(p.parse_message(&mut buf).unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ pub enum CacheKey {
|
|||||||
/// The trait uses `Pin<Box<dyn Future>>` instead of `async fn` to guarantee
|
/// The trait uses `Pin<Box<dyn Future>>` instead of `async fn` to guarantee
|
||||||
/// `Send` bounds needed for `tokio::try_join!` in `cx.run_all()`. The
|
/// `Send` bounds needed for `tokio::try_join!` in `cx.run_all()`. The
|
||||||
/// `query_one!` / `query_all!` macros hide this behind a clean declaration.
|
/// `query_one!` / `query_all!` macros hide this behind a clean declaration.
|
||||||
|
///
|
||||||
|
/// Mutation structs (generated by `mutation_exec!`) also implement this trait.
|
||||||
|
/// They override `publish_key()` to return the `CacheKey` that should be
|
||||||
|
/// invalidated after the write. `Store::exec()` calls `publish_key()` and
|
||||||
|
/// fires the broadcast automatically — no manual `store.publish()` needed.
|
||||||
pub trait Query: Send + Sync {
|
pub trait Query: Send + Sync {
|
||||||
type Output: Send;
|
type Output: Send;
|
||||||
|
|
||||||
@@ -61,6 +66,14 @@ pub trait Query: Send + Sync {
|
|||||||
&'a self,
|
&'a self,
|
||||||
db: &'a sqlx::SqlitePool,
|
db: &'a sqlx::SqlitePool,
|
||||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Self::Output>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<Self::Output>> + Send + 'a>>;
|
||||||
|
|
||||||
|
/// The cache key to publish after a successful mutation.
|
||||||
|
///
|
||||||
|
/// Read queries return `None` (the default). Mutation structs generated
|
||||||
|
/// by `mutation_exec!` override this to return the key they invalidate.
|
||||||
|
fn publish_key(&self) -> Option<CacheKey> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -113,6 +126,62 @@ macro_rules! query_one {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mutation_exec! — INSERT / UPDATE / DELETE (no RETURNING)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generate a `Query` impl for write operations (INSERT / UPDATE / DELETE).
|
||||||
|
///
|
||||||
|
/// The struct returns `()` from `execute` and overrides `publish_key()` with
|
||||||
|
/// the provided closure. Use `Store::exec()` to run it — the key is published
|
||||||
|
/// automatically after a successful write.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// pub struct CreateTodo { pub title: String }
|
||||||
|
///
|
||||||
|
/// mutation_exec!(CreateTodo {
|
||||||
|
/// sql: "INSERT INTO todos (title, completed) VALUES (?, false)",
|
||||||
|
/// params: [|s: &CreateTodo| s.title.as_str()],
|
||||||
|
/// publish: |_| CacheKey::Table { table: "todos" },
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! mutation_exec {
|
||||||
|
($name:ident {
|
||||||
|
sql: $sql:expr,
|
||||||
|
params: [$($param:expr),* $(,)?],
|
||||||
|
publish: $publish:expr $(,)?
|
||||||
|
}) => {
|
||||||
|
impl $crate::Query for $name {
|
||||||
|
type Output = ();
|
||||||
|
|
||||||
|
fn cache_key(&self) -> $crate::CacheKey {
|
||||||
|
$crate::CacheKey::None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_key(&self) -> Option<$crate::CacheKey> {
|
||||||
|
Some(($publish)(self))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute<'a>(
|
||||||
|
&'a self,
|
||||||
|
db: &'a sqlx::SqlitePool,
|
||||||
|
) -> ::std::pin::Pin<
|
||||||
|
Box<dyn ::std::future::Future<Output = anyhow::Result<Self::Output>> + Send + 'a>,
|
||||||
|
> {
|
||||||
|
let zelf = self;
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query($sql)
|
||||||
|
$(.bind(($param)(zelf)))*
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// query_all! — fetch zero or more rows
|
// query_all! — fetch zero or more rows
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+232
-134
@@ -1,8 +1,9 @@
|
|||||||
use crate::connection::{ConnectionManager, SubscriptionRegistry};
|
use crate::config::{LiveVueConfig, SharedConfig};
|
||||||
|
use crate::connection::{ConnectionManager, RenderTrigger, SubscriptionRegistry};
|
||||||
use crate::context::{ComponentTree, RenderContext};
|
use crate::context::{ComponentTree, RenderContext};
|
||||||
|
use crate::query::CacheKey;
|
||||||
use crate::signal::SignalStore;
|
use crate::signal::SignalStore;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
use crate::query::CacheKey;
|
|
||||||
|
|
||||||
use axum::extract::{Query as AxumQuery, State};
|
use axum::extract::{Query as AxumQuery, State};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
@@ -10,9 +11,14 @@ use futures::future::BoxFuture;
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEBOUNCE_MS: u64 = 2;
|
||||||
|
const RECONNECT_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RenderFn
|
// RenderFn
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -38,7 +44,11 @@ use uuid::Uuid;
|
|||||||
/// Ok((html, keys))
|
/// Ok((html, keys))
|
||||||
/// }));
|
/// }));
|
||||||
/// ```
|
/// ```
|
||||||
pub type RenderFn = Arc<dyn Fn(RenderContext) -> BoxFuture<'static, anyhow::Result<(String, Vec<CacheKey>)>> + Send + Sync>;
|
pub type RenderFn = Arc<
|
||||||
|
dyn Fn(RenderContext) -> BoxFuture<'static, anyhow::Result<(String, Vec<CacheKey>)>>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppState
|
// AppState
|
||||||
@@ -46,26 +56,196 @@ pub type RenderFn = Arc<dyn Fn(RenderContext) -> BoxFuture<'static, anyhow::Resu
|
|||||||
|
|
||||||
/// All shared framework + app state.
|
/// All shared framework + app state.
|
||||||
///
|
///
|
||||||
/// Construct with `AppState::new(store, render_fn)` and wrap in `Arc` to
|
/// Construct with [`AppState::new`] and wrap in `Arc` to produce a
|
||||||
/// produce a `SharedState` for axum's `State` extractor.
|
/// [`SharedState`] for axum's `State` extractor.
|
||||||
|
///
|
||||||
|
/// The `config` field holds the live [`LiveVueConfig`] and is updated in place
|
||||||
|
/// by the file-watcher / SIGHUP tasks started by
|
||||||
|
/// [`crate::config::load_and_watch_config`]. Handlers and middleware that need
|
||||||
|
/// config values (e.g. brotli quality) should clone or read from this field.
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub store: Arc<Store>,
|
pub store: Arc<Store>,
|
||||||
pub connections: ConnectionManager,
|
pub connections: ConnectionManager,
|
||||||
pub subscriptions: SubscriptionRegistry,
|
pub subscriptions: SubscriptionRegistry,
|
||||||
pub render_fn: RenderFn,
|
pub render_fn: RenderFn,
|
||||||
|
/// Live server configuration, hot-reloadable via SIGHUP or file watch.
|
||||||
|
pub config: SharedConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type SharedState = Arc<AppState>;
|
pub type SharedState = Arc<AppState>;
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(store: Arc<Store>, render_fn: RenderFn) -> SharedState {
|
/// Build a [`SharedState`] with the given store, render function, and config.
|
||||||
|
///
|
||||||
|
/// Typically called after [`crate::config::load_and_watch_config`]:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let config = load_and_watch_config("livevue.toml")?;
|
||||||
|
/// let state = AppState::new(store, render_fn, config);
|
||||||
|
/// spawn_fanout(state.clone());
|
||||||
|
/// ```
|
||||||
|
pub fn new(store: Arc<Store>, render_fn: RenderFn, config: SharedConfig) -> SharedState {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
store,
|
store,
|
||||||
connections: ConnectionManager::new(),
|
connections: ConnectionManager::new(),
|
||||||
subscriptions: SubscriptionRegistry::new(),
|
subscriptions: SubscriptionRegistry::new(),
|
||||||
render_fn,
|
render_fn,
|
||||||
|
config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience constructor that uses default configuration.
|
||||||
|
///
|
||||||
|
/// Suitable for examples and tests where `livevue.toml` is not needed.
|
||||||
|
pub fn new_default(store: Arc<Store>, render_fn: RenderFn) -> SharedState {
|
||||||
|
let config = Arc::new(RwLock::new(LiveVueConfig::default()));
|
||||||
|
Self::new(store, render_fn, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// render_and_send helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse signals, render, and send SSE events for one connection.
|
||||||
|
///
|
||||||
|
/// On any error, logs and returns without panicking.
|
||||||
|
async fn render_and_send(
|
||||||
|
conn_id: Uuid,
|
||||||
|
signals_json: &str,
|
||||||
|
sse_tx: &mpsc::Sender<Event>,
|
||||||
|
state: &AppState,
|
||||||
|
) {
|
||||||
|
let signals = match serde_json::from_str::<serde_json::Value>(signals_json) {
|
||||||
|
Ok(v) => SignalStore::from_json(v),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("render_and_send: failed to parse signals for {}: {}", conn_id, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tree: ComponentTree = signals
|
||||||
|
.get("tree")
|
||||||
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let cx = RenderContext::new(conn_id, signals, tree, state.store.clone());
|
||||||
|
match (state.render_fn)(cx).await {
|
||||||
|
Ok((html, keys)) => {
|
||||||
|
state.subscriptions.update(conn_id, keys);
|
||||||
|
|
||||||
|
let data_payload: String = html
|
||||||
|
.lines()
|
||||||
|
.map(|line| format!("elements {}", line))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let html_event = Event::default()
|
||||||
|
.event("datastar-patch-elements")
|
||||||
|
.data(data_payload);
|
||||||
|
|
||||||
|
if sse_tx.send(html_event).await.is_err() {
|
||||||
|
tracing::debug!("render_and_send: SSE channel closed for {}", conn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let signals_event = Event::default()
|
||||||
|
.event("datastar-patch-signals")
|
||||||
|
.data("signals ".to_owned() + signals_json);
|
||||||
|
|
||||||
|
if sse_tx.send(signals_event).await.is_err() {
|
||||||
|
tracing::debug!("render_and_send: SSE channel closed for {}", conn_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("render_and_send: render error for {}: {}", conn_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// connection_task
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Long-lived task that owns the SSE pipe for one logical connection.
|
||||||
|
///
|
||||||
|
/// Survives SSE disconnects for a grace period (RECONNECT_GRACE_SECS), allowing
|
||||||
|
/// the client to reconnect cleanly. Debounces render triggers by DEBOUNCE_MS.
|
||||||
|
async fn connection_task(
|
||||||
|
conn_id: Uuid,
|
||||||
|
mut inbox: mpsc::Receiver<RenderTrigger>,
|
||||||
|
mut pipe_rx: mpsc::Receiver<mpsc::Sender<Event>>,
|
||||||
|
initial_signals_json: String,
|
||||||
|
state: SharedState,
|
||||||
|
) {
|
||||||
|
let mut signals_json = initial_signals_json;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Wait for first/next SSE pipe (with reconnect grace period).
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
let sse_tx: mpsc::Sender<Event> = tokio::select! {
|
||||||
|
maybe_pipe = pipe_rx.recv() => {
|
||||||
|
match maybe_pipe {
|
||||||
|
Some(tx) => tx,
|
||||||
|
None => {
|
||||||
|
// pipe channel closed — shut down
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = sleep(Duration::from_secs(RECONNECT_GRACE_SECS)) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"connection_task: grace period expired for {}, cleaning up",
|
||||||
|
conn_id
|
||||||
|
);
|
||||||
|
state.connections.remove(&conn_id);
|
||||||
|
state.subscriptions.remove_connection(&conn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Do an immediate render on pipe arrival.
|
||||||
|
render_and_send(conn_id, &signals_json, &sse_tx, &state).await;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Main event loop for this pipe.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// SSE pipe dropped by the HTTP layer — wait for reconnect.
|
||||||
|
_ = sse_tx.closed() => {
|
||||||
|
tracing::debug!("connection_task: SSE pipe closed for {}, waiting for reconnect", conn_id);
|
||||||
|
break; // back to outer loop to wait for new pipe
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger channel closed — task is being shut down.
|
||||||
|
maybe_trigger = inbox.recv() => {
|
||||||
|
match maybe_trigger {
|
||||||
|
None => {
|
||||||
|
// Trigger sender dropped — clean up and exit.
|
||||||
|
state.connections.remove(&conn_id);
|
||||||
|
state.subscriptions.remove_connection(&conn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
// Debounce: drain any additional triggers within DEBOUNCE_MS.
|
||||||
|
sleep(Duration::from_millis(DEBOUNCE_MS)).await;
|
||||||
|
while inbox.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
// Read fresh signals and render.
|
||||||
|
signals_json = state
|
||||||
|
.connections
|
||||||
|
.get_signals(&conn_id)
|
||||||
|
.unwrap_or_else(|| signals_json.clone());
|
||||||
|
|
||||||
|
render_and_send(conn_id, &signals_json, &sse_tx, &state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -77,16 +257,11 @@ impl AppState {
|
|||||||
/// Must be called once at startup, after building `AppState`. The task owns
|
/// Must be called once at startup, after building `AppState`. The task owns
|
||||||
/// a `broadcast::Receiver<CacheKey>` and runs for the lifetime of the server.
|
/// a `broadcast::Receiver<CacheKey>` and runs for the lifetime of the server.
|
||||||
///
|
///
|
||||||
/// On each invalidation event:
|
/// On each invalidation event, routes a `RenderTrigger::Invalidated` to each
|
||||||
/// 1. Look up subscribed connections via `SubscriptionRegistry`.
|
/// subscribed connection's long-lived connection task.
|
||||||
/// 2. For each connection, fetch `last_signals_json` + SSE sender.
|
|
||||||
/// 3. Reconstruct `SignalStore` + `ComponentTree`, build `RenderContext`.
|
|
||||||
/// 4. Call `render_fn` to produce HTML.
|
|
||||||
/// 5. Frame as SSE and send down the connection's SSE channel.
|
|
||||||
/// 6. Update subscription keys and `last_signals_json` from the new render.
|
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// let state = AppState::new(store.clone(), render_fn);
|
/// let state = AppState::new(store.clone(), render_fn, config);
|
||||||
/// spawn_fanout(state.clone());
|
/// spawn_fanout(state.clone());
|
||||||
/// ```
|
/// ```
|
||||||
pub fn spawn_fanout(state: SharedState) {
|
pub fn spawn_fanout(state: SharedState) {
|
||||||
@@ -109,64 +284,9 @@ pub fn spawn_fanout(state: SharedState) {
|
|||||||
let conn_ids = state.subscriptions.get_connections_for_key(&key);
|
let conn_ids = state.subscriptions.get_connections_for_key(&key);
|
||||||
|
|
||||||
for conn_id in conn_ids {
|
for conn_id in conn_ids {
|
||||||
let signals_json = state.connections.get_signals(&conn_id)
|
if let Some(tx) = state.connections.get_trigger_sender(&conn_id) {
|
||||||
.unwrap_or_else(|| "{}".to_string());
|
tx.send(RenderTrigger::Invalidated).await.ok();
|
||||||
let Some(sse_tx) = state.connections.get_sender(&conn_id) else {
|
}
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let signals = match serde_json::from_str::<serde_json::Value>(&signals_json) {
|
|
||||||
Ok(v) => SignalStore::from_json(v),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("fanout: failed to parse signals for {}: {}", conn_id, e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse the component tree from the `tree` signal.
|
|
||||||
let tree = signals
|
|
||||||
.get("tree")
|
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut cx = RenderContext::new(
|
|
||||||
conn_id,
|
|
||||||
signals,
|
|
||||||
tree,
|
|
||||||
state.store.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let render_fn = state.render_fn.clone();
|
|
||||||
let state_ref = state.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match render_fn(cx).await {
|
|
||||||
Ok((html, keys)) => {
|
|
||||||
state_ref.subscriptions.update(conn_id, keys);
|
|
||||||
|
|
||||||
// Build a datastar-patch-elements SSE event.
|
|
||||||
// Each HTML line gets an `elements ` prefix; axum's
|
|
||||||
// Event::data() splits on \n and writes a `data:`
|
|
||||||
// line for each, which is correct SSE multiline format.
|
|
||||||
let data_payload: String = html
|
|
||||||
.lines()
|
|
||||||
.map(|line| format!("elements {}", line))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
let event = Event::default()
|
|
||||||
.event("datastar-patch-elements")
|
|
||||||
.data(data_payload);
|
|
||||||
|
|
||||||
if sse_tx.send(event).await.is_err() {
|
|
||||||
tracing::debug!("fanout: SSE channel closed for {}", conn_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("fanout: render error for {}: {}", conn_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -184,75 +304,45 @@ pub struct SseParams {
|
|||||||
|
|
||||||
/// `GET /sse?conn=<uuid>` — Long-lived SSE pipe for server-initiated push.
|
/// `GET /sse?conn=<uuid>` — Long-lived SSE pipe for server-initiated push.
|
||||||
///
|
///
|
||||||
/// The conn_id is generated by `GET /` and pre-seeded with initial signals
|
/// On first connect, spawns a `connection_task` that owns the lifecycle of the
|
||||||
/// in `ConnectionManager` before this handler is called. On connect, the
|
/// connection. On reconnect with the same conn_id, hands a new SSE pipe to the
|
||||||
/// handler immediately triggers a render and pushes the result down the pipe,
|
/// already-running task. The task survives disconnects for RECONNECT_GRACE_SECS.
|
||||||
/// so the client sees content without any further round-trip.
|
|
||||||
pub async fn sse_handler(
|
pub async fn sse_handler(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
AxumQuery(params): AxumQuery<SseParams>,
|
AxumQuery(params): AxumQuery<SseParams>,
|
||||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
let conn_id = params.conn;
|
let conn_id = params.conn;
|
||||||
let (tx, rx) = mpsc::channel::<Event>(32);
|
|
||||||
let tx_watch = tx.clone();
|
|
||||||
|
|
||||||
state.connections.insert(conn_id, tx.clone());
|
let pipe_tx: mpsc::Sender<mpsc::Sender<Event>> = if !state.connections.contains(&conn_id) {
|
||||||
|
// Fresh connection — create channels and spawn the connection task.
|
||||||
|
let (trigger_tx, trigger_rx) = mpsc::channel::<RenderTrigger>(32);
|
||||||
|
let (pipe_tx, pipe_rx) = mpsc::channel::<mpsc::Sender<Event>>(4);
|
||||||
|
|
||||||
// Cleanup on disconnect
|
let initial_signals = state
|
||||||
let state_cleanup = state.clone();
|
.connections
|
||||||
tokio::spawn(async move {
|
.get_signals(&conn_id)
|
||||||
tx_watch.closed().await;
|
.unwrap_or_else(|| "{}".to_string());
|
||||||
state_cleanup.connections.remove(&conn_id);
|
|
||||||
state_cleanup.subscriptions.remove_connection(&conn_id);
|
|
||||||
tracing::debug!("cleaned up connection {}", conn_id);
|
|
||||||
});
|
|
||||||
println!("SSE Handler called");
|
|
||||||
|
|
||||||
// Trigger first render immediately using the pre-seeded signals.
|
state.connections.insert(conn_id, trigger_tx, pipe_tx.clone());
|
||||||
let signals_json = state.connections.get_signals(&conn_id)
|
|
||||||
.unwrap_or_else(|| "{}".to_string());
|
|
||||||
|
|
||||||
let render_fn = state.render_fn.clone();
|
let state_task = state.clone();
|
||||||
let store = state.store.clone();
|
tokio::spawn(async move {
|
||||||
let subscriptions = state.subscriptions.clone();
|
connection_task(conn_id, trigger_rx, pipe_rx, initial_signals, state_task).await;
|
||||||
let tx_init = tx.clone();
|
});
|
||||||
|
|
||||||
tokio::spawn(async move {
|
pipe_tx
|
||||||
let signals = match serde_json::from_str::<serde_json::Value>(&signals_json) {
|
} else {
|
||||||
Ok(v) => SignalStore::from_json(v),
|
// Reconnect — retrieve existing pipe sender.
|
||||||
Err(e) => {
|
state.connections.get_pipe_sender(&conn_id)
|
||||||
tracing::error!("sse_handler: failed to parse seeded signals: {}", e);
|
.expect("connection exists but has no pipe_tx")
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
let tree = signals
|
|
||||||
.get("tree")
|
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let cx = RenderContext::new(conn_id, signals, tree, store);
|
// Create a fresh SSE channel for this HTTP connection's lifetime.
|
||||||
match render_fn(cx).await {
|
let (sse_tx, sse_rx) = mpsc::channel::<Event>(32);
|
||||||
Ok((html, keys)) => {
|
pipe_tx.send(sse_tx).await.ok();
|
||||||
subscriptions.update(conn_id, keys);
|
|
||||||
let data_payload = html
|
|
||||||
.lines()
|
|
||||||
.map(|line| format!("elements {}", line))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
let event = Event::default()
|
|
||||||
.event("datastar-patch-elements")
|
|
||||||
.data(data_payload);
|
|
||||||
tx_init.send(event).await.ok();
|
|
||||||
}
|
|
||||||
Err(e) => tracing::error!("sse_handler: initial render failed: {}", e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let stream = ReceiverStream::new(sse_rx);
|
||||||
let stream = ReceiverStream::new(rx);
|
let event_stream = tokio_stream::StreamExt::map(stream, |event| Ok::<Event, Infallible>(event));
|
||||||
let event_stream = tokio_stream::StreamExt::map(stream, |event| {
|
|
||||||
Ok::<Event, Infallible>(event)
|
|
||||||
});
|
|
||||||
|
|
||||||
Sse::new(event_stream).keep_alive(KeepAlive::default())
|
Sse::new(event_stream).keep_alive(KeepAlive::default())
|
||||||
}
|
}
|
||||||
@@ -294,14 +384,22 @@ pub struct ActionParams {
|
|||||||
/// StatusCode::ACCEPTED
|
/// StatusCode::ACCEPTED
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn ingest_signals(
|
pub fn ingest_signals(state: &AppState, conn_id: Uuid, body: serde_json::Value) -> SignalStore {
|
||||||
state: &AppState,
|
|
||||||
conn_id: Uuid,
|
|
||||||
body: serde_json::Value,
|
|
||||||
) -> SignalStore {
|
|
||||||
let store = SignalStore::from_json(body);
|
let store = SignalStore::from_json(body);
|
||||||
state
|
state
|
||||||
.connections
|
.connections
|
||||||
.update_signals(&conn_id, store.to_json_string());
|
.update_signals(&conn_id, store.to_json_string());
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn patch_signal(state: &AppState, conn_id: &Uuid, key: &str, value: serde_json::Value) {
|
||||||
|
let current = state
|
||||||
|
.connections
|
||||||
|
.get_signals(conn_id)
|
||||||
|
.unwrap_or_else(|| "{}".to_string());
|
||||||
|
let mut store = SignalStore::from_json(serde_json::from_str(¤t).unwrap_or_default());
|
||||||
|
store.insert(key.to_string(), value);
|
||||||
|
state
|
||||||
|
.connections
|
||||||
|
.update_signals(conn_id, store.to_json_string());
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub fn format_patch_elements(html: &str) -> String {
|
|||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Format a `datastar-patch-signals` SSE event.
|
/// Format a `datastar-patch-signals` SSE event.
|
||||||
///
|
///
|
||||||
/// `signals_js` should be a JS object literal string, e.g. `{foo: '', bar: 0}`.
|
/// `signals_js` should be a JS object literal string, e.g. `{foo: '', bar: 0}`.
|
||||||
|
|||||||
+18
-1
@@ -108,7 +108,7 @@ impl Store {
|
|||||||
/// that can be buffered before slow receivers start lagging. The fanout
|
/// that can be buffered before slow receivers start lagging. The fanout
|
||||||
/// task should be fast enough that this is never hit in practice.
|
/// task should be fast enough that this is never hit in practice.
|
||||||
pub fn new(db: sqlx::SqlitePool) -> Arc<Self> {
|
pub fn new(db: sqlx::SqlitePool) -> Arc<Self> {
|
||||||
let (events_tx, _) = broadcast::channel(256);
|
let (events_tx, _) = broadcast::channel(16384);
|
||||||
let kv = Arc::new(KVStore::new(events_tx.clone()));
|
let kv = Arc::new(KVStore::new(events_tx.clone()));
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
db,
|
db,
|
||||||
@@ -137,4 +137,21 @@ impl Store {
|
|||||||
pub fn subscribe(&self) -> broadcast::Receiver<CacheKey> {
|
pub fn subscribe(&self) -> broadcast::Receiver<CacheKey> {
|
||||||
self.events.subscribe()
|
self.events.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute a mutation query and auto-publish its invalidation key.
|
||||||
|
///
|
||||||
|
/// Use this in action handlers instead of calling the plain async mutation
|
||||||
|
/// function and then `store.publish(key)` separately. The mutation's
|
||||||
|
/// `publish_key()` drives the fanout automatically.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// state.store.exec(CreateTodo { title }).await?;
|
||||||
|
/// ```
|
||||||
|
pub async fn exec<Q: crate::query::Query>(&self, query: Q) -> anyhow::Result<Q::Output> {
|
||||||
|
let result = query.execute(&self.db).await?;
|
||||||
|
if let Some(key) = query.publish_key() {
|
||||||
|
self.publish(key);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Here's a list of things I still want to get done
|
||||||
|
|
||||||
|
- [ ] refactor or change the query contract so for pg_queries we keep auto setting key but for sqlite we get the option to not only set a cache invalidation key, we also get the option to publish a cache invalidation event. so a create_todo query would automaitcally trigger a fanout when cx.run gets to it.
|
||||||
|
- [ ] the sse file is unsused and the function bodies are inlined in server.rs. It's also wrong and the content of server.rs are correct.
|
||||||
|
- [ ] fix the pg_replication example such that it does not pass the {inner} in the initial render but intead pushes it over the sse connection. This is exclusively such that the signals are not yet in scope when the sse initiates to keep the noisy signals out of the get parameters
|
||||||
|
- might accomplish this by including filterSignals in the `@get(/route, options = {filterSignals = })` and then I could put some regex to filter out literally everything.
|
||||||
|
- [ ] Make another example site, this one maybe demo'ing cache-backed queries
|
||||||
|
- [ ] Review example code and extract common logic to framework code
|
||||||
|
- [ ] consider moving connId to a header by passing the following in the @get options:
|
||||||
|
headers: {
|
||||||
|
'X-Conn-Id': 'some_uuid',
|
||||||
|
},
|
||||||
|
- [ ]
|
||||||
Reference in New Issue
Block a user