diff --git a/Cargo.lock b/Cargo.lock index 547197e307..5a021d9a80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,10 +196,13 @@ dependencies = [ "aes-gcm", "affine_common", "anyhow", + "aws-sdk-s3", + "base64", "chrono", "doc_extractor", "file-format", "hex", + "homedir", "image", "infer", "jsonschema", @@ -222,9 +225,11 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sha3", + "sqlx", "tiktoken-rs", "tokio", "url", + "uuid", "v_htmlescape", "y-octo", ] @@ -414,6 +419,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -557,6 +571,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -579,6 +605,328 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2dd7213994e2ff9382ff100403b78c30d1b74cdfcd8fa9d0d1dc3a94a5c4874" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5 0.11.0", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "az" version = "1.3.0" @@ -904,6 +1252,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "camino" version = "1.2.2" @@ -1177,6 +1535,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -1489,6 +1853,16 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.0", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1684,6 +2058,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "dary_heap" version = "0.3.8" @@ -1777,6 +2160,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2223,7 +2607,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -2599,6 +2983,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -2610,7 +3013,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -2791,18 +3194,18 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "h2", + "h2 0.4.13", "hickory-proto", - "http", + "http 1.4.0", "idna", "ipnet", "jni 0.22.4", "rand 0.10.1", - "rustls", + "rustls 0.23.37", "thiserror 2.0.18", "tinyvec", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tracing", "url", "webpki-roots", @@ -2847,12 +3250,12 @@ dependencies = [ "parking_lot", "rand 0.10.1", "resolv-conf", - "rustls", + "rustls 0.23.37", "smallvec", "system-configuration", "thiserror 2.0.18", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tracing", "webpki-roots", ] @@ -2863,7 +3266,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2875,6 +3278,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -2910,6 +3322,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -2920,6 +3343,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2927,7 +3361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2938,8 +3372,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2949,6 +3383,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -2958,6 +3398,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2968,9 +3432,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -2979,18 +3443,34 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.37", + "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -3004,14 +3484,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3479,7 +3959,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2", + "socket2 0.6.3", "widestring", "windows-registry", "windows-result 0.4.1", @@ -3734,7 +4214,7 @@ dependencies = [ "regex", "regex-syntax", "reqwest", - "rustls", + "rustls 0.23.37", "serde", "serde_json", "unicode-general-category", @@ -3799,7 +4279,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -4129,6 +4609,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -5042,6 +5532,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pinyin" version = "0.11.0" @@ -5403,8 +5899,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls", - "socket2", + "rustls 0.23.37", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5424,7 +5920,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5442,7 +5938,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -5705,6 +6201,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -5722,26 +6224,26 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -5763,7 +6265,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -5889,6 +6391,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.37" @@ -5900,7 +6414,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -5938,10 +6452,10 @@ dependencies = [ "jni 0.21.1", "log", "once_cell", - "rustls", + "rustls 0.23.37", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -5954,6 +6468,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6006,7 +6530,7 @@ dependencies = [ "hickory-resolver", "image", "reqwest", - "rustls", + "rustls 0.23.37", "tokio", "url", "webpki-roots", @@ -6102,6 +6626,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sec1" version = "0.7.3" @@ -6249,6 +6783,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -6419,6 +6964,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -6438,6 +6993,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -6557,17 +7118,17 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", "rand 0.8.6", "rsa", "serde", - "sha1", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", "sqlx-core", @@ -6596,11 +7157,11 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.6", @@ -7312,7 +7873,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -7328,13 +7889,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -7466,8 +8037,8 @@ dependencies = [ "bitflags 2.11.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -8318,7 +8889,7 @@ dependencies = [ "flate2", "log", "percent-encoding", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "ureq-proto", "utf8-zero", @@ -8332,7 +8903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64", - "http", + "http 1.4.0", "httparse", "log", ] @@ -8594,7 +9165,7 @@ version = "0.51.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff" dependencies = [ - "spin", + "spin 0.9.8", "wasmi_collections", "wasmi_core", "wasmi_ir", diff --git a/Cargo.toml b/Cargo.toml index 4fc617d680..6825ce9386 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ resolver = "3" anyhow = "1" arbitrary = { version = "1.3", features = ["derive"] } assert-json-diff = "2.0" + base64 = "0.22.1" base64-simd = "0.8" bitvec = "1.0" block2 = "0.6" diff --git a/packages/backend/native/Cargo.toml b/packages/backend/native/Cargo.toml index 346c306414..d53d121c90 100644 --- a/packages/backend/native/Cargo.toml +++ b/packages/backend/native/Cargo.toml @@ -16,10 +16,13 @@ affine_common = { workspace = true, features = [ "ydoc-loader", ] } anyhow = { workspace = true } +aws-sdk-s3 = "1.115" +base64 = { workspace = true } chrono = { workspace = true } doc_extractor = { workspace = true } file-format = { workspace = true } hex = { workspace = true } +homedir = { workspace = true } image = { workspace = true } infer = { workspace = true } jsonschema = "0.46" @@ -39,8 +42,18 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } sha3 = { workspace = true } +sqlx = { workspace = true, default-features = false, features = [ + "chrono", + "json", + "macros", + "migrate", + "postgres", + "runtime-tokio", +] } tiktoken-rs = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } url = { workspace = true } +uuid = { workspace = true, features = ["v4"] } v_htmlescape = { workspace = true } y-octo = { workspace = true, features = ["large_refs"] } @@ -52,7 +65,7 @@ mimalloc = { workspace = true, features = ["local_dynamic_tls"] } [dev-dependencies] rayon = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [build-dependencies] napi-build = { workspace = true } diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index e19c6138f2..a17ad5b553 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -1,5 +1,66 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +export declare class BackendRuntime { + completeBlobUpload(workspaceId: string, key: string, expectedSize: number, expectedMime: string): Promise + completeFsBlobUpload(root: string, bucket: string, workspaceId: string, key: string, expectedSize: number, expectedMime: string): Promise + cleanupExpiredPendingBlobs(cutoffMs: number, limit: number): Promise + releaseDeletedBlobs(workspaceId: string, limit: number): Promise + acquireCoordinationLease(key: string, owner: string, ttlMs: number): Promise + releaseCoordinationLease(key: string, owner: string, fencingToken: bigint | number): Promise + renewCoordinationLease(key: string, owner: string, fencingToken: bigint | number, ttlMs: number): Promise + /** + * Merge pending doc updates with y-octo and persist the merged snapshot. + * + * Do not use this for snapshots that will be sent back to yjs clients until + * the y-octo/yjs round-trip compatibility issue is resolved. + */ + compactPendingDocUpdates(workspaceId: string, docId: string, batchLimit: number, historyMinIntervalMs: number, owner: string, leaseTtlMs: number): Promise + upsertDocSnapshot(workspaceId: string, docId: string, blob: Buffer, timestampMs: number, editorId?: string | undefined | null): Promise + createDocHistory(input: RuntimeDocHistoryInput): Promise + deleteDocStorage(workspaceId: string, docId: string): Promise + putRuntimeGateIfAbsent(key: string, ttlMs: number): Promise + cleanupExpiredRuntimeGates(limit: number): Promise + cleanupExpiredUserSessions(limit: number): Promise + cleanupExpiredSnapshotHistories(limit: number): Promise + objectStorageHealth(): RuntimeObjectStorageHealth + objectStoragePut(key: string, body: Buffer, metadata?: RuntimeObjectStoragePutOptions | undefined | null): Promise + objectStoragePresignPut(key: string, metadata?: RuntimeObjectStoragePutOptions | undefined | null): Promise + objectStorageCreateMultipartUpload(key: string, metadata?: RuntimeObjectStoragePutOptions | undefined | null): Promise + objectStoragePresignUploadPart(key: string, uploadId: string, partNumber: number): Promise + objectStorageListMultipartUploadParts(key: string, uploadId: string): Promise> + objectStorageCompleteMultipartUpload(key: string, uploadId: string, parts: Array): Promise + objectStorageAbortMultipartUpload(key: string, uploadId: string): Promise + objectStorageHead(key: string): Promise + objectStorageGet(key: string): Promise + objectStorageList(prefix?: string | undefined | null): Promise> + objectStorageDelete(key: string): Promise + createAuthChallenge(purpose: string, token: string, payload: any, ttlMs: number): Promise + getAuthChallenge(purpose: string, token: string): Promise + consumeAuthChallenge(purpose: string, token: string): Promise + createVerificationToken(tokenType: number, credential: string | undefined | null, ttlMs: number): Promise + getVerificationToken(tokenType: number, token: string, keep?: boolean | undefined | null): Promise + verifyVerificationToken(tokenType: number, token: string, credential?: string | undefined | null, keep?: boolean | undefined | null): Promise + cleanupExpiredVerificationTokens(limit: number): Promise + upsertMagicLinkOtp(email: string, otpHash: string, token: string, clientNonce: string | undefined | null, ttlMs: number): Promise + consumeMagicLinkOtp(email: string, otpHash: string, clientNonce?: string | undefined | null): Promise + createWorkspaceInviteLink(workspaceId: string, inviteId: string, inviterUserId: string, ttlMs: number): Promise + getWorkspaceInviteLink(workspaceId: string): Promise + getWorkspaceInviteLinkById(inviteId: string): Promise + revokeWorkspaceInviteLink(workspaceId: string): Promise + createByokLocalLease(activeKey: string, leaseId: string, payload: any, ttlMs: number): Promise + getByokLocalLease(leaseId: string): Promise + cleanupExpiredRuntimeStates(limit: number): Promise + refreshWorkspaceAdminStatsDirty(batchLimit: number, owner: string, leaseTtlMs: number): Promise + recalibrateWorkspaceAdminStats(lastSid: number, batchLimit: number, owner: string, leaseTtlMs: number): Promise + writeWorkspaceAdminStatsDailySnapshot(owner: string, leaseTtlMs: number): Promise + recalibrateWorkspaceAdminStatsDaily(batchLimit: number, owner: string, leaseTtlMs: number, lockRetryTimes: number, lockRetryDelayMs: number): Promise + constructor() + start(): Promise + stop(): Promise + health(): Promise + runMigrations(): Promise +} + export declare class LlmStreamHandle { abort(): void } @@ -74,6 +135,12 @@ export interface AssertSafeUrlRequest { url: string } +export interface BackendRuntimeHealth { + started: boolean + databaseConnected: boolean + objectStorageConfigured: boolean +} + export declare function buildPublicRootDoc(rootDocBin: Buffer, docMetas: Array): Buffer export interface BuiltInPromptRenderContract { @@ -164,6 +231,12 @@ export interface CommandResponse { error?: LicenseError } +export interface CoordinationLeaseGrant { + key: string + owner: string + fencingToken: bigint | number +} + /** * Converts markdown content to AFFiNE-compatible y-octo document binary. * @@ -738,6 +811,147 @@ export declare function resolveEntitlementV1(input: ResolveEntitlementInput): Re export declare function runNativeActionRecipePreparedStream(input: ActionRuntimeInput, callback: ((err: Error | null, arg: string) => void)): LlmStreamHandle +export interface RuntimeBlobCleanupResult { + scanned: number + deleted: number + abortedMultipart: number + workspaceIds: Array +} + +export interface RuntimeBlobCompleteResult { + ok: boolean + reason?: string + contentType?: string + contentLength?: number + lastModifiedMs?: number +} + +export interface RuntimeByokLocalLeaseRecord { + leaseId: string + payload: any + expiresAtMs: number +} + +export interface RuntimeDocCompactionResult { + leaseAcquired: boolean + merged: boolean + workspaceId: string + docId: string + updatesMerged: number + historyCreated: boolean +} + +export interface RuntimeDocHistoryInput { + workspaceId: string + docId: string + blob: Buffer + timestampMs: number + editorId?: string + force: boolean + historyMinIntervalMs: number + historyMaxAgeMs: number +} + +export interface RuntimeMagicLinkOtpConsumeResult { + ok: boolean + token?: string + reason?: string +} + +export interface RuntimeMultipartUploadInit { + uploadId: string + expiresAtMs: number +} + +export interface RuntimeMultipartUploadPart { + partNumber: number + etag: string +} + +export interface RuntimeObjectGetResult { + body: Buffer + metadata: RuntimeObjectMetadata +} + +export interface RuntimeObjectListEntry { + key: string + contentLength: number + lastModifiedMs: number +} + +export interface RuntimeObjectMetadata { + contentType: string + contentLength: number + lastModifiedMs: number + checksumCrc32?: string +} + +export interface RuntimeObjectStorageHealth { + configured: boolean + provider?: string + bucket?: string + endpoint?: string + region?: string + hasCredentials: boolean + forcePathStyle: boolean + requestTimeoutMs?: number + minPartSize?: number + presignExpiresInSeconds?: number + presignSignContentTypeForPut?: boolean + usePresignedUrl: boolean + clientBuildable: boolean +} + +export interface RuntimeObjectStoragePutOptions { + contentType?: string + contentLength?: number + checksumCrc32?: string +} + +export interface RuntimePresignedObjectRequest { + url: string + headersJson: string + expiresAtMs: number +} + +export interface RuntimeVerificationTokenRecord { + tokenType: number + token: string + credential?: string + expiresAtMs: number +} + +export interface RuntimeWorkspaceInviteLinkRecord { + workspaceId: string + inviteId: string + inviterUserId: string + expiresAtMs: number +} + +export interface RuntimeWorkspaceStatsDailyRecalibrationResult { + processed: number + lastSid: number + snapshotted: number + skipped: boolean +} + +export interface RuntimeWorkspaceStatsRecalibrationResult { + processed: number + lastSid: number + skipped: boolean +} + +export interface RuntimeWorkspaceStatsRefreshResult { + processed: number + backlog: number + skipped: boolean +} + +export interface RuntimeWorkspaceStatsSnapshotResult { + snapshotted: number + skipped: boolean +} + export declare function safeFetch(request: SafeFetchRequest): Promise export type SafeFetchMethod = 'get'| diff --git a/packages/backend/native/package.json b/packages/backend/native/package.json index df287ef4a8..b36fe9f474 100644 --- a/packages/backend/native/package.json +++ b/packages/backend/native/package.json @@ -29,7 +29,7 @@ "test": "node --test ./__tests__/**/*.spec.js", "bench": "node ./benchmark/index.js", "build": "napi build --release --strip --no-const-enum", - "build:debug": "napi build" + "build:debug": "napi build --no-const-enum" }, "devDependencies": { "@napi-rs/cli": "3.5.0", diff --git a/packages/backend/native/src/backend_runtime/blob_complete.rs b/packages/backend/native/src/backend_runtime/blob_complete.rs new file mode 100644 index 0000000000..28fe0334ac --- /dev/null +++ b/packages/backend/native/src/backend_runtime/blob_complete.rs @@ -0,0 +1,296 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use napi::Result; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use super::{BackendRuntime, error::napi_error, types::RuntimeBlobCompleteResult}; + +const MAX_BLOB_SIZE: i64 = i32::MAX as i64; + +fn object_missing_error(err: &napi::Error) -> bool { + let message = err.to_string(); + message.contains("NoSuchKey") || message.contains("NotFound") || message.contains("not found") +} + +fn blob_complete_failure(reason: &str) -> RuntimeBlobCompleteResult { + RuntimeBlobCompleteResult { + ok: false, + reason: Some(reason.to_string()), + content_type: None, + content_length: None, + last_modified_ms: None, + } +} + +fn blob_complete_success( + content_type: String, + content_length: i64, + last_modified_ms: i64, +) -> RuntimeBlobCompleteResult { + RuntimeBlobCompleteResult { + ok: true, + reason: None, + content_type: Some(content_type), + content_length: Some(content_length), + last_modified_ms: Some(last_modified_ms), + } +} + +fn normalize_base64_url_key(key: &str) -> &str { + key.trim_end_matches('=') +} + +fn sha256_base64_url(body: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(body)) +} + +fn sha256_base64_url_matches(body: &[u8], key: &str) -> bool { + sha256_base64_url(body) == normalize_base64_url_key(key) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FsBlobMetadata { + content_type: String, + content_length: i64, + last_modified: i64, +} + +fn normalize_storage_key(key: &str) -> Result> { + let normalized = key.replace('\\', "/"); + let segments = normalized.split('/').map(ToString::to_string).collect::>(); + + if normalized.is_empty() + || normalized.starts_with('/') + || segments + .iter() + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return Err(napi_error(format!("Invalid storage key: {key}"))); + } + + Ok(segments) +} + +fn fs_bucket_path(root: &str, bucket: &str) -> PathBuf { + if let Some(stripped) = root.strip_prefix("~/") + && let Ok(Some(home)) = homedir::my_home() + { + return home.join(stripped).join(bucket); + } + + Path::new(root).join(bucket) +} + +fn fs_object_path(root: &str, bucket: &str, key: &str) -> Result { + let mut path = fs_bucket_path(root, bucket); + for segment in normalize_storage_key(key)? { + path.push(segment); + } + Ok(path) +} + +fn read_fs_metadata(path: &Path) -> Result> { + let metadata_path = PathBuf::from(format!("{}.metadata.json", path.display())); + let raw = match fs::read_to_string(metadata_path) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(napi_error(format!("BlobComplete read fs metadata failed: {err}"))); + } + }; + + serde_json::from_str(&raw).map(Some).map_err(|err| { + napi_error(format!( + "BlobComplete parse fs metadata failed for {}: {err}", + path.display() + )) + }) +} + +async fn upsert_completed_blob( + runtime: &BackendRuntime, + workspace_id: &str, + key: &str, + mime: &str, + size: i64, +) -> Result<()> { + if !(0..=MAX_BLOB_SIZE).contains(&size) { + return Err(napi_error("BlobComplete size exceeds limit")); + } + let size = i32::try_from(size).map_err(|_| napi_error("BlobComplete size exceeds limit"))?; + + sqlx::query( + r#" + INSERT INTO blobs (workspace_id, key, mime, size, status, upload_id) + VALUES ($1, $2, $3, $4, 'completed', NULL) + ON CONFLICT (workspace_id, key) + DO UPDATE SET + mime = EXCLUDED.mime, + size = EXCLUDED.size, + status = EXCLUDED.status, + upload_id = NULL + "#, + ) + .bind(workspace_id) + .bind(key) + .bind(mime) + .bind(size) + .execute(&runtime.pool().await?) + .await + .map_err(|err| napi_error(format!("BlobComplete upsert metadata failed: {err}")))?; + + Ok(()) +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn complete_blob_upload( + &self, + workspace_id: String, + key: String, + expected_size: i64, + expected_mime: String, + ) -> Result { + if !(0..=MAX_BLOB_SIZE).contains(&expected_size) { + return Ok(blob_complete_failure("size_too_large")); + } + + let object_key = format!("{workspace_id}/{key}"); + let object = match self.object_storage_get(object_key.clone()).await { + Ok(Some(object)) => object, + Ok(None) => return Ok(blob_complete_failure("not_found")), + Err(err) if object_missing_error(&err) => return Ok(blob_complete_failure("not_found")), + Err(err) => return Err(err), + }; + + if !(0..=MAX_BLOB_SIZE).contains(&object.metadata.content_length) { + match self.object_storage_delete(object_key).await { + Ok(()) => {} + Err(err) if object_missing_error(&err) => {} + Err(err) => return Err(err), + } + return Ok(blob_complete_failure("size_too_large")); + } + if object.metadata.content_length != expected_size { + return Ok(blob_complete_failure("size_mismatch")); + } + + if !expected_mime.is_empty() && object.metadata.content_type != expected_mime { + return Ok(blob_complete_failure("mime_mismatch")); + } + + if !sha256_base64_url_matches(&object.body, &key) { + match self.object_storage_delete(object_key).await { + Ok(()) => {} + Err(err) if object_missing_error(&err) => {} + Err(err) => return Err(err), + } + return Ok(blob_complete_failure("checksum_mismatch")); + } + + upsert_completed_blob( + self, + &workspace_id, + &key, + &object.metadata.content_type, + object.metadata.content_length, + ) + .await?; + + Ok(blob_complete_success( + object.metadata.content_type, + object.metadata.content_length, + object.metadata.last_modified_ms, + )) + } + + #[napi] + pub async fn complete_fs_blob_upload( + &self, + root: String, + bucket: String, + workspace_id: String, + key: String, + expected_size: i64, + expected_mime: String, + ) -> Result { + if !(0..=MAX_BLOB_SIZE).contains(&expected_size) { + return Ok(blob_complete_failure("size_too_large")); + } + + let storage_key = format!("{workspace_id}/{key}"); + let path = fs_object_path(&root, &bucket, &storage_key)?; + let metadata = match read_fs_metadata(&path)? { + Some(metadata) => metadata, + None => return Ok(blob_complete_failure("not_found")), + }; + + if !(0..=MAX_BLOB_SIZE).contains(&metadata.content_length) { + let _ = fs::remove_file(&path); + let _ = fs::remove_file(PathBuf::from(format!("{}.metadata.json", path.display()))); + return Ok(blob_complete_failure("size_too_large")); + } + if metadata.content_length != expected_size { + return Ok(blob_complete_failure("size_mismatch")); + } + + if !expected_mime.is_empty() && metadata.content_type != expected_mime { + return Ok(blob_complete_failure("mime_mismatch")); + } + + let body = match fs::read(&path) { + Ok(body) => body, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(blob_complete_failure("not_found")), + Err(err) => return Err(napi_error(format!("BlobComplete read fs object failed: {err}"))), + }; + + if !sha256_base64_url_matches(&body, &key) { + let _ = fs::remove_file(&path); + let _ = fs::remove_file(PathBuf::from(format!("{}.metadata.json", path.display()))); + return Ok(blob_complete_failure("checksum_mismatch")); + } + + upsert_completed_blob( + self, + &workspace_id, + &key, + &metadata.content_type, + metadata.content_length, + ) + .await?; + + Ok(blob_complete_success( + metadata.content_type, + metadata.content_length, + metadata.last_modified, + )) + } +} + +#[cfg(test)] +mod tests { + use super::{sha256_base64_url, sha256_base64_url_matches}; + + #[test] + fn sha256_base64_url_omits_padding() { + assert_eq!( + sha256_base64_url(b"hello"), + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" + ); + } + + #[test] + fn sha256_base64_url_matches_legacy_padding() { + assert!(sha256_base64_url_matches( + b"hello", + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" + )); + } +} diff --git a/packages/backend/native/src/backend_runtime/blob_reclaimer.rs b/packages/backend/native/src/backend_runtime/blob_reclaimer.rs new file mode 100644 index 0000000000..a4517aa9f2 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/blob_reclaimer.rs @@ -0,0 +1,190 @@ +use chrono::{DateTime, Utc}; +use napi::Result; +use sqlx::{FromRow, PgPool}; + +use super::{BackendRuntime, error::napi_error, types::RuntimeBlobCleanupResult}; + +#[derive(FromRow)] +struct BlobRow { + workspace_id: String, + key: String, + upload_id: Option, +} + +struct BlobReclaimerStore { + pool: PgPool, +} + +impl BlobReclaimerStore { + fn new(pool: PgPool) -> Self { + Self { pool } + } + + async fn load_expired_pending(&self, cutoff: DateTime, limit: i64) -> Result> { + sqlx::query_as::<_, BlobRow>( + r#" + SELECT workspace_id, key, upload_id + FROM blobs + WHERE status = 'pending' + AND deleted_at IS NULL + AND created_at < $1 + ORDER BY created_at ASC + LIMIT $2 + "#, + ) + .bind(cutoff) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(|err| napi_error(format!("BlobReclaimer load pending blobs failed: {err}"))) + } + + async fn load_deleted(&self, workspace_id: &str, limit: i64) -> Result> { + sqlx::query_as::<_, BlobRow>( + r#" + SELECT workspace_id, key, upload_id + FROM blobs + WHERE workspace_id = $1 + AND deleted_at IS NOT NULL + ORDER BY deleted_at ASC + LIMIT $2 + "#, + ) + .bind(workspace_id) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(|err| napi_error(format!("BlobReclaimer load deleted blobs failed: {err}"))) + } + + async fn delete_pending_metadata(&self, workspace_id: &str, key: &str) -> Result { + let result = sqlx::query( + r#" + DELETE FROM blobs + WHERE workspace_id = $1 AND key = $2 + AND status = 'pending' + AND deleted_at IS NULL + "#, + ) + .bind(workspace_id) + .bind(key) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("BlobReclaimer delete pending blob metadata failed: {err}")))?; + Ok(result.rows_affected() as i64) + } + + async fn delete_released_metadata(&self, workspace_id: &str, key: &str) -> Result { + let result = sqlx::query( + r#" + DELETE FROM blobs + WHERE workspace_id = $1 AND key = $2 + AND deleted_at IS NOT NULL + "#, + ) + .bind(workspace_id) + .bind(key) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("BlobReclaimer delete blob metadata failed: {err}")))?; + Ok(result.rows_affected() as i64) + } +} + +fn object_missing_error(err: &napi::Error) -> bool { + let message = err.to_string(); + message.contains("NoSuchKey") + || message.contains("NoSuchUpload") + || message.contains("NotFound") + || message.contains("not found") +} + +async fn delete_object_idempotent(runtime: &BackendRuntime, key: &str) -> Result<()> { + match runtime.object_storage_delete_object(key).await { + Ok(()) => Ok(()), + Err(err) if object_missing_error(&err) => Ok(()), + Err(err) => Err(err), + } +} + +async fn abort_upload_idempotent(runtime: &BackendRuntime, key: &str, upload_id: &str) -> Result<()> { + match runtime.object_storage_abort_upload(key, upload_id).await { + Ok(()) => Ok(()), + Err(err) if object_missing_error(&err) => Ok(()), + Err(err) => Err(err), + } +} + +fn push_workspace_once(workspace_ids: &mut Vec, workspace_id: &str) { + if !workspace_ids.iter().any(|id| id == workspace_id) { + workspace_ids.push(workspace_id.to_string()); + } +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn cleanup_expired_pending_blobs(&self, cutoff_ms: i64, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("pending blob cleanup limit must be positive")); + } + + let cutoff = DateTime::::from_timestamp_millis(cutoff_ms) + .ok_or_else(|| napi_error("pending blob cleanup cutoff is invalid"))?; + let store = BlobReclaimerStore::new(self.pool().await?); + let rows = store.load_expired_pending(cutoff, limit).await?; + + let mut deleted = 0; + let mut aborted_multipart = 0; + let mut workspace_ids = Vec::new(); + for row in &rows { + let object_key = format!("{}/{}", row.workspace_id, row.key); + if let Some(upload_id) = row.upload_id.as_deref() { + abort_upload_idempotent(self, &object_key, upload_id).await?; + aborted_multipart += 1; + } + delete_object_idempotent(self, &object_key).await?; + let affected = store.delete_pending_metadata(&row.workspace_id, &row.key).await?; + if affected > 0 { + deleted += affected; + push_workspace_once(&mut workspace_ids, &row.workspace_id); + } + } + + Ok(RuntimeBlobCleanupResult { + scanned: rows.len() as i64, + deleted, + aborted_multipart, + workspace_ids, + }) + } + + #[napi] + pub async fn release_deleted_blobs(&self, workspace_id: String, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("deleted blob release limit must be positive")); + } + + let store = BlobReclaimerStore::new(self.pool().await?); + let rows = store.load_deleted(&workspace_id, limit).await?; + + let mut deleted = 0; + let mut workspace_ids = Vec::new(); + for row in &rows { + let object_key = format!("{}/{}", row.workspace_id, row.key); + delete_object_idempotent(self, &object_key).await?; + let affected = store.delete_released_metadata(&row.workspace_id, &row.key).await?; + if affected > 0 { + deleted += affected; + push_workspace_once(&mut workspace_ids, &row.workspace_id); + } + } + + Ok(RuntimeBlobCleanupResult { + scanned: rows.len() as i64, + deleted, + aborted_multipart: 0, + workspace_ids, + }) + } +} diff --git a/packages/backend/native/src/backend_runtime/config.rs b/packages/backend/native/src/backend_runtime/config.rs new file mode 100644 index 0000000000..9543f72c8e --- /dev/null +++ b/packages/backend/native/src/backend_runtime/config.rs @@ -0,0 +1,128 @@ +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; + +use napi::Result; +use serde::Deserialize; + +use super::{ + error::napi_error, + object_storage::{ObjectStorageConfig, StorageProviderConfig}, +}; + +#[derive(Clone, Debug)] +pub(super) struct RuntimeConfig { + pub(super) database_url: String, + pub(super) storage: Option, +} + +impl RuntimeConfig { + pub(super) fn from_config_files() -> Result { + let database_url = + database_url_from_config_files()?.unwrap_or_else(|| "postgresql://localhost:5432/affine".to_string()); + let storage = ObjectStorageConfig::from_config_files()?; + Ok(Self { database_url, storage }) + } +} + +#[derive(Debug, Deserialize)] +struct AppConfigFile { + db: Option, + storages: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DbConfigFile { + datasource_url: Option, +} + +fn database_url_from_config_files() -> Result> { + let mut database_url = None; + for path in config_json_paths() { + if !path.exists() { + continue; + } + let raw = fs::read_to_string(&path) + .map_err(|err| napi_error(format!("failed to read config file {}: {err}", path.display())))?; + let config: AppConfigFile = serde_json::from_str(&raw) + .map_err(|err| napi_error(format!("failed to parse config file {}: {err}", path.display())))?; + if let Some(next) = config.db.and_then(|db| db.datasource_url) + && !next.trim().is_empty() + { + database_url = Some(next); + } + } + + Ok(database_url) +} + +pub(super) fn blob_storage_config_from_config_files() -> Result> { + let mut storage = None; + for path in config_json_paths() { + if !path.exists() { + continue; + } + let raw = fs::read_to_string(&path) + .map_err(|err| napi_error(format!("failed to read config file {}: {err}", path.display())))?; + let config: AppConfigFile = serde_json::from_str(&raw) + .map_err(|err| napi_error(format!("failed to parse config file {}: {err}", path.display())))?; + if let Some(next) = config.storages.and_then(|mut storages| storages.remove("blob.storage")) { + storage = Some(next); + } + } + + Ok(storage) +} + +pub(super) fn config_json_paths() -> Vec { + let mut paths = Vec::new(); + if let Ok(exe) = env::current_exe() + && let Some(dir) = exe.parent() + { + paths.push(config_in(dir)); + } + if let Ok(cwd) = env::current_dir() { + paths.push(config_in(&cwd)); + } + dedupe_paths(paths) +} + +fn config_in(dir: &Path) -> PathBuf { + dir.join("config.json") +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut deduped = Vec::new(); + for path in paths { + if !deduped.contains(&path) { + deduped.push(path); + } + } + deduped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_paths_are_limited_to_executable_dir_and_cwd() { + let paths = config_json_paths(); + assert!(!paths.is_empty()); + assert!(paths.len() <= 2); + assert!( + paths + .iter() + .all(|path| path.file_name().is_some_and(|name| name == "config.json")) + ); + assert!(paths.iter().all(|path| !path.to_string_lossy().contains(".affine"))); + assert!( + paths + .iter() + .all(|path| !path.to_string_lossy().contains("packages/backend/server")) + ); + } +} diff --git a/packages/backend/native/src/backend_runtime/constants.rs b/packages/backend/native/src/backend_runtime/constants.rs new file mode 100644 index 0000000000..fc3138696a --- /dev/null +++ b/packages/backend/native/src/backend_runtime/constants.rs @@ -0,0 +1,11 @@ +pub(super) const DEFAULT_HISTORY_PERIOD_SECONDS: i32 = 7 * 24 * 60 * 60; +pub(super) const BYOK_LOCAL_LEASE_ACTIVE_PURPOSE: &str = "copilot_byok_local_lease:active"; +pub(super) const BYOK_LOCAL_LEASE_PURPOSE: &str = "copilot_byok_local_lease"; +pub(super) const MAGIC_LINK_OTP_PURPOSE: &str = "magic_link_otp"; +pub(super) const MAX_MAGIC_LINK_OTP_ATTEMPTS: i32 = 10; +pub(super) const WORKSPACE_INVITE_LINK_ID_PURPOSE: &str = "workspace_invite_link:id"; +pub(super) const WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE: &str = "workspace_invite_link:workspace"; +pub(super) const WORKSPACE_STATS_LEASE_KEY: &str = "workspace:admin-stats:refresh"; +pub(super) const WORKSPACE_STATS_LOCK_NAMESPACE: i64 = 97_301; +pub(super) const WORKSPACE_STATS_REFRESH_LOCK_KEY: i64 = 1; +pub(super) const RUNTIME_MIGRATIONS: &str = include_str!("sql/runtime_migrations.sql"); diff --git a/packages/backend/native/src/backend_runtime/coordination_lease.rs b/packages/backend/native/src/backend_runtime/coordination_lease.rs new file mode 100644 index 0000000000..b048a05e92 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/coordination_lease.rs @@ -0,0 +1,138 @@ +use napi::Result; +use sqlx::{FromRow, PgPool}; + +use super::{BackendRuntime, error::napi_error, types::CoordinationLeaseGrant}; + +#[derive(FromRow)] +struct LeaseGrantRow { + fencing_token: i64, +} + +struct CoordinationLeaseStore { + pool: PgPool, +} + +impl CoordinationLeaseStore { + fn new(pool: PgPool) -> Self { + Self { pool } + } + + async fn acquire(&self, key: String, owner: String, ttl_ms: i64) -> Result> { + let row = sqlx::query_as::<_, LeaseGrantRow>( + r#" + INSERT INTO runtime_leases (key, owner, fencing_token, expires_at) + VALUES ($1, $2, 1, CURRENT_TIMESTAMP + ($3 * INTERVAL '1 millisecond')) + ON CONFLICT (key) DO UPDATE + SET owner = EXCLUDED.owner, + fencing_token = runtime_leases.fencing_token + 1, + expires_at = EXCLUDED.expires_at, + updated_at = CURRENT_TIMESTAMP + WHERE runtime_leases.expires_at <= CURRENT_TIMESTAMP + RETURNING fencing_token + "#, + ) + .bind(&key) + .bind(&owner) + .bind(ttl_ms as f64) + .fetch_optional(&self.pool) + .await + .map_err(|err| napi_error(format!("CoordinationLease acquire failed: {err}")))?; + + Ok(row.map(|row| CoordinationLeaseGrant { + key, + owner, + fencing_token: row.fencing_token, + })) + } + + async fn release(&self, key: &str, owner: &str, fencing_token: i64) -> Result { + let result = sqlx::query( + r#" + DELETE FROM runtime_leases + WHERE key = $1 AND owner = $2 AND fencing_token = $3 + "#, + ) + .bind(key) + .bind(owner) + .bind(fencing_token) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("CoordinationLease release failed: {err}")))?; + + Ok(result.rows_affected() == 1) + } + + async fn renew(&self, key: &str, owner: &str, fencing_token: i64, ttl_ms: i64) -> Result { + let result = sqlx::query( + r#" + UPDATE runtime_leases + SET expires_at = CURRENT_TIMESTAMP + ($4 * INTERVAL '1 millisecond'), + updated_at = CURRENT_TIMESTAMP + WHERE key = $1 + AND owner = $2 + AND fencing_token = $3 + AND expires_at > CURRENT_TIMESTAMP + "#, + ) + .bind(key) + .bind(owner) + .bind(fencing_token) + .bind(ttl_ms as f64) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("CoordinationLease renew failed: {err}")))?; + + Ok(result.rows_affected() == 1) + } +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn acquire_coordination_lease( + &self, + key: String, + owner: String, + ttl_ms: i64, + ) -> Result> { + if ttl_ms <= 0 { + return Err(napi_error("coordination lease ttl must be positive")); + } + if owner.is_empty() { + return Err(napi_error("coordination lease owner is required")); + } + + CoordinationLeaseStore::new(self.pool().await?) + .acquire(key, owner, ttl_ms) + .await + } + + #[napi] + pub async fn release_coordination_lease( + &self, + key: String, + owner: String, + #[napi(ts_arg_type = "bigint | number")] fencing_token: i64, + ) -> Result { + CoordinationLeaseStore::new(self.pool().await?) + .release(&key, &owner, fencing_token) + .await + } + + #[napi] + pub async fn renew_coordination_lease( + &self, + key: String, + owner: String, + #[napi(ts_arg_type = "bigint | number")] fencing_token: i64, + ttl_ms: i64, + ) -> Result { + if ttl_ms <= 0 { + return Err(napi_error("coordination lease ttl must be positive")); + } + + CoordinationLeaseStore::new(self.pool().await?) + .renew(&key, &owner, fencing_token, ttl_ms) + .await + } +} diff --git a/packages/backend/native/src/backend_runtime/doc_compactor.rs b/packages/backend/native/src/backend_runtime/doc_compactor.rs new file mode 100644 index 0000000000..644c42217e --- /dev/null +++ b/packages/backend/native/src/backend_runtime/doc_compactor.rs @@ -0,0 +1,389 @@ +use chrono::{DateTime, Duration, Utc}; +use napi::Result; +use sqlx::{FromRow, PgPool, Postgres, Row, Transaction}; +use y_octo::Doc; + +use super::{ + BackendRuntime, constants::DEFAULT_HISTORY_PERIOD_SECONDS, error::napi_error, types::RuntimeDocCompactionResult, +}; + +#[derive(FromRow)] +struct SnapshotRow { + blob: Vec, + updated_at: DateTime, + updated_by: Option, +} + +#[derive(FromRow)] +struct UpdateRow { + blob: Vec, + created_at: DateTime, + created_by: Option, +} + +struct DocCompactorStore { + pool: PgPool, +} + +impl DocCompactorStore { + fn new(pool: PgPool) -> Self { + Self { pool } + } + + async fn compact_doc( + &self, + workspace_id: &str, + doc_id: &str, + batch_limit: i64, + history_min_interval_ms: i64, + ) -> Result<(i64, bool)> { + compact_doc( + self.pool.clone(), + workspace_id, + doc_id, + batch_limit, + history_min_interval_ms, + ) + .await + } +} + +fn is_empty_doc(bin: &[u8]) -> bool { + bin.is_empty() || (bin.len() == 1 && bin[0] == 0) || (bin.len() == 2 && bin[0] == 0 && bin[1] == 0) +} + +fn apply_updates(updates: impl IntoIterator>) -> Result> { + let mut doc = Doc::default(); + for update in updates { + doc + .apply_update_from_binary_v1(&update) + .map_err(|err| napi_error(format!("DocCompactor merge failed: {err}")))?; + } + doc + .encode_update_v1() + .map_err(|err| napi_error(format!("DocCompactor encode failed: {err}"))) +} + +async fn load_snapshot( + tx: &mut Transaction<'_, Postgres>, + workspace_id: &str, + doc_id: &str, +) -> Result> { + sqlx::query_as::<_, SnapshotRow>( + r#" + SELECT blob, updated_at, updated_by + FROM snapshots + WHERE workspace_id = $1 AND guid = $2 + FOR UPDATE + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor load snapshot failed: {err}"))) +} + +async fn load_updates( + tx: &mut Transaction<'_, Postgres>, + workspace_id: &str, + doc_id: &str, + batch_limit: i64, +) -> Result> { + sqlx::query_as::<_, UpdateRow>( + r#" + SELECT blob, created_at, created_by + FROM updates + WHERE workspace_id = $1 AND guid = $2 + ORDER BY created_at ASC + LIMIT $3 + FOR UPDATE + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .bind(batch_limit) + .fetch_all(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor load updates failed: {err}"))) +} + +async fn upsert_snapshot( + tx: &mut Transaction<'_, Postgres>, + workspace_id: &str, + doc_id: &str, + blob: &[u8], + timestamp: DateTime, + editor: Option<&str>, +) -> Result { + if is_empty_doc(blob) { + return Ok(false); + } + + let row = sqlx::query( + r#" + INSERT INTO snapshots + (workspace_id, guid, blob, size, created_at, updated_at, created_by, updated_by) + VALUES + ($1, $2, $3, $4, $5, $5, $6, $6) + ON CONFLICT (workspace_id, guid) + DO UPDATE SET + blob = $3, + size = $4, + updated_at = $5, + updated_by = $6 + WHERE snapshots.workspace_id = $1 + AND snapshots.guid = $2 + AND snapshots.updated_at <= $5 + RETURNING updated_at + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .bind(blob) + .bind(blob.len() as i64) + .bind(timestamp) + .bind(editor) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor upsert snapshot failed: {err}")))?; + + Ok(row.is_some()) +} + +async fn should_create_history( + tx: &mut Transaction<'_, Postgres>, + snapshot: &SnapshotRow, + workspace_id: &str, + doc_id: &str, + history_min_interval_ms: i64, +) -> Result { + if is_empty_doc(&snapshot.blob) { + return Ok(false); + } + + let row = sqlx::query( + r#" + SELECT timestamp + FROM snapshot_histories + WHERE workspace_id = $1 AND guid = $2 + ORDER BY timestamp DESC + LIMIT 1 + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor load latest history failed: {err}")))?; + + let Some(row) = row else { + return Ok(true); + }; + + let last_timestamp: DateTime = row.get("timestamp"); + if last_timestamp == snapshot.updated_at { + return Ok(false); + } + + Ok(last_timestamp < snapshot.updated_at - Duration::milliseconds(history_min_interval_ms)) +} + +async fn history_max_age_seconds(tx: &mut Transaction<'_, Postgres>, workspace_id: &str) -> Result { + let row = sqlx::query( + r#" + SELECT history_period_seconds + FROM effective_workspace_quota_states + WHERE workspace_id = $1 + "#, + ) + .bind(workspace_id) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor load history quota failed: {err}")))?; + + Ok( + row + .map(|row| row.get("history_period_seconds")) + .unwrap_or(DEFAULT_HISTORY_PERIOD_SECONDS), + ) +} + +async fn create_history( + tx: &mut Transaction<'_, Postgres>, + workspace_id: &str, + doc_id: &str, + snapshot: &SnapshotRow, +) -> Result { + let max_age_seconds = history_max_age_seconds(tx, workspace_id).await?; + if max_age_seconds <= 0 { + return Ok(false); + } + + let expired_at = Utc::now() + Duration::seconds(max_age_seconds as i64); + sqlx::query( + r#" + INSERT INTO snapshot_histories + (workspace_id, guid, timestamp, blob, expired_at, created_by) + VALUES + ($1, $2, $3, $4, $5, $6) + ON CONFLICT (workspace_id, guid, timestamp) + DO UPDATE SET expired_at = EXCLUDED.expired_at + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .bind(snapshot.updated_at) + .bind(&snapshot.blob) + .bind(expired_at) + .bind(snapshot.updated_by.as_deref()) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor create history failed: {err}")))?; + + Ok(true) +} + +async fn delete_updates( + tx: &mut Transaction<'_, Postgres>, + workspace_id: &str, + doc_id: &str, + timestamps: &[DateTime], +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM updates + WHERE workspace_id = $1 + AND guid = $2 + AND created_at = ANY($3) + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .bind(timestamps) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("DocCompactor delete updates failed: {err}")))?; + + Ok(result.rows_affected() as i64) +} + +async fn compact_doc( + pool: PgPool, + workspace_id: &str, + doc_id: &str, + batch_limit: i64, + history_min_interval_ms: i64, +) -> Result<(i64, bool)> { + let mut tx = pool + .begin() + .await + .map_err(|err| napi_error(format!("DocCompactor begin transaction failed: {err}")))?; + + let snapshot = load_snapshot(&mut tx, workspace_id, doc_id).await?; + let updates = load_updates(&mut tx, workspace_id, doc_id, batch_limit).await?; + if updates.is_empty() { + tx.commit() + .await + .map_err(|err| napi_error(format!("DocCompactor commit transaction failed: {err}")))?; + return Ok((0, false)); + } + + let last = updates.last().expect("updates is not empty"); + let mut merge_inputs = Vec::with_capacity(updates.len() + usize::from(snapshot.is_some())); + if let Some(snapshot) = &snapshot { + merge_inputs.push(snapshot.blob.clone()); + } + merge_inputs.extend(updates.iter().map(|update| update.blob.clone())); + + let final_blob = if merge_inputs.len() == 1 { + merge_inputs.remove(0) + } else { + apply_updates(merge_inputs)? + }; + + let snapshot_updated = upsert_snapshot( + &mut tx, + workspace_id, + doc_id, + &final_blob, + last.created_at, + last.created_by.as_deref(), + ) + .await?; + + let mut history_created = false; + if snapshot_updated + && let Some(snapshot) = &snapshot + && should_create_history(&mut tx, snapshot, workspace_id, doc_id, history_min_interval_ms).await? + { + history_created = create_history(&mut tx, workspace_id, doc_id, snapshot).await?; + } + + let timestamps = updates.iter().map(|update| update.created_at).collect::>(); + let deleted = delete_updates(&mut tx, workspace_id, doc_id, ×tamps).await?; + + tx.commit() + .await + .map_err(|err| napi_error(format!("DocCompactor commit transaction failed: {err}")))?; + + Ok((deleted, history_created)) +} + +#[napi_derive::napi] +impl BackendRuntime { + /// Merge pending doc updates with y-octo and persist the merged snapshot. + /// + /// Do not use this for snapshots that will be sent back to yjs clients until + /// the y-octo/yjs round-trip compatibility issue is resolved. + #[napi] + pub async fn compact_pending_doc_updates( + &self, + workspace_id: String, + doc_id: String, + batch_limit: i64, + history_min_interval_ms: i64, + owner: String, + lease_ttl_ms: i64, + ) -> Result { + if batch_limit <= 0 { + return Err(napi_error("doc compactor batch limit must be positive")); + } + if history_min_interval_ms < 0 { + return Err(napi_error("doc compactor history interval must be non-negative")); + } + + let lease_key = format!("doc:update:{workspace_id}:{doc_id}"); + let Some(lease) = self.acquire_coordination_lease(lease_key, owner, lease_ttl_ms).await? else { + return Ok(RuntimeDocCompactionResult { + lease_acquired: false, + merged: false, + workspace_id, + doc_id, + updates_merged: 0, + history_created: false, + }); + }; + + let result = DocCompactorStore::new(self.pool().await?) + .compact_doc(&workspace_id, &doc_id, batch_limit, history_min_interval_ms) + .await; + + let released = self + .release_coordination_lease(lease.key, lease.owner, lease.fencing_token) + .await?; + if !released { + return Err(napi_error("DocCompactor failed to release coordination lease")); + } + + let (updates_merged, history_created) = result?; + Ok(RuntimeDocCompactionResult { + lease_acquired: true, + merged: updates_merged > 0, + workspace_id, + doc_id, + updates_merged, + history_created, + }) + } +} diff --git a/packages/backend/native/src/backend_runtime/doc_storage.rs b/packages/backend/native/src/backend_runtime/doc_storage.rs new file mode 100644 index 0000000000..0f2f61a8e6 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/doc_storage.rs @@ -0,0 +1,158 @@ +use chrono::{DateTime, Duration, Utc}; +use napi::{Result, bindgen_prelude::Buffer}; +use sqlx::{PgPool, Row}; + +use super::{BackendRuntime, error::napi_error, types::RuntimeDocHistoryInput}; + +fn is_empty_doc(bin: &[u8]) -> bool { + bin.is_empty() || (bin.len() == 1 && bin[0] == 0) || (bin.len() == 2 && bin[0] == 0 && bin[1] == 0) +} + +async fn latest_history_timestamp(pool: &PgPool, workspace_id: &str, doc_id: &str) -> Result>> { + sqlx::query( + r#" + SELECT timestamp + FROM snapshot_histories + WHERE workspace_id = $1 AND guid = $2 + ORDER BY timestamp DESC + LIMIT 1 + "#, + ) + .bind(workspace_id) + .bind(doc_id) + .fetch_optional(pool) + .await + .map(|row| row.map(|row| row.get("timestamp"))) + .map_err(|err| napi_error(format!("DocStorage load latest history failed: {err}"))) +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn upsert_doc_snapshot( + &self, + workspace_id: String, + doc_id: String, + blob: Buffer, + timestamp_ms: i64, + editor_id: Option, + ) -> Result { + if is_empty_doc(blob.as_ref()) { + return Ok(false); + } + + let timestamp = DateTime::::from_timestamp_millis(timestamp_ms) + .ok_or_else(|| napi_error(format!("Invalid doc snapshot timestamp: {timestamp_ms}")))?; + let pool = self.pool().await?; + let row = sqlx::query( + r#" + INSERT INTO snapshots + (workspace_id, guid, blob, size, created_at, updated_at, created_by, updated_by) + VALUES + ($1, $2, $3, $4, $5, $5, $6, $6) + ON CONFLICT (workspace_id, guid) + DO UPDATE SET + blob = $3, + size = $4, + updated_at = $5, + updated_by = $6 + WHERE snapshots.workspace_id = $1 + AND snapshots.guid = $2 + AND snapshots.updated_at <= $5 + RETURNING updated_at + "#, + ) + .bind(&workspace_id) + .bind(&doc_id) + .bind(blob.as_ref()) + .bind(blob.len() as i64) + .bind(timestamp) + .bind(editor_id.as_deref()) + .fetch_optional(&pool) + .await + .map_err(|err| napi_error(format!("DocStorage upsert snapshot failed: {err}")))?; + + Ok(row.is_some()) + } + + #[napi] + pub async fn create_doc_history(&self, input: RuntimeDocHistoryInput) -> Result { + if input.history_min_interval_ms < 0 { + return Err(napi_error("doc history interval must be non-negative")); + } + if input.history_max_age_ms <= 0 || is_empty_doc(input.blob.as_ref()) { + return Ok(false); + } + + let timestamp = DateTime::::from_timestamp_millis(input.timestamp_ms) + .ok_or_else(|| napi_error(format!("Invalid doc history timestamp: {}", input.timestamp_ms)))?; + let pool = self.pool().await?; + let should_create = match latest_history_timestamp(&pool, &input.workspace_id, &input.doc_id).await? { + None => true, + Some(last_timestamp) if last_timestamp == timestamp => false, + Some(last_timestamp) => { + input.force || last_timestamp < timestamp - Duration::milliseconds(input.history_min_interval_ms) + } + }; + + if !should_create { + return Ok(false); + } + + let expired_at = Utc::now() + Duration::milliseconds(input.history_max_age_ms); + sqlx::query( + r#" + INSERT INTO snapshot_histories + (workspace_id, guid, timestamp, blob, expired_at, created_by) + VALUES + ($1, $2, $3, $4, $5, $6) + ON CONFLICT (workspace_id, guid, timestamp) + DO UPDATE SET expired_at = EXCLUDED.expired_at + "#, + ) + .bind(&input.workspace_id) + .bind(&input.doc_id) + .bind(timestamp) + .bind(input.blob.as_ref()) + .bind(expired_at) + .bind(input.editor_id.as_deref()) + .execute(&pool) + .await + .map_err(|err| napi_error(format!("DocStorage create history failed: {err}")))?; + + Ok(true) + } + + #[napi] + pub async fn delete_doc_storage(&self, workspace_id: String, doc_id: String) -> Result<()> { + let pool = self.pool().await?; + let mut tx = pool + .begin() + .await + .map_err(|err| napi_error(format!("DocStorage delete begin transaction failed: {err}")))?; + + sqlx::query("DELETE FROM snapshots WHERE workspace_id = $1 AND guid = $2") + .bind(&workspace_id) + .bind(&doc_id) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("DocStorage delete snapshot failed: {err}")))?; + sqlx::query("DELETE FROM updates WHERE workspace_id = $1 AND guid = $2") + .bind(&workspace_id) + .bind(&doc_id) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("DocStorage delete updates failed: {err}")))?; + sqlx::query("DELETE FROM snapshot_histories WHERE workspace_id = $1 AND guid = $2") + .bind(&workspace_id) + .bind(&doc_id) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("DocStorage delete histories failed: {err}")))?; + + tx.commit() + .await + .map_err(|err| napi_error(format!("DocStorage delete commit failed: {err}")))?; + Ok(()) + } +} diff --git a/packages/backend/native/src/backend_runtime/error.rs b/packages/backend/native/src/backend_runtime/error.rs new file mode 100644 index 0000000000..fd0a38d1b4 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/error.rs @@ -0,0 +1,5 @@ +use napi::{Error, Status}; + +pub(super) fn napi_error(message: impl Into) -> Error { + Error::new(Status::GenericFailure, message.into()) +} diff --git a/packages/backend/native/src/backend_runtime/gate.rs b/packages/backend/native/src/backend_runtime/gate.rs new file mode 100644 index 0000000000..8686183131 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/gate.rs @@ -0,0 +1,90 @@ +use napi::Result; +use sqlx::PgPool; + +use super::{BackendRuntime, error::napi_error}; + +struct RuntimeGateStore { + pool: PgPool, +} + +impl RuntimeGateStore { + fn new(pool: PgPool) -> Self { + Self { pool } + } + + async fn put_if_absent(&self, key: &str, ttl_ms: i64) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| napi_error(format!("RuntimeGate transaction failed: {err}")))?; + + sqlx::query("DELETE FROM runtime_gates WHERE key = $1 AND expires_at <= CURRENT_TIMESTAMP") + .bind(key) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("RuntimeGate expired cleanup failed: {err}")))?; + + let inserted = sqlx::query( + r#" + INSERT INTO runtime_gates (key, expires_at) + VALUES ($1, CURRENT_TIMESTAMP + ($2 * INTERVAL '1 millisecond')) + ON CONFLICT (key) DO NOTHING + "#, + ) + .bind(key) + .bind(ttl_ms as f64) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("RuntimeGate put_if_absent failed: {err}")))? + .rows_affected() + == 1; + + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeGate transaction commit failed: {err}")))?; + + Ok(inserted) + } + + async fn cleanup_expired(&self, limit: i64) -> Result { + let result = sqlx::query( + r#" + DELETE FROM runtime_gates + WHERE key IN ( + SELECT key FROM runtime_gates + WHERE expires_at <= CURRENT_TIMESTAMP + ORDER BY expires_at ASC + LIMIT $1 + ) + "#, + ) + .bind(limit) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("RuntimeGate cleanup failed: {err}")))?; + + Ok(result.rows_affected() as i64) + } +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn put_runtime_gate_if_absent(&self, key: String, ttl_ms: i64) -> Result { + if ttl_ms <= 0 { + return Err(napi_error("runtime gate ttl must be positive")); + } + RuntimeGateStore::new(self.pool().await?) + .put_if_absent(&key, ttl_ms) + .await + } + + #[napi] + pub async fn cleanup_expired_runtime_gates(&self, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("runtime gate cleanup limit must be positive")); + } + RuntimeGateStore::new(self.pool().await?).cleanup_expired(limit).await + } +} diff --git a/packages/backend/native/src/backend_runtime/housekeeping.rs b/packages/backend/native/src/backend_runtime/housekeeping.rs new file mode 100644 index 0000000000..f89dc360cc --- /dev/null +++ b/packages/backend/native/src/backend_runtime/housekeeping.rs @@ -0,0 +1,80 @@ +use napi::Result; +use sqlx::PgPool; + +use super::{BackendRuntime, error::napi_error}; + +struct HousekeepingStore { + pool: PgPool, +} + +impl HousekeepingStore { + fn new(pool: PgPool) -> Self { + Self { pool } + } + + async fn cleanup_expired_user_sessions(&self, limit: i64) -> Result { + let result = sqlx::query( + r#" + DELETE FROM user_sessions + WHERE id IN ( + SELECT id FROM user_sessions + WHERE expires_at <= CURRENT_TIMESTAMP + ORDER BY expires_at ASC + LIMIT $1 + ) + "#, + ) + .bind(limit) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("Housekeeping user sessions cleanup failed: {err}")))?; + + Ok(result.rows_affected() as i64) + } + + async fn cleanup_expired_snapshot_histories(&self, limit: i64) -> Result { + let result = sqlx::query( + r#" + DELETE FROM snapshot_histories + WHERE (workspace_id, guid, timestamp) IN ( + SELECT workspace_id, guid, timestamp + FROM snapshot_histories + WHERE expired_at <= CURRENT_TIMESTAMP + ORDER BY expired_at ASC + LIMIT $1 + ) + "#, + ) + .bind(limit) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("Housekeeping snapshot histories cleanup failed: {err}")))?; + + Ok(result.rows_affected() as i64) + } +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn cleanup_expired_user_sessions(&self, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("user sessions cleanup limit must be positive")); + } + + HousekeepingStore::new(self.pool().await?) + .cleanup_expired_user_sessions(limit) + .await + } + + #[napi] + pub async fn cleanup_expired_snapshot_histories(&self, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("snapshot histories cleanup limit must be positive")); + } + + HousekeepingStore::new(self.pool().await?) + .cleanup_expired_snapshot_histories(limit) + .await + } +} diff --git a/packages/backend/native/src/backend_runtime/mod.rs b/packages/backend/native/src/backend_runtime/mod.rs new file mode 100644 index 0000000000..ec547f0916 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/mod.rs @@ -0,0 +1,128 @@ +mod blob_complete; +mod blob_reclaimer; +mod config; +mod constants; +mod coordination_lease; +mod doc_compactor; +mod doc_storage; +mod error; +mod gate; +mod housekeeping; +mod object_storage; +mod runtime_state; +#[cfg(test)] +mod tests; +mod types; +mod workspace_stats; + +use std::time::Duration; + +use napi::Result; +use sha2::{Digest, Sha256}; +use sqlx::{PgPool, Row, postgres::PgPoolOptions}; +use tokio::sync::Mutex; + +use self::{config::RuntimeConfig, constants::RUNTIME_MIGRATIONS, error::napi_error, types::BackendRuntimeHealth}; + +pub(super) fn token_hash(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +#[napi_derive::napi] +pub struct BackendRuntime { + config: RuntimeConfig, + pool: Mutex>, +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi(constructor)] + pub fn new() -> Result { + Ok(Self { + config: RuntimeConfig::from_config_files()?, + pool: Mutex::new(None), + }) + } + + #[napi] + pub async fn start(&self) -> Result<()> { + let mut guard = self.pool.lock().await; + if guard.is_some() { + return Ok(()); + } + + let pool = PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(5)) + .connect(&self.config.database_url) + .await + .map_err(|err| napi_error(format!("BackendRuntime failed to connect postgres: {err}")))?; + + sqlx::query("SELECT 1") + .execute(&pool) + .await + .map_err(|err| napi_error(format!("BackendRuntime postgres health check failed: {err}")))?; + + *guard = Some(pool); + Ok(()) + } + + #[napi] + pub async fn stop(&self) -> Result<()> { + let pool = self.pool.lock().await.take(); + if let Some(pool) = pool { + pool.close().await; + } + Ok(()) + } + + #[napi] + pub async fn health(&self) -> Result { + let pool = self.pool.lock().await.as_ref().cloned(); + let database_connected = match pool.as_ref() { + Some(pool) => sqlx::query("SELECT 1") + .fetch_one(pool) + .await + .map(|row| row.try_get::(0).unwrap_or(0) == 1) + .unwrap_or(false), + None => false, + }; + + Ok(BackendRuntimeHealth { + started: pool.is_some(), + database_connected, + object_storage_configured: self.config.storage.is_some(), + }) + } + + #[napi] + pub async fn run_migrations(&self) -> Result<()> { + let pool = self.pool().await?; + migrate_runtime_tables(&pool).await + } + + async fn pool(&self) -> Result { + self + .pool + .lock() + .await + .as_ref() + .cloned() + .ok_or_else(|| napi_error("BackendRuntime must be started before using postgres operations")) + } +} + +async fn migrate_runtime_tables(pool: &PgPool) -> Result<()> { + for statement in RUNTIME_MIGRATIONS + .split(';') + .map(str::trim) + .filter(|statement| !statement.is_empty()) + { + sqlx::query(statement) + .execute(pool) + .await + .map_err(|err| napi_error(format!("BackendRuntime migration failed: {err}")))?; + } + + Ok(()) +} diff --git a/packages/backend/native/src/backend_runtime/object_storage/client.rs b/packages/backend/native/src/backend_runtime/object_storage/client.rs new file mode 100644 index 0000000000..a92c3e15f7 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/object_storage/client.rs @@ -0,0 +1,353 @@ +use std::{ + collections::HashMap, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use aws_sdk_s3::{ + Client as S3Client, presigning::PresigningConfig, primitives::ByteStream, types::CompletedMultipartUpload, +}; +use napi::Result; + +use super::types::{ + MultipartUploadInitResult, MultipartUploadPart, ObjectGetResult, ObjectListEntry, ObjectMetadata, ObjectPutMetadata, + PresignedObjectRequest, completed_multipart_parts, trim_etag, +}; +use crate::backend_runtime::error::napi_error; + +#[derive(Clone)] +pub(super) struct ObjectStorageClient { + client: S3Client, + bucket: String, + presign_expires_in_seconds: u64, + presign_sign_content_type_for_put: bool, +} + +impl ObjectStorageClient { + pub(super) fn new( + config: aws_sdk_s3::Config, + bucket: String, + presign_expires_in_seconds: u64, + presign_sign_content_type_for_put: bool, + ) -> Self { + Self { + client: S3Client::from_conf(config), + bucket, + presign_expires_in_seconds, + presign_sign_content_type_for_put, + } + } + + pub(super) fn non_destructive_health(&self) -> bool { + let _ = &self.client; + !self.bucket.is_empty() + } + + pub(super) async fn put(&self, key: &str, body: Vec, metadata: ObjectPutMetadata) -> Result<()> { + let content_length = metadata.content_length.unwrap_or(body.len() as i64); + let content_type = metadata + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()); + + let mut request = self + .client + .put_object() + .bucket(&self.bucket) + .key(key) + .body(ByteStream::from(body)) + .content_type(content_type) + .content_length(content_length); + + if let Some(checksum) = metadata.checksum_crc32 { + request = request.checksum_crc32(checksum); + } + + request + .send() + .await + .map_err(|err| napi_error(format!("ObjectStorage put failed for {key}: {err:?}")))?; + Ok(()) + } + + pub(super) async fn presign_put(&self, key: &str, metadata: ObjectPutMetadata) -> Result { + let content_type = metadata + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()); + let expires_at_ms = expires_at_ms(self.presign_expires_in_seconds)?; + let config = PresigningConfig::expires_in(Duration::from_secs(self.presign_expires_in_seconds)) + .map_err(|err| napi_error(format!("ObjectStorage presign config failed: {err}")))?; + + let mut request = self.client.put_object().bucket(&self.bucket).key(key); + if self.presign_sign_content_type_for_put { + request = request.content_type(content_type.clone()); + } + if let Some(content_length) = metadata.content_length { + request = request.content_length(content_length); + } + + let presigned = request + .presigned(config) + .await + .map_err(|err| napi_error(format!("ObjectStorage presign put failed for {key}: {err}")))?; + let mut headers = presigned_headers(&presigned); + headers.insert("Content-Type".to_string(), content_type); + + Ok(PresignedObjectRequest { + url: presigned.uri().to_string(), + headers, + expires_at_ms, + }) + } + + pub(super) async fn create_multipart_upload( + &self, + key: &str, + metadata: ObjectPutMetadata, + ) -> Result> { + let content_type = metadata + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()); + let result = self + .client + .create_multipart_upload() + .bucket(&self.bucket) + .key(key) + .content_type(content_type) + .send() + .await + .map_err(|err| { + napi_error(format!( + "ObjectStorage create multipart upload failed for {key}: {err:?}" + )) + })?; + + let expires_at_ms = expires_at_ms(self.presign_expires_in_seconds)?; + Ok(result.upload_id.map(|upload_id| MultipartUploadInitResult { + upload_id, + expires_at_ms, + })) + } + + pub(super) async fn presign_upload_part( + &self, + key: &str, + upload_id: &str, + part_number: i32, + ) -> Result { + let expires_at_ms = expires_at_ms(self.presign_expires_in_seconds)?; + let config = PresigningConfig::expires_in(Duration::from_secs(self.presign_expires_in_seconds)) + .map_err(|err| napi_error(format!("ObjectStorage presign config failed: {err}")))?; + let presigned = self + .client + .upload_part() + .bucket(&self.bucket) + .key(key) + .upload_id(upload_id) + .part_number(part_number) + .presigned(config) + .await + .map_err(|err| napi_error(format!("ObjectStorage presign upload part failed for {key}: {err}")))?; + + Ok(PresignedObjectRequest { + url: presigned.uri().to_string(), + headers: presigned_headers(&presigned), + expires_at_ms, + }) + } + + pub(super) async fn list_multipart_upload_parts( + &self, + key: &str, + upload_id: &str, + ) -> Result> { + let result = self + .client + .list_parts() + .bucket(&self.bucket) + .key(key) + .upload_id(upload_id) + .send() + .await + .map_err(|err| { + napi_error(format!( + "ObjectStorage list multipart upload parts failed for {key}: {err}" + )) + })?; + + Ok( + result + .parts() + .iter() + .filter_map(|part| { + Some(MultipartUploadPart { + part_number: part.part_number?, + etag: trim_etag(part.e_tag.as_deref().unwrap_or_default()), + }) + }) + .collect(), + ) + } + + pub(super) async fn complete_multipart_upload( + &self, + key: &str, + upload_id: &str, + parts: Vec, + ) -> Result<()> { + let ordered_parts = completed_multipart_parts(parts); + self + .client + .complete_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(upload_id) + .multipart_upload( + CompletedMultipartUpload::builder() + .set_parts(Some(ordered_parts)) + .build(), + ) + .send() + .await + .map_err(|err| { + napi_error(format!( + "ObjectStorage complete multipart upload failed for {key}: {err}" + )) + })?; + Ok(()) + } + + pub(super) async fn abort_multipart_upload(&self, key: &str, upload_id: &str) -> Result<()> { + self + .client + .abort_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(upload_id) + .send() + .await + .map_err(|err| { + napi_error(format!( + "ObjectStorage abort multipart upload failed for {key}: {err:?}" + )) + })?; + Ok(()) + } + + pub(super) async fn head(&self, key: &str) -> Result> { + let result = self + .client + .head_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + .map_err(|err| napi_error(format!("ObjectStorage head failed for {key}: {err:?}")))?; + + Ok(Some(ObjectMetadata { + content_type: result + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()), + content_length: result.content_length.unwrap_or(0), + last_modified_ms: optional_datetime_ms(result.last_modified), + checksum_crc32: result.checksum_crc32, + })) + } + + pub(super) async fn get(&self, key: &str) -> Result> { + let result = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + .map_err(|err| napi_error(format!("ObjectStorage get failed for {key}: {err:?}")))?; + let metadata = ObjectMetadata { + content_type: result + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()), + content_length: result.content_length.unwrap_or(0), + last_modified_ms: optional_datetime_ms(result.last_modified), + checksum_crc32: result.checksum_crc32, + }; + let body = result + .body + .collect() + .await + .map_err(|err| napi_error(format!("ObjectStorage read body failed for {key}: {err}")))? + .into_bytes() + .to_vec(); + + Ok(Some(ObjectGetResult { body, metadata })) + } + + pub(super) async fn list(&self, prefix: Option) -> Result> { + let mut entries = Vec::new(); + let mut token = None; + loop { + let mut request = self.client.list_objects_v2().bucket(&self.bucket); + if let Some(prefix) = &prefix { + request = request.prefix(prefix); + } + if let Some(next_token) = token { + request = request.continuation_token(next_token); + } + let result = request + .send() + .await + .map_err(|err| napi_error(format!("ObjectStorage list failed: {err:?}")))?; + + entries.extend(result.contents().iter().filter_map(|object| { + Some(ObjectListEntry { + key: object.key.as_ref()?.clone(), + content_length: object.size.unwrap_or(0), + last_modified_ms: optional_datetime_ms(object.last_modified), + }) + })); + + if result.is_truncated.unwrap_or(false) { + token = result.next_continuation_token; + } else { + break; + } + } + + Ok(entries) + } + + pub(super) async fn delete(&self, key: &str) -> Result<()> { + self + .client + .delete_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + .map_err(|err| napi_error(format!("ObjectStorage delete failed for {key}: {err:?}")))?; + Ok(()) + } +} + +fn expires_at_ms(expires_in_seconds: u64) -> Result { + let expires_at = SystemTime::now() + .checked_add(Duration::from_secs(expires_in_seconds)) + .ok_or_else(|| napi_error("ObjectStorage presign expiration overflow"))?; + system_time_ms(expires_at) +} + +fn system_time_ms(time: SystemTime) -> Result { + let duration = time + .duration_since(UNIX_EPOCH) + .map_err(|err| napi_error(format!("system time before unix epoch: {err}")))?; + Ok(duration.as_millis() as i64) +} + +fn optional_datetime_ms(time: Option) -> i64 { + time.and_then(|value| value.to_millis().ok()).unwrap_or(0) +} + +fn presigned_headers(request: &aws_sdk_s3::presigning::PresignedRequest) -> HashMap { + request + .headers() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} diff --git a/packages/backend/native/src/backend_runtime/object_storage/config.rs b/packages/backend/native/src/backend_runtime/object_storage/config.rs new file mode 100644 index 0000000000..c29a8af97a --- /dev/null +++ b/packages/backend/native/src/backend_runtime/object_storage/config.rs @@ -0,0 +1,225 @@ +use aws_sdk_s3::config::{ + BehaviorVersion, Credentials, Region, RequestChecksumCalculation, ResponseChecksumValidation, timeout::TimeoutConfig, +}; +use napi::Result; +use serde::Deserialize; + +use super::{client::ObjectStorageClient, types::StorageProviderConfig}; +use crate::backend_runtime::{ + config::blob_storage_config_from_config_files, error::napi_error, types::RuntimeObjectStorageHealth, +}; + +#[derive(Clone, Debug)] +pub(in crate::backend_runtime) struct ObjectStorageConfig { + pub(super) provider: String, + pub(super) bucket: String, + pub(super) endpoint: Option, + pub(super) region: Option, + pub(super) access_key_id: Option, + pub(super) secret_access_key: Option, + pub(super) session_token: Option, + pub(super) force_path_style: bool, + pub(super) request_timeout_ms: Option, + pub(super) min_part_size: Option, + pub(super) presign_expires_in_seconds: Option, + pub(super) presign_sign_content_type_for_put: Option, + pub(super) use_presigned_url: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct S3ConfigFile { + endpoint: Option, + region: Option, + credentials: Option, + force_path_style: Option, + request_timeout_ms: Option, + min_part_size: Option, + presign: Option, + #[serde(rename = "usePresignedURL")] + use_presigned_url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct R2ConfigFile { + account_id: String, + jurisdiction: Option, + region: Option, + credentials: Option, + request_timeout_ms: Option, + min_part_size: Option, + presign: Option, + #[serde(rename = "usePresignedURL")] + use_presigned_url: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct S3CredentialsConfigFile { + access_key_id: Option, + secret_access_key: Option, + session_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct S3PresignConfigFile { + expires_in_seconds: Option, + sign_content_type_for_put: Option, +} + +#[derive(Debug, Deserialize)] +struct UsePresignedUrlConfigFile { + enabled: bool, +} + +impl ObjectStorageConfig { + pub(in crate::backend_runtime) fn from_config_files() -> Result> { + let Some(storage) = blob_storage_config_from_config_files()? else { + return Ok(None); + }; + + match storage.provider.as_str() { + "aws-s3" => Self::from_s3_config(storage), + "cloudflare-r2" => Self::from_r2_config(storage), + "fs" => Ok(None), + provider => Err(napi_error(format!( + "unsupported blob storage provider for BackendRuntime: {provider}" + ))), + } + } + + pub(super) fn from_s3_config(storage: StorageProviderConfig) -> Result> { + let config: S3ConfigFile = serde_json::from_value(storage.config) + .map_err(|err| napi_error(format!("invalid aws-s3 blob storage config: {err}")))?; + let region = config + .region + .ok_or_else(|| napi_error("aws-s3 blob storage config requires region"))?; + let endpoint = config.endpoint.or_else(|| Some(resolve_s3_endpoint(®ion))); + let credentials = config.credentials.unwrap_or_default(); + + Ok(Some(Self { + provider: storage.provider, + bucket: storage.bucket, + endpoint, + region: Some(region), + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials.session_token, + force_path_style: config.force_path_style.unwrap_or(false), + request_timeout_ms: config.request_timeout_ms, + min_part_size: config.min_part_size, + presign_expires_in_seconds: config.presign.as_ref().and_then(|v| v.expires_in_seconds), + presign_sign_content_type_for_put: config.presign.as_ref().and_then(|v| v.sign_content_type_for_put), + use_presigned_url: config.use_presigned_url.map(|v| v.enabled).unwrap_or(false), + })) + } + + pub(super) fn from_r2_config(storage: StorageProviderConfig) -> Result> { + let config: R2ConfigFile = serde_json::from_value(storage.config) + .map_err(|err| napi_error(format!("invalid cloudflare-r2 blob storage config: {err}")))?; + let account = match config.jurisdiction { + Some(jurisdiction) => format!("{}.{}", config.account_id, jurisdiction), + None => config.account_id, + }; + let credentials = config.credentials.unwrap_or_default(); + + Ok(Some(Self { + provider: storage.provider, + bucket: storage.bucket, + endpoint: Some(format!("https://{account}.r2.cloudflarestorage.com")), + region: Some(config.region.unwrap_or_else(|| "auto".to_string())), + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials.session_token, + force_path_style: true, + request_timeout_ms: config.request_timeout_ms, + min_part_size: config.min_part_size, + presign_expires_in_seconds: config.presign.as_ref().and_then(|v| v.expires_in_seconds), + presign_sign_content_type_for_put: config.presign.as_ref().and_then(|v| v.sign_content_type_for_put), + use_presigned_url: config.use_presigned_url.map(|v| v.enabled).unwrap_or(false), + })) + } + + pub(super) fn build_client(&self) -> Result { + let region = self + .region + .clone() + .ok_or_else(|| napi_error("object storage region is required"))?; + let access_key_id = self + .access_key_id + .clone() + .ok_or_else(|| napi_error("object storage accessKeyId is required"))?; + let secret_access_key = self + .secret_access_key + .clone() + .ok_or_else(|| napi_error("object storage secretAccessKey is required"))?; + + let credentials = Credentials::new( + access_key_id, + secret_access_key, + self.session_token.clone(), + None, + "affine-server-config-json", + ); + let mut builder = aws_sdk_s3::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .region(Region::new(region)) + .credentials_provider(credentials) + .force_path_style(self.force_path_style) + .request_checksum_calculation(RequestChecksumCalculation::WhenRequired) + .response_checksum_validation(ResponseChecksumValidation::WhenRequired); + + if let Some(endpoint) = &self.endpoint { + builder = builder.endpoint_url(endpoint); + } + if let Some(request_timeout_ms) = self.request_timeout_ms { + builder = builder.timeout_config( + TimeoutConfig::builder() + .operation_timeout(std::time::Duration::from_millis(request_timeout_ms)) + .build(), + ); + } + + Ok(ObjectStorageClient::new( + builder.build(), + self.bucket.clone(), + self.presign_expires_in_seconds.unwrap_or(60), + self.presign_sign_content_type_for_put.unwrap_or(true), + )) + } + + pub(super) fn health(&self) -> RuntimeObjectStorageHealth { + let client_buildable = self + .build_client() + .map(|client| client.non_destructive_health()) + .unwrap_or(false); + + RuntimeObjectStorageHealth { + configured: true, + provider: Some(self.provider.clone()), + bucket: Some(self.bucket.clone()), + endpoint: self.endpoint.clone(), + region: self.region.clone(), + has_credentials: self.access_key_id.is_some() + && self.secret_access_key.is_some() + && self.session_token.as_ref().map(|v| !v.is_empty()).unwrap_or(true), + force_path_style: self.force_path_style, + request_timeout_ms: self.request_timeout_ms.map(|v| v as i64), + min_part_size: self.min_part_size.map(|v| v as i64), + presign_expires_in_seconds: self.presign_expires_in_seconds.map(|v| v as i64), + presign_sign_content_type_for_put: self.presign_sign_content_type_for_put, + use_presigned_url: self.use_presigned_url, + client_buildable, + } + } +} + +fn resolve_s3_endpoint(region: &str) -> String { + if region == "us-east-1" { + "https://s3.amazonaws.com".to_string() + } else { + format!("https://s3.{region}.amazonaws.com") + } +} diff --git a/packages/backend/native/src/backend_runtime/object_storage/mod.rs b/packages/backend/native/src/backend_runtime/object_storage/mod.rs new file mode 100644 index 0000000000..9016f5fa1b --- /dev/null +++ b/packages/backend/native/src/backend_runtime/object_storage/mod.rs @@ -0,0 +1,184 @@ +mod client; +mod config; +#[cfg(test)] +mod tests; +mod types; + +use client::ObjectStorageClient; +pub(super) use config::ObjectStorageConfig; +use napi::{Result, bindgen_prelude::Buffer}; +pub(super) use types::StorageProviderConfig; + +use super::{ + BackendRuntime, + types::{ + RuntimeMultipartUploadInit, RuntimeMultipartUploadPart, RuntimeObjectGetResult, RuntimeObjectListEntry, + RuntimeObjectMetadata, RuntimeObjectStorageHealth, RuntimeObjectStoragePutOptions, RuntimePresignedObjectRequest, + }, +}; + +#[napi_derive::napi] +impl BackendRuntime { + fn object_storage_client(&self) -> Result { + self + .config + .storage + .as_ref() + .ok_or_else(|| super::error::napi_error("ObjectStorageClient is not configured"))? + .build_client() + } + + pub(super) async fn object_storage_delete_object(&self, key: &str) -> Result<()> { + self.object_storage_client()?.delete(key).await + } + + pub(super) async fn object_storage_abort_upload(&self, key: &str, upload_id: &str) -> Result<()> { + self + .object_storage_client()? + .abort_multipart_upload(key, upload_id) + .await + } + + #[napi] + pub fn object_storage_health(&self) -> RuntimeObjectStorageHealth { + match &self.config.storage { + Some(storage) => storage.health(), + None => RuntimeObjectStorageHealth { + configured: false, + provider: None, + bucket: None, + endpoint: None, + region: None, + has_credentials: false, + force_path_style: false, + request_timeout_ms: None, + min_part_size: None, + presign_expires_in_seconds: None, + presign_sign_content_type_for_put: None, + use_presigned_url: false, + client_buildable: false, + }, + } + } + + #[napi] + pub async fn object_storage_put( + &self, + key: String, + body: Buffer, + metadata: Option, + ) -> Result<()> { + self + .object_storage_client()? + .put(&key, body.to_vec(), metadata.map(Into::into).unwrap_or_default()) + .await + } + + #[napi] + pub async fn object_storage_presign_put( + &self, + key: String, + metadata: Option, + ) -> Result { + self + .object_storage_client()? + .presign_put(&key, metadata.map(Into::into).unwrap_or_default()) + .await? + .try_into() + } + + #[napi] + pub async fn object_storage_create_multipart_upload( + &self, + key: String, + metadata: Option, + ) -> Result> { + Ok( + self + .object_storage_client()? + .create_multipart_upload(&key, metadata.map(Into::into).unwrap_or_default()) + .await? + .map(Into::into), + ) + } + + #[napi] + pub async fn object_storage_presign_upload_part( + &self, + key: String, + upload_id: String, + part_number: i32, + ) -> Result { + self + .object_storage_client()? + .presign_upload_part(&key, &upload_id, part_number) + .await? + .try_into() + } + + #[napi] + pub async fn object_storage_list_multipart_upload_parts( + &self, + key: String, + upload_id: String, + ) -> Result> { + Ok( + self + .object_storage_client()? + .list_multipart_upload_parts(&key, &upload_id) + .await? + .into_iter() + .map(Into::into) + .collect(), + ) + } + + #[napi] + pub async fn object_storage_complete_multipart_upload( + &self, + key: String, + upload_id: String, + parts: Vec, + ) -> Result<()> { + self + .object_storage_client()? + .complete_multipart_upload(&key, &upload_id, parts.into_iter().map(Into::into).collect()) + .await + } + + #[napi] + pub async fn object_storage_abort_multipart_upload(&self, key: String, upload_id: String) -> Result<()> { + self + .object_storage_client()? + .abort_multipart_upload(&key, &upload_id) + .await + } + + #[napi] + pub async fn object_storage_head(&self, key: String) -> Result> { + Ok(self.object_storage_client()?.head(&key).await?.map(Into::into)) + } + + #[napi] + pub async fn object_storage_get(&self, key: String) -> Result> { + Ok(self.object_storage_client()?.get(&key).await?.map(Into::into)) + } + + #[napi] + pub async fn object_storage_list(&self, prefix: Option) -> Result> { + Ok( + self + .object_storage_client()? + .list(prefix) + .await? + .into_iter() + .map(Into::into) + .collect(), + ) + } + + #[napi] + pub async fn object_storage_delete(&self, key: String) -> Result<()> { + self.object_storage_client()?.delete(&key).await + } +} diff --git a/packages/backend/native/src/backend_runtime/object_storage/tests.rs b/packages/backend/native/src/backend_runtime/object_storage/tests.rs new file mode 100644 index 0000000000..d39753709a --- /dev/null +++ b/packages/backend/native/src/backend_runtime/object_storage/tests.rs @@ -0,0 +1,129 @@ +use super::{ + config::ObjectStorageConfig, + types::{MultipartUploadPart, ObjectPutMetadata, StorageProviderConfig, completed_multipart_parts, trim_etag}, +}; + +#[test] +fn resolves_r2_config_from_config_json_shape() { + let storage = StorageProviderConfig { + provider: "cloudflare-r2".to_string(), + bucket: "workspace-blobs".to_string(), + config: serde_json::json!({ + "accountId": "account", + "jurisdiction": "eu", + "credentials": { + "accessKeyId": "key", + "secretAccessKey": "secret" + }, + "usePresignedURL": { + "enabled": true + } + }), + }; + + let config = ObjectStorageConfig::from_r2_config(storage).unwrap().unwrap(); + assert_eq!(config.provider, "cloudflare-r2"); + assert_eq!(config.bucket, "workspace-blobs"); + assert_eq!( + config.endpoint.as_deref(), + Some("https://account.eu.r2.cloudflarestorage.com") + ); + assert_eq!(config.region.as_deref(), Some("auto")); + assert!(config.force_path_style); + assert!(config.use_presigned_url); + assert_eq!(config.access_key_id.as_deref(), Some("key")); +} + +#[test] +fn resolves_s3_config_from_config_json_shape() { + let storage = StorageProviderConfig { + provider: "aws-s3".to_string(), + bucket: "workspace-blobs".to_string(), + config: serde_json::json!({ + "region": "us-west-2", + "credentials": { + "accessKeyId": "key", + "secretAccessKey": "secret", + "sessionToken": "session" + }, + "forcePathStyle": true, + "requestTimeoutMs": 1000, + "minPartSize": 1024, + "presign": { + "expiresInSeconds": 60, + "signContentTypeForPut": false + } + }), + }; + + let config = ObjectStorageConfig::from_s3_config(storage).unwrap().unwrap(); + assert_eq!(config.provider, "aws-s3"); + assert_eq!(config.endpoint.as_deref(), Some("https://s3.us-west-2.amazonaws.com")); + assert_eq!(config.session_token.as_deref(), Some("session")); + assert!(config.force_path_style); + assert_eq!(config.request_timeout_ms, Some(1000)); + assert_eq!(config.min_part_size, Some(1024)); + assert_eq!(config.presign_expires_in_seconds, Some(60)); + assert_eq!(config.presign_sign_content_type_for_put, Some(false)); +} + +#[tokio::test] +async fn object_storage_presign_put_returns_sigv4_url_and_headers() { + let storage = StorageProviderConfig { + provider: "aws-s3".to_string(), + bucket: "test-bucket".to_string(), + config: serde_json::json!({ + "region": "us-east-1", + "endpoint": "https://s3.us-east-1.amazonaws.com", + "credentials": { + "accessKeyId": "key", + "secretAccessKey": "secret" + }, + "presign": { + "expiresInSeconds": 60 + } + }), + }; + let config = ObjectStorageConfig::from_s3_config(storage).unwrap().unwrap(); + let Ok(Ok(client)) = std::panic::catch_unwind(|| config.build_client()) else { + eprintln!("skipping object storage presign test: S3 client cannot be built in this environment"); + return; + }; + let result = client + .presign_put( + "key", + ObjectPutMetadata { + content_type: Some("text/plain".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert!(result.url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256")); + assert!(result.url.contains("X-Amz-SignedHeaders=")); + assert_eq!( + result.headers.get("Content-Type").map(String::as_str), + Some("text/plain") + ); + assert!(result.expires_at_ms > 0); +} + +#[test] +fn object_storage_orders_completed_multipart_parts_and_trims_etags() { + let parts = completed_multipart_parts(vec![ + MultipartUploadPart { + part_number: 2, + etag: trim_etag("\"b\""), + }, + MultipartUploadPart { + part_number: 1, + etag: trim_etag("a"), + }, + ]); + + assert_eq!(parts[0].part_number, Some(1)); + assert_eq!(parts[0].e_tag.as_deref(), Some("a")); + assert_eq!(parts[1].part_number, Some(2)); + assert_eq!(parts[1].e_tag.as_deref(), Some("b")); +} diff --git a/packages/backend/native/src/backend_runtime/object_storage/types.rs b/packages/backend/native/src/backend_runtime/object_storage/types.rs new file mode 100644 index 0000000000..0bf3d02a35 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/object_storage/types.rs @@ -0,0 +1,165 @@ +use std::collections::HashMap; + +use aws_sdk_s3::types::CompletedPart; +use napi::Result; +use serde::Deserialize; + +use crate::backend_runtime::{ + error::napi_error, + types::{ + RuntimeMultipartUploadInit, RuntimeMultipartUploadPart, RuntimeObjectGetResult, RuntimeObjectListEntry, + RuntimeObjectMetadata, RuntimeObjectStoragePutOptions, RuntimePresignedObjectRequest, + }, +}; + +#[derive(Clone, Debug, Default)] +pub(super) struct ObjectPutMetadata { + pub(super) content_type: Option, + pub(super) content_length: Option, + pub(super) checksum_crc32: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ObjectMetadata { + pub(super) content_type: String, + pub(super) content_length: i64, + pub(super) last_modified_ms: i64, + pub(super) checksum_crc32: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ObjectListEntry { + pub(super) key: String, + pub(super) content_length: i64, + pub(super) last_modified_ms: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ObjectGetResult { + pub(super) body: Vec, + pub(super) metadata: ObjectMetadata, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct PresignedObjectRequest { + pub(super) url: String, + pub(super) headers: HashMap, + pub(super) expires_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct MultipartUploadInitResult { + pub(super) upload_id: String, + pub(super) expires_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct MultipartUploadPart { + pub(super) part_number: i32, + pub(super) etag: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::backend_runtime) struct StorageProviderConfig { + pub(super) provider: String, + pub(super) bucket: String, + #[serde(default)] + pub(super) config: serde_json::Value, +} + +pub(super) fn trim_etag(etag: &str) -> String { + etag.trim_matches('"').to_string() +} + +pub(super) fn completed_multipart_parts(mut parts: Vec) -> Vec { + parts.sort_by_key(|part| part.part_number); + parts + .into_iter() + .map(|part| { + CompletedPart::builder() + .part_number(part.part_number) + .e_tag(part.etag) + .build() + }) + .collect() +} + +impl From for ObjectPutMetadata { + fn from(options: RuntimeObjectStoragePutOptions) -> Self { + Self { + content_type: options.content_type, + content_length: options.content_length, + checksum_crc32: options.checksum_crc32, + } + } +} + +impl From for RuntimeObjectMetadata { + fn from(metadata: ObjectMetadata) -> Self { + Self { + content_type: metadata.content_type, + content_length: metadata.content_length, + last_modified_ms: metadata.last_modified_ms, + checksum_crc32: metadata.checksum_crc32, + } + } +} + +impl From for RuntimeObjectListEntry { + fn from(entry: ObjectListEntry) -> Self { + Self { + key: entry.key, + content_length: entry.content_length, + last_modified_ms: entry.last_modified_ms, + } + } +} + +impl TryFrom for RuntimePresignedObjectRequest { + type Error = napi::Error; + + fn try_from(request: PresignedObjectRequest) -> Result { + Ok(Self { + url: request.url, + headers_json: serde_json::to_string(&request.headers) + .map_err(|err| napi_error(format!("ObjectStorage headers serialization failed: {err}")))?, + expires_at_ms: request.expires_at_ms, + }) + } +} + +impl From for RuntimeObjectGetResult { + fn from(result: ObjectGetResult) -> Self { + Self { + body: result.body.into(), + metadata: result.metadata.into(), + } + } +} + +impl From for RuntimeMultipartUploadInit { + fn from(init: MultipartUploadInitResult) -> Self { + Self { + upload_id: init.upload_id, + expires_at_ms: init.expires_at_ms, + } + } +} + +impl From for MultipartUploadPart { + fn from(part: RuntimeMultipartUploadPart) -> Self { + Self { + part_number: part.part_number, + etag: part.etag, + } + } +} + +impl From for RuntimeMultipartUploadPart { + fn from(part: MultipartUploadPart) -> Self { + Self { + part_number: part.part_number, + etag: part.etag, + } + } +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/auth_challenge.rs b/packages/backend/native/src/backend_runtime/runtime_state/auth_challenge.rs new file mode 100644 index 0000000000..17b9063192 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/auth_challenge.rs @@ -0,0 +1,42 @@ +use napi::Result; + +use super::{auth_challenge_purpose, dto::RuntimeStateRows}; + +pub(super) async fn create( + rows: &RuntimeStateRows, + purpose: &str, + token: &str, + payload: serde_json::Value, + ttl_ms: i64, +) -> Result { + rows + .insert_payload_if_absent( + &auth_challenge_purpose(purpose), + token, + None, + payload, + ttl_ms, + "RuntimeState auth challenge create", + ) + .await +} + +pub(super) async fn get(rows: &RuntimeStateRows, purpose: &str, token: &str) -> Result> { + rows + .active_payload( + &auth_challenge_purpose(purpose), + token, + "RuntimeState auth challenge get", + ) + .await +} + +pub(super) async fn consume(rows: &RuntimeStateRows, purpose: &str, token: &str) -> Result> { + rows + .consume_payload( + &auth_challenge_purpose(purpose), + token, + "RuntimeState auth challenge consume", + ) + .await +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/byok_local_lease.rs b/packages/backend/native/src/backend_runtime/runtime_state/byok_local_lease.rs new file mode 100644 index 0000000000..57c00fca43 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/byok_local_lease.rs @@ -0,0 +1,136 @@ +use napi::Result; + +use super::dto::{RuntimeStateInsertPayload, RuntimeStatePayloadRow, RuntimeStateRows}; +use crate::backend_runtime::{ + constants::{BYOK_LOCAL_LEASE_ACTIVE_PURPOSE, BYOK_LOCAL_LEASE_PURPOSE}, + error::napi_error, + types::RuntimeByokLocalLeaseRecord, +}; + +pub(super) async fn get(rows: &RuntimeStateRows, lease_id: String) -> Result> { + get_lease_by_id(rows, &lease_id).await +} + +pub(super) async fn create( + rows: &RuntimeStateRows, + active_key: String, + lease_id: String, + payload: serde_json::Value, + ttl_ms: i64, +) -> Result { + if ttl_ms <= 0 { + return Err(napi_error("BYOK local lease ttl must be positive")); + } + + let mut tx = rows.begin("RuntimeState BYOK local lease").await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(&active_key) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("RuntimeState BYOK local lease active lock failed: {err}")))?; + + if let Some(active) = rows + .active_payload_with_expires_for_update_in_tx( + &mut tx, + BYOK_LOCAL_LEASE_ACTIVE_PURPOSE, + &active_key, + "RuntimeState BYOK local lease active get", + ) + .await? + { + let existing_lease = match active.payload.get("leaseId").and_then(serde_json::Value::as_str) { + Some(existing_lease_id) => get_lease_by_id_in_tx(rows, &mut tx, existing_lease_id).await?, + None => None, + }; + if let Some(lease) = existing_lease { + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState BYOK local lease transaction commit failed: {err}" + )) + })?; + return Ok(lease); + } + + rows + .delete_by_key_in_tx( + &mut tx, + BYOK_LOCAL_LEASE_ACTIVE_PURPOSE, + &active_key, + "RuntimeState BYOK local lease stale active delete", + ) + .await?; + } + + let expires_at_ms = rows + .insert_payload_returning_expires_in_tx( + &mut tx, + RuntimeStateInsertPayload { + purpose: BYOK_LOCAL_LEASE_PURPOSE, + token: &lease_id, + lookup_key: &active_key, + payload: &payload, + ttl_ms, + context: "RuntimeState BYOK local lease create", + }, + ) + .await?; + let active_payload = serde_json::json!({ "leaseId": lease_id }); + rows + .insert_payload_returning_expires_in_tx( + &mut tx, + RuntimeStateInsertPayload { + purpose: BYOK_LOCAL_LEASE_ACTIVE_PURPOSE, + token: &active_key, + lookup_key: &active_key, + payload: &active_payload, + ttl_ms, + context: "RuntimeState BYOK local lease active create", + }, + ) + .await?; + + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState BYOK local lease transaction commit failed: {err}" + )) + })?; + + Ok(RuntimeByokLocalLeaseRecord { + lease_id, + payload, + expires_at_ms, + }) +} + +async fn get_lease_by_id(rows: &RuntimeStateRows, lease_id: &str) -> Result> { + rows + .active_payload_with_expires(BYOK_LOCAL_LEASE_PURPOSE, lease_id, "RuntimeState BYOK local lease get") + .await? + .map(|row| record_from_row(lease_id, row)) + .transpose() +} + +async fn get_lease_by_id_in_tx( + rows: &RuntimeStateRows, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + lease_id: &str, +) -> Result> { + rows + .active_payload_with_expires_for_update_in_tx( + tx, + BYOK_LOCAL_LEASE_PURPOSE, + lease_id, + "RuntimeState BYOK local lease get", + ) + .await? + .map(|row| record_from_row(lease_id, row)) + .transpose() +} + +fn record_from_row(lease_id: &str, row: RuntimeStatePayloadRow) -> Result { + Ok(RuntimeByokLocalLeaseRecord { + lease_id: lease_id.to_string(), + payload: row.payload, + expires_at_ms: row.expires_at_ms, + }) +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/dto.rs b/packages/backend/native/src/backend_runtime/runtime_state/dto.rs new file mode 100644 index 0000000000..35b4fefcc6 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/dto.rs @@ -0,0 +1,454 @@ +use napi::Result; +use sqlx::{PgPool, Row}; + +use crate::backend_runtime::{error::napi_error, token_hash}; + +pub(super) struct RuntimeStatePayloadRow { + pub(super) payload: serde_json::Value, + pub(super) expires_at_ms: i64, +} + +pub(super) struct RuntimeStateLockedRow { + pub(super) payload: serde_json::Value, + pub(super) attempts: i32, + pub(super) expires_at: chrono::DateTime, +} + +pub(super) struct RuntimeStateInsertPayload<'a> { + pub(super) purpose: &'a str, + pub(super) token: &'a str, + pub(super) lookup_key: &'a str, + pub(super) payload: &'a serde_json::Value, + pub(super) ttl_ms: i64, + pub(super) context: &'a str, +} + +#[derive(Clone)] +pub(super) struct RuntimeStateRows { + pub(super) pool: PgPool, +} + +impl RuntimeStateRows { + pub(super) fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub(super) fn pool(&self) -> &PgPool { + &self.pool + } + + pub(super) async fn begin(&self, context: &str) -> Result> { + self + .pool + .begin() + .await + .map_err(|err| napi_error(format!("{context} transaction failed: {err}"))) + } + + pub(super) async fn insert_payload( + &self, + purpose: &str, + token: &str, + lookup_key: Option<&str>, + payload: serde_json::Value, + ttl_ms: i64, + context: &str, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO runtime_states (purpose, token_hash, lookup_key, payload, expires_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP + ($5 * INTERVAL '1 millisecond')) + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .bind(lookup_key) + .bind(payload) + .bind(ttl_ms as f64) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(()) + } + + pub(super) async fn insert_payload_if_absent( + &self, + purpose: &str, + token: &str, + lookup_key: Option<&str>, + payload: serde_json::Value, + ttl_ms: i64, + context: &str, + ) -> Result { + let inserted = sqlx::query( + r#" + INSERT INTO runtime_states (purpose, token_hash, lookup_key, payload, expires_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP + ($5 * INTERVAL '1 millisecond')) + ON CONFLICT (purpose, token_hash) DO NOTHING + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .bind(lookup_key) + .bind(payload) + .bind(ttl_ms as f64) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))? + .rows_affected() + == 1; + + Ok(inserted) + } + + pub(super) async fn upsert_payload_reset_attempts( + &self, + purpose: &str, + token: &str, + lookup_key: &str, + payload: serde_json::Value, + ttl_ms: i64, + context: &str, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO runtime_states (purpose, token_hash, lookup_key, payload, attempts, consumed_at, expires_at) + VALUES ($1, $2, $3, $4, 0, NULL, CURRENT_TIMESTAMP + ($5 * INTERVAL '1 millisecond')) + ON CONFLICT (purpose, token_hash) DO UPDATE + SET lookup_key = EXCLUDED.lookup_key, + payload = EXCLUDED.payload, + attempts = 0, + consumed_at = NULL, + expires_at = EXCLUDED.expires_at, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .bind(lookup_key) + .bind(payload) + .bind(ttl_ms as f64) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(()) + } + + pub(super) async fn active_payload( + &self, + purpose: &str, + token: &str, + context: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT payload + FROM runtime_states + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .fetch_optional(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(row.map(|row| row.get::("payload"))) + } + + pub(super) async fn active_payload_with_expires( + &self, + purpose: &str, + token: &str, + context: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT payload, (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + FROM runtime_states + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .fetch_optional(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(row.map(payload_row)) + } + + pub(super) async fn consume_payload( + &self, + purpose: &str, + token: &str, + context: &str, + ) -> Result> { + let row = sqlx::query( + r#" + UPDATE runtime_states + SET consumed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + RETURNING payload + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .fetch_optional(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(row.map(|row| row.get::("payload"))) + } + + pub(super) async fn consume_payload_with_expires( + &self, + purpose: &str, + token: &str, + context: &str, + ) -> Result> { + let row = sqlx::query( + r#" + UPDATE runtime_states + SET consumed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + RETURNING payload, (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .fetch_optional(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(row.map(payload_row)) + } + + pub(super) async fn active_payload_with_expires_for_update_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + purpose: &str, + token: &str, + context: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT payload, (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + FROM runtime_states + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > clock_timestamp() + FOR UPDATE + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(row.map(payload_row)) + } + + pub(super) async fn unconsumed_row_for_update_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + purpose: &str, + token: &str, + context: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT payload, attempts, expires_at + FROM runtime_states + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + FOR UPDATE + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(row.map(|row| RuntimeStateLockedRow { + payload: row.get("payload"), + attempts: row.get("attempts"), + expires_at: row.get("expires_at"), + })) + } + + pub(super) async fn insert_payload_returning_expires_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + input: RuntimeStateInsertPayload<'_>, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO runtime_states (purpose, token_hash, lookup_key, payload, expires_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP + ($5 * INTERVAL '1 millisecond')) + RETURNING (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + "#, + ) + .bind(input.purpose) + .bind(token_hash(input.token)) + .bind(input.lookup_key) + .bind(input.payload) + .bind(input.ttl_ms as f64) + .fetch_one(&mut **tx) + .await + .map_err(|err| napi_error(format!("{} failed: {err}", input.context)))?; + + Ok(row.get::("expires_at_ms")) + } + + pub(super) async fn upsert_expired_or_consumed_payload_returning_expires_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + input: RuntimeStateInsertPayload<'_>, + ) -> Result> { + let row = sqlx::query( + r#" + INSERT INTO runtime_states (purpose, token_hash, lookup_key, payload, expires_at) + VALUES ($1, $2, $3, $4, clock_timestamp() + ($5 * INTERVAL '1 millisecond')) + ON CONFLICT (purpose, token_hash) DO UPDATE + SET lookup_key = EXCLUDED.lookup_key, + payload = EXCLUDED.payload, + attempts = 0, + consumed_at = NULL, + expires_at = clock_timestamp() + ($5 * INTERVAL '1 millisecond') + WHERE runtime_states.consumed_at IS NOT NULL + OR runtime_states.expires_at <= clock_timestamp() + RETURNING (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + "#, + ) + .bind(input.purpose) + .bind(token_hash(input.token)) + .bind(input.lookup_key) + .bind(input.payload) + .bind(input.ttl_ms as f64) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("{} failed: {err}", input.context)))?; + + Ok(row.map(|row| row.get::("expires_at_ms"))) + } + + pub(super) async fn update_attempts_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + purpose: &str, + token: &str, + attempts: i32, + context: &str, + ) -> Result<()> { + sqlx::query( + r#" + UPDATE runtime_states + SET attempts = $3, + updated_at = CURRENT_TIMESTAMP + WHERE purpose = $1 + AND token_hash = $2 + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .bind(attempts) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(()) + } + + pub(super) async fn delete_by_key_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + purpose: &str, + token: &str, + context: &str, + ) -> Result<()> { + sqlx::query("DELETE FROM runtime_states WHERE purpose = $1 AND token_hash = $2") + .bind(purpose) + .bind(token_hash(token)) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(()) + } + + pub(super) async fn cleanup_expired_or_consumed(&self, limit: i64, context: &str) -> Result { + let result = sqlx::query( + r#" + DELETE FROM runtime_states + WHERE (purpose, token_hash) IN ( + SELECT purpose, token_hash FROM runtime_states + WHERE expires_at <= CURRENT_TIMESTAMP + OR consumed_at IS NOT NULL + ORDER BY expires_at ASC + LIMIT $1 + ) + "#, + ) + .bind(limit) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(result.rows_affected() as i64) + } + + pub(super) async fn cleanup_expired_by_purpose_prefix( + &self, + purpose_prefix: &str, + limit: i64, + context: &str, + ) -> Result { + let result = sqlx::query( + r#" + DELETE FROM runtime_states + WHERE (purpose, token_hash) IN ( + SELECT purpose, token_hash FROM runtime_states + WHERE purpose LIKE $1 + AND expires_at <= CURRENT_TIMESTAMP + ORDER BY expires_at ASC + LIMIT $2 + ) + "#, + ) + .bind(format!("{purpose_prefix}%")) + .bind(limit) + .execute(&self.pool) + .await + .map_err(|err| napi_error(format!("{context} failed: {err}")))?; + + Ok(result.rows_affected() as i64) + } +} + +fn payload_row(row: sqlx::postgres::PgRow) -> RuntimeStatePayloadRow { + RuntimeStatePayloadRow { + payload: row.get("payload"), + expires_at_ms: row.get("expires_at_ms"), + } +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs b/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs new file mode 100644 index 0000000000..54aba85e28 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs @@ -0,0 +1,194 @@ +use napi::Result; + +use super::dto::{RuntimeStateInsertPayload, RuntimeStatePayloadRow, RuntimeStateRows}; +use crate::backend_runtime::{ + constants::{WORKSPACE_INVITE_LINK_ID_PURPOSE, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE}, + error::napi_error, + types::RuntimeWorkspaceInviteLinkRecord, +}; + +pub(super) async fn get_by_workspace( + rows: &RuntimeStateRows, + workspace_id: String, +) -> Result> { + get_by_key(rows, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, &workspace_id).await +} + +pub(super) async fn get_by_invite_id( + rows: &RuntimeStateRows, + invite_id: String, +) -> Result> { + get_by_key(rows, WORKSPACE_INVITE_LINK_ID_PURPOSE, &invite_id).await +} + +pub(super) async fn create( + rows: &RuntimeStateRows, + workspace_id: String, + invite_id: String, + inviter_user_id: String, + ttl_ms: i64, +) -> Result { + if ttl_ms <= 0 { + return Err(napi_error("workspace invite link ttl must be positive")); + } + + let mut tx = rows.begin("RuntimeState workspace invite link").await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(&workspace_id) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("RuntimeState workspace invite link active lock failed: {err}")))?; + + if let Some(existing) = + get_by_key_in_tx(rows, &mut tx, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, &workspace_id).await? + { + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState workspace invite link transaction commit failed: {err}" + )) + })?; + return Ok(existing); + } + + let payload = serde_json::json!({ + "workspaceId": workspace_id, + "inviteId": invite_id, + "inviterUserId": inviter_user_id, + }); + + let Some(expires_at_ms) = rows + .upsert_expired_or_consumed_payload_returning_expires_in_tx( + &mut tx, + RuntimeStateInsertPayload { + purpose: WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, + token: &workspace_id, + lookup_key: &workspace_id, + payload: &payload, + ttl_ms, + context: "RuntimeState workspace invite link create", + }, + ) + .await? + else { + let existing = get_by_key_in_tx(rows, &mut tx, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, &workspace_id).await?; + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState workspace invite link transaction commit failed: {err}" + )) + })?; + return existing.ok_or_else(|| napi_error("RuntimeState workspace invite link active conflict missing row")); + }; + rows + .insert_payload_returning_expires_in_tx( + &mut tx, + RuntimeStateInsertPayload { + purpose: WORKSPACE_INVITE_LINK_ID_PURPOSE, + token: &invite_id, + lookup_key: &invite_id, + payload: &payload, + ttl_ms, + context: "RuntimeState workspace invite link create", + }, + ) + .await?; + + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState workspace invite link transaction commit failed: {err}" + )) + })?; + + Ok(RuntimeWorkspaceInviteLinkRecord { + workspace_id, + invite_id, + inviter_user_id, + expires_at_ms, + }) +} + +pub(super) async fn revoke(rows: &RuntimeStateRows, workspace_id: String) -> Result { + let mut tx = rows.begin("RuntimeState workspace invite link").await?; + let existing = get_by_key_in_tx(rows, &mut tx, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, &workspace_id).await?; + let Some(existing) = existing else { + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState workspace invite link transaction commit failed: {err}" + )) + })?; + return Ok(false); + }; + + rows + .delete_by_key_in_tx( + &mut tx, + WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, + &workspace_id, + "RuntimeState workspace invite link revoke", + ) + .await?; + rows + .delete_by_key_in_tx( + &mut tx, + WORKSPACE_INVITE_LINK_ID_PURPOSE, + &existing.invite_id, + "RuntimeState workspace invite link revoke", + ) + .await?; + + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState workspace invite link transaction commit failed: {err}" + )) + })?; + + Ok(true) +} + +async fn get_by_key( + rows: &RuntimeStateRows, + purpose: &str, + key: &str, +) -> Result> { + rows + .active_payload_with_expires(purpose, key, "RuntimeState workspace invite link get") + .await? + .map(record_from_row) + .transpose() +} + +async fn get_by_key_in_tx( + rows: &RuntimeStateRows, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + purpose: &str, + key: &str, +) -> Result> { + rows + .active_payload_with_expires_for_update_in_tx(tx, purpose, key, "RuntimeState workspace invite link get") + .await? + .map(record_from_row) + .transpose() +} + +fn record_from_row(row: RuntimeStatePayloadRow) -> Result { + Ok(RuntimeWorkspaceInviteLinkRecord { + workspace_id: row + .payload + .get("workspaceId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| napi_error("RuntimeState workspace invite link payload missing workspaceId"))? + .to_string(), + invite_id: row + .payload + .get("inviteId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| napi_error("RuntimeState workspace invite link payload missing inviteId"))? + .to_string(), + inviter_user_id: row + .payload + .get("inviterUserId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| napi_error("RuntimeState workspace invite link payload missing inviterUserId"))? + .to_string(), + expires_at_ms: row.expires_at_ms, + }) +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/magic_link_otp.rs b/packages/backend/native/src/backend_runtime/runtime_state/magic_link_otp.rs new file mode 100644 index 0000000000..a26106edfe --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/magic_link_otp.rs @@ -0,0 +1,178 @@ +use napi::Result; + +use super::dto::RuntimeStateRows; +use crate::backend_runtime::{ + constants::{MAGIC_LINK_OTP_PURPOSE, MAX_MAGIC_LINK_OTP_ATTEMPTS}, + error::napi_error, + types::RuntimeMagicLinkOtpConsumeResult, +}; + +impl RuntimeMagicLinkOtpConsumeResult { + fn ok(token: String) -> Self { + Self { + ok: true, + token: Some(token), + reason: None, + } + } + + fn fail(reason: &'static str) -> Self { + Self { + ok: false, + token: None, + reason: Some(reason.to_string()), + } + } +} + +pub(super) async fn upsert( + rows: &RuntimeStateRows, + email: String, + otp_hash: String, + token: String, + client_nonce: Option, + ttl_ms: i64, +) -> Result<()> { + if ttl_ms <= 0 { + return Err(napi_error("magic link otp ttl must be positive")); + } + + let payload = serde_json::json!({ + "otpHash": otp_hash, + "token": token, + "clientNonce": client_nonce, + }); + + rows + .upsert_payload_reset_attempts( + MAGIC_LINK_OTP_PURPOSE, + &email, + &email, + payload, + ttl_ms, + "RuntimeState magic link otp upsert", + ) + .await +} + +pub(super) async fn consume( + rows: &RuntimeStateRows, + email: String, + otp_hash: String, + client_nonce: Option, +) -> Result { + let mut tx = rows.begin("RuntimeState magic link otp").await?; + + let row = rows + .unconsumed_row_for_update_in_tx( + &mut tx, + MAGIC_LINK_OTP_PURPOSE, + &email, + "RuntimeState magic link otp lookup", + ) + .await?; + + let Some(row) = row else { + tx.rollback().await.map_err(|err| { + napi_error(format!( + "RuntimeState magic link otp transaction rollback failed: {err}" + )) + })?; + return Ok(RuntimeMagicLinkOtpConsumeResult::fail("not_found")); + }; + + let payload = row.payload; + let attempts = row.attempts; + let expires_at = row.expires_at; + + if expires_at <= chrono::Utc::now() { + rows + .delete_by_key_in_tx( + &mut tx, + MAGIC_LINK_OTP_PURPOSE, + &email, + "RuntimeState magic link otp delete", + ) + .await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeState magic link otp transaction commit failed: {err}")))?; + return Ok(RuntimeMagicLinkOtpConsumeResult::fail("expired")); + } + + let stored_client_nonce = payload.get("clientNonce").and_then(serde_json::Value::as_str); + if stored_client_nonce.is_some() && stored_client_nonce != client_nonce.as_deref() { + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeState magic link otp transaction commit failed: {err}")))?; + return Ok(RuntimeMagicLinkOtpConsumeResult::fail("nonce_mismatch")); + } + + if attempts >= MAX_MAGIC_LINK_OTP_ATTEMPTS { + rows + .delete_by_key_in_tx( + &mut tx, + MAGIC_LINK_OTP_PURPOSE, + &email, + "RuntimeState magic link otp delete", + ) + .await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeState magic link otp transaction commit failed: {err}")))?; + return Ok(RuntimeMagicLinkOtpConsumeResult::fail("locked")); + } + + let stored_otp_hash = payload.get("otpHash").and_then(serde_json::Value::as_str); + if stored_otp_hash != Some(otp_hash.as_str()) { + let attempts = attempts + 1; + if attempts >= MAX_MAGIC_LINK_OTP_ATTEMPTS { + rows + .delete_by_key_in_tx( + &mut tx, + MAGIC_LINK_OTP_PURPOSE, + &email, + "RuntimeState magic link otp delete", + ) + .await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeState magic link otp transaction commit failed: {err}")))?; + return Ok(RuntimeMagicLinkOtpConsumeResult::fail("locked")); + } + + rows + .update_attempts_in_tx( + &mut tx, + MAGIC_LINK_OTP_PURPOSE, + &email, + attempts, + "RuntimeState magic link otp attempts update", + ) + .await?; + + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeState magic link otp transaction commit failed: {err}")))?; + return Ok(RuntimeMagicLinkOtpConsumeResult::fail("invalid_otp")); + } + + let token = payload + .get("token") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| napi_error("RuntimeState magic link otp payload missing token"))? + .to_string(); + rows + .delete_by_key_in_tx( + &mut tx, + MAGIC_LINK_OTP_PURPOSE, + &email, + "RuntimeState magic link otp delete", + ) + .await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("RuntimeState magic link otp transaction commit failed: {err}")))?; + + Ok(RuntimeMagicLinkOtpConsumeResult::ok(token)) +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/mod.rs b/packages/backend/native/src/backend_runtime/runtime_state/mod.rs new file mode 100644 index 0000000000..39ed1f7610 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/mod.rs @@ -0,0 +1,235 @@ +use napi::Result; + +use super::{ + BackendRuntime, + error::napi_error, + types::{ + RuntimeByokLocalLeaseRecord, RuntimeMagicLinkOtpConsumeResult, RuntimeVerificationTokenRecord, + RuntimeWorkspaceInviteLinkRecord, + }, +}; + +mod auth_challenge; +mod byok_local_lease; +mod dto; +mod invite_link; +mod magic_link_otp; +mod store; +mod verification_token; +use store::RuntimeStateStore; + +pub(super) fn auth_challenge_purpose(purpose: &str) -> String { + format!("auth_challenge:{purpose}") +} + +pub(super) fn verification_token_purpose(token_type: i32) -> String { + format!("verification_token:{token_type}") +} + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn create_auth_challenge( + &self, + purpose: String, + token: String, + payload: serde_json::Value, + ttl_ms: i64, + ) -> Result { + if ttl_ms <= 0 { + return Err(napi_error("auth challenge ttl must be positive")); + } + RuntimeStateStore::new(self.pool().await?) + .create_auth_challenge(&purpose, &token, payload, ttl_ms) + .await + } + + #[napi] + pub async fn get_auth_challenge(&self, purpose: String, token: String) -> Result> { + RuntimeStateStore::new(self.pool().await?) + .get_auth_challenge(&purpose, &token) + .await + } + + #[napi] + pub async fn consume_auth_challenge(&self, purpose: String, token: String) -> Result> { + RuntimeStateStore::new(self.pool().await?) + .consume_auth_challenge(&purpose, &token) + .await + } + + #[napi] + pub async fn create_verification_token( + &self, + token_type: i32, + credential: Option, + ttl_ms: i64, + ) -> Result { + if ttl_ms <= 0 { + return Err(napi_error("verification token ttl must be positive")); + } + RuntimeStateStore::new(self.pool().await?) + .create_verification_token(token_type, credential, ttl_ms) + .await + } + + #[napi] + pub async fn get_verification_token( + &self, + token_type: i32, + token: String, + keep: Option, + ) -> Result> { + let keep = keep.unwrap_or(false); + RuntimeStateStore::new(self.pool().await?) + .get_verification_token(token_type, token, keep) + .await + } + + #[napi] + pub async fn verify_verification_token( + &self, + token_type: i32, + token: String, + credential: Option, + keep: Option, + ) -> Result> { + let keep = keep.unwrap_or(false); + RuntimeStateStore::new(self.pool().await?) + .verify_verification_token(token_type, token, credential, keep) + .await + } + + #[napi] + pub async fn cleanup_expired_verification_tokens(&self, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("verification token cleanup limit must be positive")); + } + RuntimeStateStore::new(self.pool().await?) + .cleanup_expired_verification_tokens(limit) + .await + } + + #[napi] + pub async fn upsert_magic_link_otp( + &self, + email: String, + otp_hash: String, + token: String, + client_nonce: Option, + ttl_ms: i64, + ) -> Result<()> { + RuntimeStateStore::new(self.pool().await?) + .upsert_magic_link_otp(email, otp_hash, token, client_nonce, ttl_ms) + .await + } + + #[napi] + pub async fn consume_magic_link_otp( + &self, + email: String, + otp_hash: String, + client_nonce: Option, + ) -> Result { + RuntimeStateStore::new(self.pool().await?) + .consume_magic_link_otp(email, otp_hash, client_nonce) + .await + } + + #[napi] + pub async fn create_workspace_invite_link( + &self, + workspace_id: String, + invite_id: String, + inviter_user_id: String, + ttl_ms: i64, + ) -> Result { + RuntimeStateStore::new(self.pool().await?) + .create_workspace_invite_link(workspace_id, invite_id, inviter_user_id, ttl_ms) + .await + } + + #[napi] + pub async fn get_workspace_invite_link( + &self, + workspace_id: String, + ) -> Result> { + RuntimeStateStore::new(self.pool().await?) + .get_workspace_invite_link(workspace_id) + .await + } + + #[napi] + pub async fn get_workspace_invite_link_by_id( + &self, + invite_id: String, + ) -> Result> { + RuntimeStateStore::new(self.pool().await?) + .get_workspace_invite_link_by_id(invite_id) + .await + } + + #[napi] + pub async fn revoke_workspace_invite_link(&self, workspace_id: String) -> Result { + RuntimeStateStore::new(self.pool().await?) + .revoke_workspace_invite_link(workspace_id) + .await + } + + #[napi] + pub async fn create_byok_local_lease( + &self, + active_key: String, + lease_id: String, + payload: serde_json::Value, + ttl_ms: i64, + ) -> Result { + RuntimeStateStore::new(self.pool().await?) + .create_byok_local_lease(active_key, lease_id, payload, ttl_ms) + .await + } + + #[napi] + pub async fn get_byok_local_lease(&self, lease_id: String) -> Result> { + RuntimeStateStore::new(self.pool().await?) + .get_byok_local_lease(lease_id) + .await + } + + #[napi] + pub async fn cleanup_expired_runtime_states(&self, limit: i64) -> Result { + if limit <= 0 { + return Err(napi_error("runtime state cleanup limit must be positive")); + } + RuntimeStateStore::new(self.pool().await?) + .cleanup_expired_runtime_states(limit) + .await + } +} + +#[cfg(test)] +mod tests { + use crate::backend_runtime::{ + constants::{MAGIC_LINK_OTP_PURPOSE, WORKSPACE_INVITE_LINK_ID_PURPOSE, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE}, + token_hash, + }; + + #[test] + fn magic_link_otp_uses_scoped_purpose_and_email_hash() { + assert_eq!(MAGIC_LINK_OTP_PURPOSE, "magic_link_otp"); + assert_ne!(token_hash("user@affine.test"), "user@affine.test"); + assert_eq!(token_hash("user@affine.test"), token_hash("user@affine.test")); + assert_ne!(token_hash("user@affine.test"), token_hash("other@affine.test")); + } + + #[test] + fn workspace_invite_link_uses_scoped_purposes_and_hashes() { + assert_eq!( + WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, + "workspace_invite_link:workspace" + ); + assert_eq!(WORKSPACE_INVITE_LINK_ID_PURPOSE, "workspace_invite_link:id"); + assert_ne!(token_hash("workspace-id"), "workspace-id"); + assert_ne!(token_hash("invite-id"), "invite-id"); + } +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/store.rs b/packages/backend/native/src/backend_runtime/runtime_state/store.rs new file mode 100644 index 0000000000..4d12a356b2 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/store.rs @@ -0,0 +1,139 @@ +use napi::Result; +use sqlx::PgPool; + +use super::{auth_challenge, byok_local_lease, dto::RuntimeStateRows, invite_link, magic_link_otp, verification_token}; +use crate::backend_runtime::types::{ + RuntimeByokLocalLeaseRecord, RuntimeMagicLinkOtpConsumeResult, RuntimeVerificationTokenRecord, + RuntimeWorkspaceInviteLinkRecord, +}; + +pub(super) struct RuntimeStateStore { + rows: RuntimeStateRows, +} + +impl RuntimeStateStore { + pub(super) fn new(pool: PgPool) -> Self { + Self { + rows: RuntimeStateRows::new(pool), + } + } + + pub(super) async fn create_auth_challenge( + &self, + purpose: &str, + token: &str, + payload: serde_json::Value, + ttl_ms: i64, + ) -> Result { + auth_challenge::create(&self.rows, purpose, token, payload, ttl_ms).await + } + + pub(super) async fn get_auth_challenge(&self, purpose: &str, token: &str) -> Result> { + auth_challenge::get(&self.rows, purpose, token).await + } + + pub(super) async fn consume_auth_challenge(&self, purpose: &str, token: &str) -> Result> { + auth_challenge::consume(&self.rows, purpose, token).await + } + + pub(super) async fn create_verification_token( + &self, + token_type: i32, + credential: Option, + ttl_ms: i64, + ) -> Result { + verification_token::create(&self.rows, token_type, credential, ttl_ms).await + } + + pub(super) async fn get_verification_token( + &self, + token_type: i32, + token: String, + keep: bool, + ) -> Result> { + verification_token::get(&self.rows, token_type, token, keep).await + } + + pub(super) async fn verify_verification_token( + &self, + token_type: i32, + token: String, + credential: Option, + keep: bool, + ) -> Result> { + verification_token::verify(&self.rows, token_type, token, credential, keep).await + } + + pub(super) async fn cleanup_expired_verification_tokens(&self, limit: i64) -> Result { + verification_token::cleanup_expired(&self.rows, limit).await + } + + pub(super) async fn cleanup_expired_runtime_states(&self, limit: i64) -> Result { + self + .rows + .cleanup_expired_or_consumed(limit, "RuntimeState cleanup") + .await + } + + pub(super) async fn upsert_magic_link_otp( + &self, + email: String, + otp_hash: String, + token: String, + client_nonce: Option, + ttl_ms: i64, + ) -> Result<()> { + magic_link_otp::upsert(&self.rows, email, otp_hash, token, client_nonce, ttl_ms).await + } + + pub(super) async fn consume_magic_link_otp( + &self, + email: String, + otp_hash: String, + client_nonce: Option, + ) -> Result { + magic_link_otp::consume(&self.rows, email, otp_hash, client_nonce).await + } + + pub(super) async fn create_workspace_invite_link( + &self, + workspace_id: String, + invite_id: String, + inviter_user_id: String, + ttl_ms: i64, + ) -> Result { + invite_link::create(&self.rows, workspace_id, invite_id, inviter_user_id, ttl_ms).await + } + + pub(super) async fn get_workspace_invite_link( + &self, + workspace_id: String, + ) -> Result> { + invite_link::get_by_workspace(&self.rows, workspace_id).await + } + + pub(super) async fn get_workspace_invite_link_by_id( + &self, + invite_id: String, + ) -> Result> { + invite_link::get_by_invite_id(&self.rows, invite_id).await + } + + pub(super) async fn revoke_workspace_invite_link(&self, workspace_id: String) -> Result { + invite_link::revoke(&self.rows, workspace_id).await + } + + pub(super) async fn create_byok_local_lease( + &self, + active_key: String, + lease_id: String, + payload: serde_json::Value, + ttl_ms: i64, + ) -> Result { + byok_local_lease::create(&self.rows, active_key, lease_id, payload, ttl_ms).await + } + + pub(super) async fn get_byok_local_lease(&self, lease_id: String) -> Result> { + byok_local_lease::get(&self.rows, lease_id).await + } +} diff --git a/packages/backend/native/src/backend_runtime/runtime_state/verification_token.rs b/packages/backend/native/src/backend_runtime/runtime_state/verification_token.rs new file mode 100644 index 0000000000..6de836870b --- /dev/null +++ b/packages/backend/native/src/backend_runtime/runtime_state/verification_token.rs @@ -0,0 +1,150 @@ +use napi::Result; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use super::{ + dto::{RuntimeStatePayloadRow, RuntimeStateRows}, + verification_token_purpose, +}; +use crate::backend_runtime::{error::napi_error, token_hash, types::RuntimeVerificationTokenRecord}; + +pub(super) async fn create( + rows: &RuntimeStateRows, + token_type: i32, + credential: Option, + ttl_ms: i64, +) -> Result { + let token = Uuid::new_v4().to_string(); + let payload = serde_json::json!({ "credential": credential }); + + rows + .insert_payload( + &verification_token_purpose(token_type), + &token, + credential.as_deref(), + payload, + ttl_ms, + "RuntimeState verification token create", + ) + .await?; + + Ok(token) +} + +pub(super) async fn get( + rows: &RuntimeStateRows, + token_type: i32, + token: String, + keep: bool, +) -> Result> { + let purpose = verification_token_purpose(token_type); + let row = if keep { + rows + .active_payload_with_expires(&purpose, &token, "RuntimeState verification token get") + .await? + } else { + rows + .consume_payload_with_expires(&purpose, &token, "RuntimeState verification token get") + .await? + }; + + Ok(row.map(|row| record_from_row(token_type, token, row))) +} + +pub(super) async fn verify( + rows: &RuntimeStateRows, + token_type: i32, + token: String, + credential: Option, + keep: bool, +) -> Result> { + let purpose = verification_token_purpose(token_type); + let row = if keep { + active_payload_with_credential(rows.pool(), &purpose, &token, credential.as_deref()).await + } else { + consume_payload_with_credential(rows.pool(), &purpose, &token, credential.as_deref()).await + } + .map_err(|err| napi_error(format!("RuntimeState verification token verify failed: {err}")))?; + + Ok(row.map(|row| record_from_row(token_type, token, row))) +} + +pub(super) async fn cleanup_expired(rows: &RuntimeStateRows, limit: i64) -> Result { + rows + .cleanup_expired_by_purpose_prefix("verification_token:", limit, "RuntimeState verification token cleanup") + .await +} + +async fn active_payload_with_credential( + pool: &PgPool, + purpose: &str, + token: &str, + credential: Option<&str>, +) -> sqlx::Result> { + let row = sqlx::query( + r#" + SELECT payload, (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + FROM runtime_states + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + AND (payload->>'credential' IS NULL OR payload->>'credential' = $3) + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .bind(credential) + .fetch_optional(pool) + .await?; + + Ok(row.map(payload_row)) +} + +async fn consume_payload_with_credential( + pool: &PgPool, + purpose: &str, + token: &str, + credential: Option<&str>, +) -> sqlx::Result> { + let row = sqlx::query( + r#" + UPDATE runtime_states + SET consumed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE purpose = $1 + AND token_hash = $2 + AND consumed_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + AND (payload->>'credential' IS NULL OR payload->>'credential' = $3) + RETURNING payload, (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + "#, + ) + .bind(purpose) + .bind(token_hash(token)) + .bind(credential) + .fetch_optional(pool) + .await?; + + Ok(row.map(payload_row)) +} + +fn payload_row(row: sqlx::postgres::PgRow) -> RuntimeStatePayloadRow { + RuntimeStatePayloadRow { + payload: row.get("payload"), + expires_at_ms: row.get("expires_at_ms"), + } +} + +fn record_from_row(token_type: i32, token: String, row: RuntimeStatePayloadRow) -> RuntimeVerificationTokenRecord { + RuntimeVerificationTokenRecord { + token_type, + token, + credential: row + .payload + .get("credential") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + expires_at_ms: row.expires_at_ms, + } +} diff --git a/packages/backend/native/src/backend_runtime/sql/runtime_migrations.sql b/packages/backend/native/src/backend_runtime/sql/runtime_migrations.sql new file mode 100644 index 0000000000..4912f1026d --- /dev/null +++ b/packages/backend/native/src/backend_runtime/sql/runtime_migrations.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS runtime_states ( + purpose TEXT NOT NULL, + token_hash TEXT NOT NULL, + lookup_key TEXT, + payload JSONB NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + consumed_at TIMESTAMPTZ(3), + expires_at TIMESTAMPTZ(3) NOT NULL, + created_at TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (purpose, token_hash) +); + +CREATE INDEX IF NOT EXISTS runtime_states_lookup_idx + ON runtime_states (purpose, lookup_key) + WHERE lookup_key IS NOT NULL AND consumed_at IS NULL; + +CREATE INDEX IF NOT EXISTS runtime_states_expires_at_idx + ON runtime_states (expires_at); + +CREATE TABLE IF NOT EXISTS runtime_gates ( + key TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ(3) NOT NULL, + created_at TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS runtime_gates_expires_at_idx + ON runtime_gates (expires_at); + +CREATE TABLE IF NOT EXISTS runtime_leases ( + key TEXT PRIMARY KEY, + owner TEXT NOT NULL, + fencing_token BIGINT NOT NULL, + expires_at TIMESTAMPTZ(3) NOT NULL, + created_at TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS runtime_leases_expires_at_idx + ON runtime_leases (expires_at); diff --git a/packages/backend/native/src/backend_runtime/sql/upsert_workspace_admin_stats.sql b/packages/backend/native/src/backend_runtime/sql/upsert_workspace_admin_stats.sql new file mode 100644 index 0000000000..15ecc06617 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/sql/upsert_workspace_admin_stats.sql @@ -0,0 +1,85 @@ +WITH targets AS ( + SELECT UNNEST($1::varchar[]) AS workspace_id +), +snapshot_stats AS ( + SELECT workspace_id, + COUNT(*) AS snapshot_count, + COALESCE(SUM(COALESCE(size, octet_length(blob))), 0) AS snapshot_size + FROM snapshots + WHERE workspace_id IN (SELECT workspace_id FROM targets) + GROUP BY workspace_id +), +blob_stats AS ( + SELECT workspace_id, + COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count, + COALESCE(SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed'), 0) AS blob_size + FROM blobs + WHERE workspace_id IN (SELECT workspace_id FROM targets) + GROUP BY workspace_id +), +member_stats AS ( + SELECT workspace_id, COUNT(*) AS member_count + FROM workspace_user_permissions + WHERE workspace_id IN (SELECT workspace_id FROM targets) + GROUP BY workspace_id +), +public_page_stats AS ( + SELECT workspace_id, COUNT(*) AS public_page_count + FROM workspace_pages + WHERE public = TRUE AND workspace_id IN (SELECT workspace_id FROM targets) + GROUP BY workspace_id +), +feature_stats AS ( + SELECT workspace_id, + ARRAY_AGG(DISTINCT name ORDER BY name) FILTER (WHERE activated) AS features + FROM workspace_features + WHERE workspace_id IN (SELECT workspace_id FROM targets) + GROUP BY workspace_id +), +aggregated AS ( + SELECT t.workspace_id, + COALESCE(ss.snapshot_count, 0) AS snapshot_count, + COALESCE(ss.snapshot_size, 0) AS snapshot_size, + COALESCE(bs.blob_count, 0) AS blob_count, + COALESCE(bs.blob_size, 0) AS blob_size, + COALESCE(ms.member_count, 0) AS member_count, + COALESCE(pp.public_page_count, 0) AS public_page_count, + COALESCE(fs.features, ARRAY[]::text[]) AS features + FROM targets t + LEFT JOIN snapshot_stats ss ON ss.workspace_id = t.workspace_id + LEFT JOIN blob_stats bs ON bs.workspace_id = t.workspace_id + LEFT JOIN member_stats ms ON ms.workspace_id = t.workspace_id + LEFT JOIN public_page_stats pp ON pp.workspace_id = t.workspace_id + LEFT JOIN feature_stats fs ON fs.workspace_id = t.workspace_id +) +INSERT INTO workspace_admin_stats ( + workspace_id, + snapshot_count, + snapshot_size, + blob_count, + blob_size, + member_count, + public_page_count, + features, + updated_at +) +SELECT + workspace_id, + snapshot_count, + snapshot_size, + blob_count, + blob_size, + member_count, + public_page_count, + features, + NOW() +FROM aggregated +ON CONFLICT (workspace_id) DO UPDATE SET + snapshot_count = EXCLUDED.snapshot_count, + snapshot_size = EXCLUDED.snapshot_size, + blob_count = EXCLUDED.blob_count, + blob_size = EXCLUDED.blob_size, + member_count = EXCLUDED.member_count, + public_page_count = EXCLUDED.public_page_count, + features = EXCLUDED.features, + updated_at = EXCLUDED.updated_at diff --git a/packages/backend/native/src/backend_runtime/tests.rs b/packages/backend/native/src/backend_runtime/tests.rs new file mode 100644 index 0000000000..a734259ab9 --- /dev/null +++ b/packages/backend/native/src/backend_runtime/tests.rs @@ -0,0 +1,431 @@ +use anyhow::{Context, Result as AnyResult, anyhow}; + +use super::{runtime_state::*, *}; + +static PG_TEST_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); +const TEST_VERIFICATION_TOKEN_TYPE: i32 = 99_999; + +fn pg_test_lock() -> &'static tokio::sync::Mutex<()> { + PG_TEST_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +#[test] +fn migrations_include_runtime_tables_without_worker_heartbeats() { + assert!(RUNTIME_MIGRATIONS.contains("runtime_states")); + assert!(RUNTIME_MIGRATIONS.contains("runtime_gates")); + assert!(RUNTIME_MIGRATIONS.contains("runtime_leases")); + assert!(!RUNTIME_MIGRATIONS.contains("runtime_worker_heartbeats")); +} + +#[test] +fn auth_challenge_state_uses_scoped_purpose_and_token_hash() { + assert_eq!(auth_challenge_purpose("oauth_state"), "auth_challenge:oauth_state"); + assert_ne!(token_hash("plain-token"), "plain-token"); + assert_eq!(token_hash("plain-token"), token_hash("plain-token")); + assert_ne!(token_hash("plain-token"), token_hash("other-token")); +} + +#[test] +fn verification_token_state_uses_typed_purpose_and_token_hash() { + assert_eq!(verification_token_purpose(0), "verification_token:0"); + assert_ne!(token_hash("verification-token"), "verification-token"); + assert_eq!(token_hash("verification-token"), token_hash("verification-token")); + assert_ne!(token_hash("verification-token"), token_hash("other-token")); +} + +async fn runtime_from_database_url() -> AnyResult> { + let Ok(database_url) = std::env::var("DATABASE_URL") else { + return Ok(None); + }; + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .context("connect postgres for backend runtime tests")?; + migrate_runtime_tables(&pool) + .await + .map_err(|err| anyhow!(err.to_string()))?; + sqlx::query( + r#" + DELETE FROM runtime_states + WHERE purpose LIKE 'rust_test:%' + OR purpose LIKE 'auth_challenge:rust_test:%' + OR purpose = 'verification_token:99999' + "#, + ) + .execute(&pool) + .await + .context("cleanup runtime_states for backend runtime tests")?; + sqlx::query("DELETE FROM runtime_gates WHERE key LIKE 'rust-test:%'") + .execute(&pool) + .await + .context("cleanup runtime_gates for backend runtime tests")?; + sqlx::query("DELETE FROM runtime_leases WHERE key LIKE 'rust-test:%'") + .execute(&pool) + .await + .context("cleanup runtime_leases for backend runtime tests")?; + + Ok(Some(BackendRuntime { + config: RuntimeConfig { + database_url, + storage: None, + }, + pool: Mutex::new(Some(pool)), + })) +} + +#[tokio::test] +async fn runtime_gate_sql_semantics_are_atomic_and_ttl_bound() { + let _guard = pg_test_lock().lock().await; + let Some(runtime) = runtime_from_database_url().await.unwrap() else { + eprintln!("skipping postgres integration test: DATABASE_URL is not set"); + return; + }; + + struct Case { + key: &'static str, + first_ttl_ms: i64, + wait_ms: Option, + second_expected: bool, + } + + for case in [ + Case { + key: "rust-test:gate:same-key", + first_ttl_ms: 30_000, + wait_ms: None, + second_expected: false, + }, + Case { + key: "rust-test:gate:expired-key", + first_ttl_ms: 1, + wait_ms: Some(20), + second_expected: true, + }, + ] { + assert!( + runtime + .put_runtime_gate_if_absent(case.key.to_string(), case.first_ttl_ms) + .await + .unwrap() + ); + if let Some(wait_ms) = case.wait_ms { + tokio::time::sleep(Duration::from_millis(wait_ms)).await; + } + assert_eq!( + runtime + .put_runtime_gate_if_absent(case.key.to_string(), 30_000) + .await + .unwrap(), + case.second_expected, + "{}", + case.key + ); + } + + let mut tasks = Vec::new(); + for _ in 0..16 { + let runtime = BackendRuntime { + config: runtime.config.clone(), + pool: Mutex::new(Some(runtime.pool().await.unwrap())), + }; + tasks.push(tokio::spawn(async move { + runtime + .put_runtime_gate_if_absent("rust-test:gate:concurrent".to_string(), 30_000) + .await + .unwrap() + })); + } + let mut successful = 0; + for task in tasks { + if task.await.unwrap() { + successful += 1; + } + } + assert_eq!(successful, 1); + + assert!( + runtime + .put_runtime_gate_if_absent("rust-test:gate:cleanup".to_string(), 1) + .await + .unwrap() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(runtime.cleanup_expired_runtime_gates(100).await.unwrap(), 1); + assert_eq!(runtime.cleanup_expired_runtime_gates(100).await.unwrap(), 0); +} + +#[tokio::test] +async fn coordination_lease_sql_semantics_are_fenced_and_ttl_bound() { + let _guard = pg_test_lock().lock().await; + let Some(runtime) = runtime_from_database_url().await.unwrap() else { + eprintln!("skipping postgres integration test: DATABASE_URL is not set"); + return; + }; + + let lease = runtime + .acquire_coordination_lease("rust-test:lease:basic".to_string(), "owner-1".to_string(), 30_000) + .await + .unwrap() + .expect("first owner should acquire lease"); + assert_eq!(lease.fencing_token, 1); + assert!( + !runtime + .release_coordination_lease(lease.key.clone(), "owner-2".to_string(), lease.fencing_token) + .await + .unwrap() + ); + assert!( + runtime + .release_coordination_lease(lease.key.clone(), lease.owner.clone(), lease.fencing_token) + .await + .unwrap() + ); + + let mut tasks = Vec::new(); + for index in 0..16 { + let runtime = BackendRuntime { + config: runtime.config.clone(), + pool: Mutex::new(Some(runtime.pool().await.unwrap())), + }; + tasks.push(tokio::spawn(async move { + runtime + .acquire_coordination_lease( + "rust-test:lease:concurrent".to_string(), + format!("owner-{index}"), + 30_000, + ) + .await + .unwrap() + .is_some() + })); + } + let mut successful = 0; + for task in tasks { + if task.await.unwrap() { + successful += 1; + } + } + assert_eq!(successful, 1); + + let stale = runtime + .acquire_coordination_lease("rust-test:lease:stale".to_string(), "owner-1".to_string(), 1) + .await + .unwrap() + .expect("stale lease owner should acquire"); + tokio::time::sleep(Duration::from_millis(20)).await; + let takeover = runtime + .acquire_coordination_lease("rust-test:lease:stale".to_string(), "owner-2".to_string(), 30_000) + .await + .unwrap() + .expect("expired lease should be taken over"); + assert_eq!(takeover.fencing_token, stale.fencing_token + 1); + assert!( + !runtime + .release_coordination_lease(stale.key.clone(), stale.owner.clone(), stale.fencing_token) + .await + .unwrap() + ); + + let renew = runtime + .acquire_coordination_lease("rust-test:lease:renew".to_string(), "owner-1".to_string(), 30_000) + .await + .unwrap() + .expect("renew lease owner should acquire"); + assert!( + !runtime + .renew_coordination_lease(renew.key.clone(), "owner-2".to_string(), renew.fencing_token, 30_000) + .await + .unwrap() + ); + assert!( + !runtime + .renew_coordination_lease(renew.key.clone(), renew.owner.clone(), renew.fencing_token + 1, 30_000) + .await + .unwrap() + ); + assert!( + runtime + .renew_coordination_lease(renew.key.clone(), renew.owner.clone(), renew.fencing_token, 30_000) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn runtime_state_cleanup_deletes_expired_and_consumed_rows() { + let _guard = pg_test_lock().lock().await; + let Some(runtime) = runtime_from_database_url().await.unwrap() else { + eprintln!("skipping postgres integration test: DATABASE_URL is not set"); + return; + }; + + assert!( + runtime + .create_auth_challenge( + "rust_test:cleanup".to_string(), + "expired".to_string(), + serde_json::json!({}), + 1 + ) + .await + .unwrap() + ); + assert!( + runtime + .create_auth_challenge( + "rust_test:cleanup".to_string(), + "consumed".to_string(), + serde_json::json!({}), + 30_000, + ) + .await + .unwrap() + ); + assert!( + runtime + .consume_auth_challenge("rust_test:cleanup".to_string(), "consumed".to_string()) + .await + .unwrap() + .is_some() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + + assert_eq!(runtime.cleanup_expired_runtime_states(100).await.unwrap(), 2); + assert_eq!(runtime.cleanup_expired_runtime_states(100).await.unwrap(), 0); +} + +#[tokio::test] +async fn verification_token_sql_state_machine_handles_keep_verify_and_cleanup() { + let _guard = pg_test_lock().lock().await; + let Some(runtime) = runtime_from_database_url().await.unwrap() else { + eprintln!("skipping postgres integration test: DATABASE_URL is not set"); + return; + }; + + let mismatch_token = runtime + .create_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + Some("user@affine.test".to_string()), + 30_000, + ) + .await + .unwrap(); + assert!( + runtime + .verify_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + mismatch_token.clone(), + Some("wrong@affine.test".to_string()), + None, + ) + .await + .unwrap() + .is_none() + ); + assert!( + runtime + .verify_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + mismatch_token.clone(), + Some("user@affine.test".to_string()), + None, + ) + .await + .unwrap() + .is_some() + ); + assert!( + runtime + .verify_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + mismatch_token.clone(), + Some("user@affine.test".to_string()), + None, + ) + .await + .unwrap() + .is_none() + ); + + let keep_token = runtime + .create_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + Some("keep@affine.test".to_string()), + 30_000, + ) + .await + .unwrap(); + assert!( + runtime + .get_verification_token(TEST_VERIFICATION_TOKEN_TYPE, keep_token.clone(), Some(true)) + .await + .unwrap() + .is_some() + ); + assert!( + runtime + .get_verification_token(TEST_VERIFICATION_TOKEN_TYPE, keep_token.clone(), None) + .await + .unwrap() + .is_some() + ); + assert!( + runtime + .get_verification_token(TEST_VERIFICATION_TOKEN_TYPE, keep_token.clone(), None) + .await + .unwrap() + .is_none() + ); + + let concurrent_token = runtime + .create_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + Some("concurrent@affine.test".to_string()), + 30_000, + ) + .await + .unwrap(); + let mut tasks = Vec::new(); + for _ in 0..16 { + let runtime = BackendRuntime { + config: runtime.config.clone(), + pool: Mutex::new(Some(runtime.pool().await.unwrap())), + }; + let token = concurrent_token.clone(); + tasks.push(tokio::spawn(async move { + runtime + .verify_verification_token( + TEST_VERIFICATION_TOKEN_TYPE, + token, + Some("concurrent@affine.test".to_string()), + None, + ) + .await + .unwrap() + .is_some() + })); + } + let mut successful = 0; + for task in tasks { + if task.await.unwrap() { + successful += 1; + } + } + assert_eq!(successful, 1); + + let expired_token = runtime + .create_verification_token(TEST_VERIFICATION_TOKEN_TYPE, Some("expired@affine.test".to_string()), 1) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + runtime + .get_verification_token(TEST_VERIFICATION_TOKEN_TYPE, expired_token.clone(), None) + .await + .unwrap() + .is_none() + ); + assert_eq!(runtime.cleanup_expired_verification_tokens(100).await.unwrap(), 1); + assert_eq!(runtime.cleanup_expired_verification_tokens(100).await.unwrap(), 0); +} diff --git a/packages/backend/native/src/backend_runtime/types.rs b/packages/backend/native/src/backend_runtime/types.rs new file mode 100644 index 0000000000..513859a8da --- /dev/null +++ b/packages/backend/native/src/backend_runtime/types.rs @@ -0,0 +1,177 @@ +use napi::bindgen_prelude::Buffer; + +#[napi_derive::napi(object)] +pub struct RuntimeVerificationTokenRecord { + pub token_type: i32, + pub token: String, + pub credential: Option, + pub expires_at_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct BackendRuntimeHealth { + pub started: bool, + pub database_connected: bool, + pub object_storage_configured: bool, +} + +#[napi_derive::napi(object)] +pub struct RuntimeObjectStorageHealth { + pub configured: bool, + pub provider: Option, + pub bucket: Option, + pub endpoint: Option, + pub region: Option, + pub has_credentials: bool, + pub force_path_style: bool, + pub request_timeout_ms: Option, + pub min_part_size: Option, + pub presign_expires_in_seconds: Option, + pub presign_sign_content_type_for_put: Option, + pub use_presigned_url: bool, + pub client_buildable: bool, +} + +#[napi_derive::napi(object)] +pub struct CoordinationLeaseGrant { + pub key: String, + pub owner: String, + #[napi(ts_type = "bigint | number")] + pub fencing_token: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeMagicLinkOtpConsumeResult { + pub ok: bool, + pub token: Option, + pub reason: Option, +} + +#[napi_derive::napi(object)] +pub struct RuntimeWorkspaceInviteLinkRecord { + pub workspace_id: String, + pub invite_id: String, + pub inviter_user_id: String, + pub expires_at_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeByokLocalLeaseRecord { + pub lease_id: String, + pub payload: serde_json::Value, + pub expires_at_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeDocHistoryInput { + pub workspace_id: String, + pub doc_id: String, + pub blob: Buffer, + pub timestamp_ms: i64, + pub editor_id: Option, + pub force: bool, + pub history_min_interval_ms: i64, + pub history_max_age_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeObjectStoragePutOptions { + pub content_type: Option, + pub content_length: Option, + pub checksum_crc32: Option, +} + +#[napi_derive::napi(object)] +pub struct RuntimeObjectMetadata { + pub content_type: String, + pub content_length: i64, + pub last_modified_ms: i64, + pub checksum_crc32: Option, +} + +#[napi_derive::napi(object)] +pub struct RuntimeObjectListEntry { + pub key: String, + pub content_length: i64, + pub last_modified_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeObjectGetResult { + pub body: Buffer, + pub metadata: RuntimeObjectMetadata, +} + +#[napi_derive::napi(object)] +pub struct RuntimePresignedObjectRequest { + pub url: String, + pub headers_json: String, + pub expires_at_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeMultipartUploadInit { + pub upload_id: String, + pub expires_at_ms: i64, +} + +#[napi_derive::napi(object)] +pub struct RuntimeMultipartUploadPart { + pub part_number: i32, + pub etag: String, +} + +#[napi_derive::napi(object)] +pub struct RuntimeBlobCleanupResult { + pub scanned: i64, + pub deleted: i64, + pub aborted_multipart: i64, + pub workspace_ids: Vec, +} + +#[napi_derive::napi(object)] +pub struct RuntimeBlobCompleteResult { + pub ok: bool, + pub reason: Option, + pub content_type: Option, + pub content_length: Option, + pub last_modified_ms: Option, +} + +#[napi_derive::napi(object)] +pub struct RuntimeDocCompactionResult { + pub lease_acquired: bool, + pub merged: bool, + pub workspace_id: String, + pub doc_id: String, + pub updates_merged: i64, + pub history_created: bool, +} + +#[napi_derive::napi(object)] +pub struct RuntimeWorkspaceStatsRefreshResult { + pub processed: i64, + pub backlog: i64, + pub skipped: bool, +} + +#[napi_derive::napi(object)] +pub struct RuntimeWorkspaceStatsRecalibrationResult { + pub processed: i64, + pub last_sid: i64, + pub skipped: bool, +} + +#[napi_derive::napi(object)] +pub struct RuntimeWorkspaceStatsSnapshotResult { + pub snapshotted: i64, + pub skipped: bool, +} + +#[napi_derive::napi(object)] +pub struct RuntimeWorkspaceStatsDailyRecalibrationResult { + pub processed: i64, + pub last_sid: i64, + pub snapshotted: i64, + pub skipped: bool, +} diff --git a/packages/backend/native/src/backend_runtime/workspace_stats.rs b/packages/backend/native/src/backend_runtime/workspace_stats.rs new file mode 100644 index 0000000000..70e749b00d --- /dev/null +++ b/packages/backend/native/src/backend_runtime/workspace_stats.rs @@ -0,0 +1,527 @@ +use napi::Result; +use sqlx::{FromRow, PgPool, Postgres, Row, Transaction}; +use tokio::time::{Duration as TokioDuration, sleep}; + +use super::{ + BackendRuntime, + constants::{WORKSPACE_STATS_LEASE_KEY, WORKSPACE_STATS_LOCK_NAMESPACE, WORKSPACE_STATS_REFRESH_LOCK_KEY}, + error::napi_error, + types::{ + CoordinationLeaseGrant, RuntimeWorkspaceStatsDailyRecalibrationResult, RuntimeWorkspaceStatsRecalibrationResult, + RuntimeWorkspaceStatsRefreshResult, RuntimeWorkspaceStatsSnapshotResult, + }, +}; + +const UPSERT_WORKSPACE_ADMIN_STATS_SQL: &str = include_str!("sql/upsert_workspace_admin_stats.sql"); + +#[napi_derive::napi] +impl BackendRuntime { + #[napi] + pub async fn refresh_workspace_admin_stats_dirty( + &self, + batch_limit: i64, + owner: String, + lease_ttl_ms: i64, + ) -> Result { + if batch_limit <= 0 { + return Err(napi_error("workspace stats dirty refresh limit must be positive")); + } + + let Some(lease) = self + .acquire_coordination_lease(WORKSPACE_STATS_LEASE_KEY.to_string(), owner, lease_ttl_ms) + .await? + else { + return Ok(RuntimeWorkspaceStatsRefreshResult { + processed: 0, + backlog: 0, + skipped: true, + }); + }; + + let result = async { + WorkspaceStatsStore::new(self.pool().await?) + .refresh_dirty(batch_limit) + .await + } + .await; + + release_workspace_stats_lease(self, lease).await?; + result + } + + #[napi] + pub async fn recalibrate_workspace_admin_stats( + &self, + last_sid: i64, + batch_limit: i64, + owner: String, + lease_ttl_ms: i64, + ) -> Result { + if batch_limit <= 0 { + return Err(napi_error("workspace stats recalibration limit must be positive")); + } + + let Some(lease) = self + .acquire_coordination_lease(WORKSPACE_STATS_LEASE_KEY.to_string(), owner, lease_ttl_ms) + .await? + else { + return Ok(RuntimeWorkspaceStatsRecalibrationResult { + processed: 0, + last_sid, + skipped: true, + }); + }; + + let result = async { + WorkspaceStatsStore::new(self.pool().await?) + .recalibrate(last_sid, batch_limit) + .await + } + .await; + + release_workspace_stats_lease(self, lease).await?; + result + } + + #[napi] + pub async fn write_workspace_admin_stats_daily_snapshot( + &self, + owner: String, + lease_ttl_ms: i64, + ) -> Result { + let Some(lease) = self + .acquire_coordination_lease(WORKSPACE_STATS_LEASE_KEY.to_string(), owner, lease_ttl_ms) + .await? + else { + return Ok(RuntimeWorkspaceStatsSnapshotResult { + snapshotted: 0, + skipped: true, + }); + }; + + let result = async { + WorkspaceStatsStore::new(self.pool().await?) + .write_daily_snapshot() + .await + } + .await; + + release_workspace_stats_lease(self, lease).await?; + result + } + + #[napi] + pub async fn recalibrate_workspace_admin_stats_daily( + &self, + batch_limit: i64, + owner: String, + lease_ttl_ms: i64, + lock_retry_times: i64, + lock_retry_delay_ms: i64, + ) -> Result { + if batch_limit <= 0 { + return Err(napi_error("workspace stats daily recalibration limit must be positive")); + } + if lock_retry_times <= 0 { + return Err(napi_error( + "workspace stats daily recalibration retry times must be positive", + )); + } + if lock_retry_delay_ms < 0 { + return Err(napi_error( + "workspace stats daily recalibration retry delay must be non-negative", + )); + } + + let Some(lease) = acquire_workspace_stats_lease_with_retry( + self, + owner.clone(), + lease_ttl_ms, + lock_retry_times, + lock_retry_delay_ms, + ) + .await? + else { + return Ok(RuntimeWorkspaceStatsDailyRecalibrationResult { + processed: 0, + last_sid: 0, + snapshotted: 0, + skipped: true, + }); + }; + + let result = async { + let store = WorkspaceStatsStore::new(self.pool().await?); + let mut processed = 0; + let mut last_sid = 0; + + loop { + let batch = retry_workspace_stats_operation(lock_retry_times, lock_retry_delay_ms, || { + store.recalibrate(last_sid, batch_limit) + }) + .await?; + + if batch.skipped { + return Ok(RuntimeWorkspaceStatsDailyRecalibrationResult { + processed, + last_sid, + snapshotted: 0, + skipped: true, + }); + } + + if batch.processed == 0 { + break; + } + + processed += batch.processed; + last_sid = batch.last_sid; + + if batch.processed < batch_limit { + break; + } + } + + let snapshot = + retry_workspace_stats_operation(lock_retry_times, lock_retry_delay_ms, || store.write_daily_snapshot()).await?; + + Ok(RuntimeWorkspaceStatsDailyRecalibrationResult { + processed, + last_sid, + snapshotted: snapshot.snapshotted, + skipped: snapshot.skipped, + }) + } + .await; + + release_workspace_stats_lease(self, lease).await?; + result + } +} + +#[derive(FromRow)] +struct WorkspaceSid { + id: String, + sid: i32, +} + +struct WorkspaceStatsStore { + pool: PgPool, +} + +impl WorkspaceStatsStore { + fn new(pool: PgPool) -> Self { + Self { pool } + } + + async fn refresh_dirty(&self, batch_limit: i64) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| napi_error(format!("WorkspaceStats dirty refresh transaction failed: {err}")))?; + if !try_transaction_lock(&mut tx).await? { + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats dirty refresh commit failed: {err}")))?; + return Ok(RuntimeWorkspaceStatsRefreshResult { + processed: 0, + backlog: 0, + skipped: true, + }); + } + + let backlog = count_dirty(&mut tx).await?; + let dirty = load_dirty(&mut tx, batch_limit).await?; + if dirty.is_empty() { + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats dirty refresh commit failed: {err}")))?; + return Ok(RuntimeWorkspaceStatsRefreshResult { + processed: 0, + backlog, + skipped: false, + }); + } + + upsert_stats(&mut tx, &dirty).await?; + clear_dirty(&mut tx, &dirty).await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats dirty refresh commit failed: {err}")))?; + + Ok(RuntimeWorkspaceStatsRefreshResult { + processed: dirty.len() as i64, + backlog, + skipped: false, + }) + } + + async fn recalibrate(&self, last_sid: i64, batch_limit: i64) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| napi_error(format!("WorkspaceStats recalibration transaction failed: {err}")))?; + if !try_transaction_lock(&mut tx).await? { + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats recalibration commit failed: {err}")))?; + return Ok(RuntimeWorkspaceStatsRecalibrationResult { + processed: 0, + last_sid, + skipped: true, + }); + } + + let workspaces = fetch_workspace_batch(&mut tx, last_sid, batch_limit).await?; + if workspaces.is_empty() { + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats recalibration commit failed: {err}")))?; + return Ok(RuntimeWorkspaceStatsRecalibrationResult { + processed: 0, + last_sid, + skipped: false, + }); + } + + let ids = workspaces + .iter() + .map(|workspace| workspace.id.clone()) + .collect::>(); + let next_sid = workspaces + .last() + .map(|workspace| workspace.sid as i64) + .unwrap_or(last_sid); + upsert_stats(&mut tx, &ids).await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats recalibration commit failed: {err}")))?; + + Ok(RuntimeWorkspaceStatsRecalibrationResult { + processed: ids.len() as i64, + last_sid: next_sid, + skipped: false, + }) + } + + async fn write_daily_snapshot(&self) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| napi_error(format!("WorkspaceStats daily snapshot transaction failed: {err}")))?; + if !try_transaction_lock(&mut tx).await? { + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats daily snapshot commit failed: {err}")))?; + return Ok(RuntimeWorkspaceStatsSnapshotResult { + snapshotted: 0, + skipped: true, + }); + } + let snapshotted = write_daily_snapshot(&mut tx).await?; + tx.commit() + .await + .map_err(|err| napi_error(format!("WorkspaceStats daily snapshot commit failed: {err}")))?; + + Ok(RuntimeWorkspaceStatsSnapshotResult { + snapshotted, + skipped: false, + }) + } +} + +async fn release_workspace_stats_lease(runtime: &BackendRuntime, lease: CoordinationLeaseGrant) -> Result<()> { + let _ = runtime + .release_coordination_lease(lease.key, lease.owner, lease.fencing_token) + .await?; + Ok(()) +} + +async fn acquire_workspace_stats_lease_with_retry( + runtime: &BackendRuntime, + owner: String, + lease_ttl_ms: i64, + retry_times: i64, + retry_delay_ms: i64, +) -> Result> { + for attempt in 0..retry_times { + let lease = runtime + .acquire_coordination_lease(WORKSPACE_STATS_LEASE_KEY.to_string(), owner.clone(), lease_ttl_ms) + .await?; + if lease.is_some() { + return Ok(lease); + } + + if attempt < retry_times - 1 && retry_delay_ms > 0 { + sleep(TokioDuration::from_millis(retry_delay_ms as u64)).await; + } + } + + Ok(None) +} + +async fn retry_workspace_stats_operation( + retry_times: i64, + retry_delay_ms: i64, + mut operation: F, +) -> Result +where + T: WorkspaceStatsSkippable, + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for attempt in 0..retry_times { + let result = operation().await?; + if !result.skipped() || attempt == retry_times - 1 { + return Ok(result); + } + + if retry_delay_ms > 0 { + sleep(TokioDuration::from_millis(retry_delay_ms as u64)).await; + } + } + + unreachable!("workspace stats retry loop validates retry_times > 0") +} + +trait WorkspaceStatsSkippable { + fn skipped(&self) -> bool; +} + +impl WorkspaceStatsSkippable for RuntimeWorkspaceStatsRecalibrationResult { + fn skipped(&self) -> bool { + self.skipped + } +} + +impl WorkspaceStatsSkippable for RuntimeWorkspaceStatsSnapshotResult { + fn skipped(&self) -> bool { + self.skipped + } +} + +async fn try_transaction_lock(tx: &mut Transaction<'_, Postgres>) -> Result { + let row = sqlx::query( + r#" + SELECT pg_try_advisory_xact_lock(($1::bigint << 32) + $2::bigint) AS locked + "#, + ) + .bind(WORKSPACE_STATS_LOCK_NAMESPACE) + .bind(WORKSPACE_STATS_REFRESH_LOCK_KEY) + .fetch_one(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats transaction lock failed: {err}")))?; + + Ok(row.get::("locked")) +} + +async fn load_dirty(tx: &mut Transaction<'_, Postgres>, limit: i64) -> Result> { + let rows = sqlx::query( + r#" + SELECT workspace_id + FROM workspace_admin_stats_dirty + ORDER BY updated_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + "#, + ) + .bind(limit) + .fetch_all(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats load dirty workspaces failed: {err}")))?; + + Ok(rows.into_iter().map(|row| row.get("workspace_id")).collect()) +} + +async fn count_dirty(tx: &mut Transaction<'_, Postgres>) -> Result { + let row = sqlx::query("SELECT COUNT(*) AS total FROM workspace_admin_stats_dirty") + .fetch_one(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats count dirty workspaces failed: {err}")))?; + Ok(row.get::("total")) +} + +async fn clear_dirty(tx: &mut Transaction<'_, Postgres>, workspace_ids: &[String]) -> Result<()> { + sqlx::query( + r#" + DELETE FROM workspace_admin_stats_dirty + WHERE workspace_id = ANY($1::varchar[]) + "#, + ) + .bind(workspace_ids) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats clear dirty workspaces failed: {err}")))?; + Ok(()) +} + +async fn upsert_stats(tx: &mut Transaction<'_, Postgres>, workspace_ids: &[String]) -> Result<()> { + if workspace_ids.is_empty() { + return Ok(()); + } + + sqlx::query(UPSERT_WORKSPACE_ADMIN_STATS_SQL) + .bind(workspace_ids) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats upsert stats failed: {err}")))?; + Ok(()) +} + +async fn fetch_workspace_batch( + tx: &mut Transaction<'_, Postgres>, + last_sid: i64, + limit: i64, +) -> Result> { + sqlx::query_as::<_, WorkspaceSid>( + r#" + SELECT id, sid + FROM workspaces + WHERE sid > $1 + ORDER BY sid + LIMIT $2 + "#, + ) + .bind(last_sid) + .bind(limit) + .fetch_all(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats fetch workspace batch failed: {err}"))) +} + +async fn write_daily_snapshot(tx: &mut Transaction<'_, Postgres>) -> Result { + let result = sqlx::query( + r#" + INSERT INTO workspace_admin_stats_daily ( + workspace_id, + date, + snapshot_size, + blob_size, + member_count, + updated_at + ) + SELECT + workspace_id, + CURRENT_DATE, + snapshot_size, + blob_size, + member_count, + NOW() + FROM workspace_admin_stats + ON CONFLICT (workspace_id, date) + DO UPDATE SET + snapshot_size = EXCLUDED.snapshot_size, + blob_size = EXCLUDED.blob_size, + member_count = EXCLUDED.member_count, + updated_at = EXCLUDED.updated_at + "#, + ) + .execute(&mut **tx) + .await + .map_err(|err| napi_error(format!("WorkspaceStats daily snapshot failed: {err}")))?; + + Ok(result.rows_affected() as i64) +} diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index faac1a1ba6..ed3cc66bb7 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -2,6 +2,7 @@ mod utils; +pub mod backend_runtime; pub mod doc; pub mod doc_loader; pub mod entitlement; diff --git a/packages/backend/native/src/utils.rs b/packages/backend/native/src/utils.rs index 143f1ff6bc..6940aa6253 100644 --- a/packages/backend/native/src/utils.rs +++ b/packages/backend/native/src/utils.rs @@ -26,11 +26,8 @@ fn try_remove_label(s: &str, i: usize) -> Option { return None; } - if let Some(ch) = s[next_idx..].chars().next() { - if !ch.is_whitespace() { - return None; - } - } else { + let ch = s[next_idx..].chars().next()?; + if !ch.is_whitespace() { return None; } diff --git a/packages/backend/server/migrations/20260618000000_backend_runtime_tables/migration.sql b/packages/backend/server/migrations/20260618000000_backend_runtime_tables/migration.sql new file mode 100644 index 0000000000..24a86dc91d --- /dev/null +++ b/packages/backend/server/migrations/20260618000000_backend_runtime_tables/migration.sql @@ -0,0 +1,39 @@ +CREATE TABLE "runtime_states" ( + "purpose" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "lookup_key" TEXT, + "payload" JSONB NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "consumed_at" TIMESTAMPTZ(3), + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "runtime_states_pkey" PRIMARY KEY ("purpose", "token_hash") +); + +CREATE INDEX "runtime_states_lookup_idx" ON "runtime_states"("purpose", "lookup_key") WHERE "lookup_key" IS NOT NULL AND "consumed_at" IS NULL; +CREATE INDEX "runtime_states_expires_at_idx" ON "runtime_states"("expires_at"); + +CREATE TABLE "runtime_gates" ( + "key" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "runtime_gates_pkey" PRIMARY KEY ("key") +); + +CREATE INDEX "runtime_gates_expires_at_idx" ON "runtime_gates"("expires_at"); + +CREATE TABLE "runtime_leases" ( + "key" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "fencing_token" BIGINT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "runtime_leases_pkey" PRIMARY KEY ("key") +); + +CREATE INDEX "runtime_leases_expires_at_idx" ON "runtime_leases"("expires_at"); diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index 9a012f62d5..8675ebbbf9 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -2,6 +2,7 @@ import serverNativeModule, { type ActionEvent as NativeActionEventContract, type ActionRuntimeInput as NativeActionRuntimeInputContract, type AssertSafeUrlRequest, + type BackendRuntimeHealth, type BuiltInPromptRenderContract, type BuiltInPromptSessionContract, type BuiltInPromptSpec, @@ -45,6 +46,22 @@ import serverNativeModule, { type RequestedModelMatchResponse, type ResolvedEntitlement, type ResolveEntitlementInput, + type RuntimeBlobCleanupResult, + type RuntimeBlobCompleteResult, + type RuntimeByokLocalLeaseRecord, + type RuntimeDocCompactionResult, + type RuntimeMagicLinkOtpConsumeResult, + type RuntimeMultipartUploadInit, + type RuntimeMultipartUploadPart, + type RuntimeObjectGetResult, + type RuntimeObjectListEntry, + type RuntimeObjectMetadata, + type RuntimeObjectStorageHealth, + type RuntimeObjectStoragePutOptions, + type RuntimePresignedObjectRequest, + type RuntimeVerificationTokenRecord, + type RuntimeWorkspaceInviteLinkRecord, + type RuntimeWorkspaceStatsDailyRecalibrationResult, type SafeFetchRequest, type SafeFetchResponse, type Tokenizer, @@ -52,6 +69,7 @@ import serverNativeModule, { export type { AssertSafeUrlRequest, + BackendRuntimeHealth, CapabilityAttachmentContract, CapabilityModelCapability, CommandResponse, @@ -73,6 +91,22 @@ export type { RemoteMimeTypeRequest, ResolvedEntitlement, ResolveEntitlementInput, + RuntimeBlobCleanupResult, + RuntimeBlobCompleteResult, + RuntimeByokLocalLeaseRecord, + RuntimeDocCompactionResult, + RuntimeMagicLinkOtpConsumeResult, + RuntimeMultipartUploadInit, + RuntimeMultipartUploadPart, + RuntimeObjectGetResult, + RuntimeObjectListEntry, + RuntimeObjectMetadata, + RuntimeObjectStorageHealth, + RuntimeObjectStoragePutOptions, + RuntimePresignedObjectRequest, + RuntimeVerificationTokenRecord, + RuntimeWorkspaceInviteLinkRecord, + RuntimeWorkspaceStatsDailyRecalibrationResult, SafeFetchRequest, SafeFetchResponse, }; @@ -180,6 +214,7 @@ export const readAllDocIdsFromRootDoc = export const AFFINE_PRO_PUBLIC_KEY = serverNativeModule.AFFINE_PRO_PUBLIC_KEY; export const AFFINE_PRO_LICENSE_AES_KEY = serverNativeModule.AFFINE_PRO_LICENSE_AES_KEY; +export const BackendRuntime = serverNativeModule.BackendRuntime; export type PermissionWorkspaceRole = 'external' | 'member' | 'admin' | 'owner'; export type PermissionDocRole = diff --git a/packages/common/native/src/hashcash.rs b/packages/common/native/src/hashcash.rs index a01c4ada8a..1c2354483c 100644 --- a/packages/common/native/src/hashcash.rs +++ b/packages/common/native/src/hashcash.rs @@ -61,7 +61,7 @@ impl Stamp { let ts = now.format("%Y%m%d%H%M%S"); let bits = bits.unwrap_or(20); let rand = String::from_iter(Alphanumeric.sample_iter(rng()).take(SALT_LENGTH).map(char::from)); - let challenge = format!("{}:{}:{}:{}:{}:{}", version, bits, ts, &resource, "", rand); + let challenge = format!("{}:{}:{}:{}:{}:{}", version, bits, ts, resource, "", rand); Stamp { version: version.to_string(),