feat: schema projections, command transactions, quantum-secure encryption
This commit is contained in:
Generated
+346
-4
@@ -2,6 +2,41 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -29,6 +64,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -65,7 +112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core 0.4.5",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
@@ -74,7 +121,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"itoa",
|
"itoa",
|
||||||
"matchit",
|
"matchit 0.7.3",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -92,6 +139,34 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core 0.5.6",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.4.0",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit 0.8.4",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower 0.5.3",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-core"
|
name = "axum-core"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@@ -113,6 +188,24 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http 1.4.0",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-test"
|
name = "axum-test"
|
||||||
version = "14.10.0"
|
version = "14.10.0"
|
||||||
@@ -122,7 +215,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auto-future",
|
"auto-future",
|
||||||
"axum",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
@@ -169,6 +262,29 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake3"
|
||||||
|
version = "1.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"constant_time_eq",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -209,6 +325,16 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -219,6 +345,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -255,6 +387,24 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -289,6 +439,26 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -304,6 +474,16 @@ version = "0.1.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -352,6 +532,21 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "engram-crypto"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"base64",
|
||||||
|
"blake3",
|
||||||
|
"rand",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "engram-ffi"
|
name = "engram-ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -379,17 +574,36 @@ dependencies = [
|
|||||||
"engram-core",
|
"engram-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "engram-projection"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"engram-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "engram-server"
|
name = "engram-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum 0.7.9",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
"engram-core",
|
"engram-core",
|
||||||
|
"engram-crypto",
|
||||||
|
"engram-projection",
|
||||||
"engram-sync",
|
"engram-sync",
|
||||||
|
"engram-tx",
|
||||||
|
"mime_guess",
|
||||||
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sled",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -415,6 +629,19 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "engram-tx"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"engram-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sled",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -557,6 +784,16 @@ dependencies = [
|
|||||||
"byteorder",
|
"byteorder",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -581,6 +818,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -906,6 +1153,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "instant"
|
name = "instant"
|
||||||
version = "0.1.13"
|
version = "0.1.13"
|
||||||
@@ -1078,6 +1334,12 @@ version = "0.7.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -1168,6 +1430,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.78"
|
version = "0.10.78"
|
||||||
@@ -1307,6 +1575,18 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1537,6 +1817,41 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-embed"
|
||||||
|
version = "8.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||||
|
dependencies = [
|
||||||
|
"axum 0.8.9",
|
||||||
|
"rust-embed-impl",
|
||||||
|
"rust-embed-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-embed-impl"
|
||||||
|
version = "8.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rust-embed-utils",
|
||||||
|
"syn",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-embed-utils"
|
||||||
|
version = "8.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||||
|
dependencies = [
|
||||||
|
"sha2",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-multipart-rfc7578_2"
|
name = "rust-multipart-rfc7578_2"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -1739,6 +2054,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -2185,6 +2511,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@@ -2203,6 +2535,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ members = [
|
|||||||
"crates/engram-migrate",
|
"crates/engram-migrate",
|
||||||
"crates/engram-sync",
|
"crates/engram-sync",
|
||||||
"crates/engram-server",
|
"crates/engram-server",
|
||||||
|
"crates/engram-projection",
|
||||||
|
"crates/engram-tx",
|
||||||
|
"crates/engram-crypto",
|
||||||
# engram-wasm is in bindings/ and compiled separately via wasm-pack
|
# engram-wasm is in bindings/ and compiled separately via wasm-pack
|
||||||
# (wasm targets can't be in the same workspace build as native targets)
|
# (wasm targets can't be in the same workspace build as native targets)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[package]
|
||||||
|
name = "engram-crypto"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Quantum-secure encryption at rest for Engram — AES-256-GCM with PQ upgrade path"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
thiserror = "1"
|
||||||
|
# AES-256-GCM symmetric encryption (quantum-resistant at 256-bit key length)
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
# BLAKE3 for key derivation (fast, cryptographically strong)
|
||||||
|
blake3 = "1"
|
||||||
|
# Random number generation
|
||||||
|
rand = "0.8"
|
||||||
|
# Base64 encoding for serialization (used in EncryptedContent serialization)
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# TODO: Upgrade to post-quantum KEM/signature once crates stabilize.
|
||||||
|
# Target: ml-kem (CRYSTALS-Kyber / NIST ML-KEM) and ml-dsa (CRYSTALS-Dilithium / NIST ML-DSA).
|
||||||
|
# As of 2025, the `ml-kem` and `ml-dsa` crates are available on crates.io but not yet
|
||||||
|
# production-stable for all platforms. The algorithm registry structure below is designed
|
||||||
|
# so that the upgrade is a drop-in: add the PQ crate, implement the KemAlgorithm variant,
|
||||||
|
# and new writes use the new algorithm while old records continue to decrypt via the registry.
|
||||||
|
#
|
||||||
|
# Uncomment when ready:
|
||||||
|
# ml-kem = "0.2" # CRYSTALS-Kyber (NIST ML-KEM 768/1024)
|
||||||
|
# ml-dsa = "0.1" # CRYSTALS-Dilithium (NIST ML-DSA)
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/// Algorithm registry types — the versioning layer for crypto algorithm rotation.
|
||||||
|
///
|
||||||
|
/// Each encrypted record carries an `algorithm_id`. The registry maps these IDs
|
||||||
|
/// to the parameters needed to decrypt. When you rotate algorithms, old records
|
||||||
|
/// keep their ID and decrypt using the historical version. New records use the
|
||||||
|
/// new active algorithm.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Key Encapsulation Mechanism algorithms.
|
||||||
|
///
|
||||||
|
/// # Current
|
||||||
|
/// - `Aes256GcmDirect`: AES-256-GCM with a directly-provided 256-bit key.
|
||||||
|
/// Quantum-resistant at 256-bit (Grover halves to 128-bit effective security).
|
||||||
|
///
|
||||||
|
/// # Planned (post-quantum upgrade)
|
||||||
|
/// - `MlKem768`: CRYSTALS-Kyber 768 (NIST ML-KEM Level 3 — 128-bit PQ security)
|
||||||
|
/// - `MlKem1024`: CRYSTALS-Kyber 1024 (NIST ML-KEM Level 5 — 256-bit PQ security)
|
||||||
|
/// - `ClassicRsa4096`: RSA-4096 OAEP fallback (NOT quantum-resistant — for compat only)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum KemAlgorithm {
|
||||||
|
/// AES-256-GCM with direct key (current default, quantum-resistant at 256-bit)
|
||||||
|
Aes256GcmDirect,
|
||||||
|
// TODO: uncomment when ml-kem crate stabilizes
|
||||||
|
// MlKem768,
|
||||||
|
// MlKem1024,
|
||||||
|
// ClassicRsa4096,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KemAlgorithm {
|
||||||
|
pub fn id(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
KemAlgorithm::Aes256GcmDirect => "aes256gcm-direct-v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signature algorithms for authenticating ciphertext.
|
||||||
|
///
|
||||||
|
/// # Current
|
||||||
|
/// - `Blake3Mac`: BLAKE3 keyed hash as a MAC (message authentication code).
|
||||||
|
/// Not a signature in the asymmetric sense, but provides authenticity.
|
||||||
|
///
|
||||||
|
/// # Planned (post-quantum upgrade)
|
||||||
|
/// - `MlDsa44` / `MlDsa65` / `MlDsa87`: CRYSTALS-Dilithium (NIST ML-DSA)
|
||||||
|
/// - `SphincsSha256128f`: SPHINCS+ stateless hash-based signature
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum SigAlgorithm {
|
||||||
|
/// BLAKE3 keyed MAC (current default)
|
||||||
|
Blake3Mac,
|
||||||
|
// TODO: uncomment when ml-dsa crate stabilizes
|
||||||
|
// MlDsa44, // NIST Security Level 2 (128-bit)
|
||||||
|
// MlDsa65, // NIST Security Level 3 (192-bit)
|
||||||
|
// MlDsa87, // NIST Security Level 5 (256-bit)
|
||||||
|
// SphincsSha256128f, // Stateless hash-based, conservative security
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SigAlgorithm {
|
||||||
|
pub fn id(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SigAlgorithm::Blake3Mac => "blake3-mac-v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A versioned algorithm configuration entry.
|
||||||
|
///
|
||||||
|
/// Historical versions are kept in the registry so that old ciphertexts can
|
||||||
|
/// always be decrypted even after algorithm rotation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AlgorithmVersion {
|
||||||
|
/// Unique string ID stored alongside every ciphertext.
|
||||||
|
pub id: String,
|
||||||
|
/// The KEM algorithm used for key derivation/encapsulation.
|
||||||
|
pub kem: KemAlgorithm,
|
||||||
|
/// The signature algorithm used for ciphertext authentication.
|
||||||
|
pub sig: SigAlgorithm,
|
||||||
|
/// Unix milliseconds when this version became active.
|
||||||
|
pub activated_at: i64,
|
||||||
|
/// Unix milliseconds when this version was superseded (None = still active).
|
||||||
|
pub retired_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlgorithmVersion {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.retired_at.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/// CryptoEngine — encrypt and decrypt node content with algorithm versioning.
|
||||||
|
///
|
||||||
|
/// # Security Model
|
||||||
|
///
|
||||||
|
/// - **Symmetric encryption**: AES-256-GCM (authenticated encryption, quantum-resistant)
|
||||||
|
/// - **Key derivation**: BLAKE3 KDF — stretches the master key into per-operation keys
|
||||||
|
/// - **Authentication**: BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext)
|
||||||
|
/// - **Nonces**: 96-bit random nonce per encryption (from OS CSPRNG via `rand`)
|
||||||
|
///
|
||||||
|
/// # AES-256-GCM and Quantum Resistance
|
||||||
|
///
|
||||||
|
/// AES-256 is considered quantum-resistant: Grover's algorithm provides at most
|
||||||
|
/// a quadratic speedup, reducing 256-bit security to ~128-bit effective security
|
||||||
|
/// against quantum adversaries. 128-bit quantum security is currently considered
|
||||||
|
/// sufficient. The algorithm_id in EncryptedContent ensures an upgrade to
|
||||||
|
/// ML-KEM/Kyber is a transparent drop-in when the crates stabilize.
|
||||||
|
use aes_gcm::{
|
||||||
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||||
|
Aes256Gcm, Key, Nonce,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{CryptoError, CryptoResult};
|
||||||
|
use crate::registry::AlgorithmRegistry;
|
||||||
|
|
||||||
|
/// An encrypted content blob, self-describing with its algorithm version.
|
||||||
|
///
|
||||||
|
/// The `algorithm_id` field allows any version to decrypt any record,
|
||||||
|
/// even after algorithm rotation. This is the key to migration-free upgrades.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EncryptedContent {
|
||||||
|
/// Which algorithm version encrypted this record.
|
||||||
|
/// Maps to an entry in `AlgorithmRegistry::versions`.
|
||||||
|
pub algorithm_id: String,
|
||||||
|
/// The AES-256-GCM ciphertext (includes the GCM auth tag).
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
/// In a PQ scheme: the KEM-encapsulated symmetric key.
|
||||||
|
/// In the current AES-direct scheme: empty (key is derived from master key + context).
|
||||||
|
pub encapsulated_key: Vec<u8>,
|
||||||
|
/// 96-bit random AES-GCM nonce.
|
||||||
|
pub nonce: Vec<u8>,
|
||||||
|
/// BLAKE3 MAC over (algorithm_id || nonce || ciphertext).
|
||||||
|
/// In a PQ scheme: this would be a Dilithium/ML-DSA signature.
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncryptedContent {
|
||||||
|
/// Serialize to a compact JSON string for storage.
|
||||||
|
pub fn to_bytes(&self) -> CryptoResult<Vec<u8>> {
|
||||||
|
serde_json::to_vec(self).map_err(|e| CryptoError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize from bytes (JSON).
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> CryptoResult<Self> {
|
||||||
|
serde_json::from_slice(bytes).map_err(|e| CryptoError::DecryptionFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encryption engine.
|
||||||
|
///
|
||||||
|
/// One engine instance per process (or per-request for stateless usage).
|
||||||
|
/// The engine holds the master key and the algorithm registry.
|
||||||
|
pub struct CryptoEngine {
|
||||||
|
/// Master key bytes — 32 bytes for AES-256.
|
||||||
|
/// In a PQ scheme: this would be a keypair (public/private).
|
||||||
|
master_key: [u8; 32],
|
||||||
|
/// Algorithm registry — tracks active and historical versions.
|
||||||
|
pub registry: AlgorithmRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CryptoEngine {
|
||||||
|
/// Create an engine from a 32-byte master key (AES-256 requires 256-bit key).
|
||||||
|
pub fn from_key(key: &[u8]) -> CryptoResult<Self> {
|
||||||
|
if key.len() < 32 {
|
||||||
|
return Err(CryptoError::InvalidKeyLength {
|
||||||
|
expected: 32,
|
||||||
|
got: key.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut master_key = [0u8; 32];
|
||||||
|
master_key.copy_from_slice(&key[..32]);
|
||||||
|
Ok(Self {
|
||||||
|
master_key,
|
||||||
|
registry: AlgorithmRegistry::default_registry(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an engine from an environment variable `ENGRAM_ENCRYPTION_KEY`.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the variable is not set (dev mode — plaintext storage).
|
||||||
|
/// Returns an error if the variable is set but the key is too short.
|
||||||
|
pub fn from_env() -> CryptoResult<Option<Self>> {
|
||||||
|
match std::env::var("ENGRAM_ENCRYPTION_KEY") {
|
||||||
|
Ok(key_str) => {
|
||||||
|
let key_bytes = key_str.as_bytes();
|
||||||
|
// Derive a 32-byte key from whatever the user provided
|
||||||
|
let derived = derive_key(key_bytes, b"engram-master-key");
|
||||||
|
Ok(Some(Self::from_key(&derived)?))
|
||||||
|
}
|
||||||
|
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||||
|
Err(e) => Err(CryptoError::KeyDerivation(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Encrypt `plaintext` using the active algorithm.
|
||||||
|
///
|
||||||
|
/// Returns an `EncryptedContent` that is self-describing: it carries its
|
||||||
|
/// `algorithm_id`, so decryption never needs out-of-band version tracking.
|
||||||
|
pub fn encrypt(&self, plaintext: &[u8]) -> CryptoResult<EncryptedContent> {
|
||||||
|
let algorithm_id = self.registry.active_id().to_string();
|
||||||
|
|
||||||
|
// Derive a per-operation encryption key from the master key
|
||||||
|
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
|
||||||
|
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
|
||||||
|
let cipher = Aes256Gcm::new(key);
|
||||||
|
|
||||||
|
// Random 96-bit nonce
|
||||||
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, plaintext)
|
||||||
|
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
// MAC: BLAKE3 keyed hash over (algorithm_id || nonce || ciphertext)
|
||||||
|
let mac_key = derive_key(&self.master_key, b"mac");
|
||||||
|
let signature = compute_mac(&mac_key, &algorithm_id, nonce.as_slice(), &ciphertext);
|
||||||
|
|
||||||
|
Ok(EncryptedContent {
|
||||||
|
algorithm_id,
|
||||||
|
ciphertext,
|
||||||
|
encapsulated_key: vec![], // unused in AES-direct mode
|
||||||
|
nonce: nonce.to_vec(),
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decrypt ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decrypt an `EncryptedContent`, dispatching to the correct algorithm version.
|
||||||
|
pub fn decrypt(&self, content: &EncryptedContent) -> CryptoResult<Vec<u8>> {
|
||||||
|
// Look up the algorithm version that produced this ciphertext
|
||||||
|
let _version = self.registry.get_version(&content.algorithm_id)?;
|
||||||
|
|
||||||
|
// Verify MAC before decrypting (fail-fast on tampering)
|
||||||
|
let mac_key = derive_key(&self.master_key, b"mac");
|
||||||
|
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
|
||||||
|
if expected != content.signature {
|
||||||
|
return Err(CryptoError::SignatureInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
|
||||||
|
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
|
||||||
|
let cipher = Aes256Gcm::new(key);
|
||||||
|
|
||||||
|
if content.nonce.len() != 12 {
|
||||||
|
return Err(CryptoError::DecryptionFailed(format!(
|
||||||
|
"invalid nonce length: {}",
|
||||||
|
content.nonce.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let nonce = Nonce::from_slice(&content.nonce);
|
||||||
|
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, content.ciphertext.as_slice())
|
||||||
|
.map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signature verification ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verify the MAC/signature on an encrypted content blob.
|
||||||
|
pub fn verify_signature(&self, content: &EncryptedContent) -> CryptoResult<bool> {
|
||||||
|
let mac_key = derive_key(&self.master_key, b"mac");
|
||||||
|
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
|
||||||
|
Ok(expected == content.signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Algorithm rotation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Rotate to a new KEM algorithm.
|
||||||
|
///
|
||||||
|
/// After rotation, new encryptions use the new algorithm.
|
||||||
|
/// Old records retain their `algorithm_id` and decrypt via the historical registry.
|
||||||
|
pub fn rotate_algorithm(&mut self, new_kem: crate::algorithm::KemAlgorithm) -> CryptoResult<()> {
|
||||||
|
self.registry.rotate_kem(new_kem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key derivation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Derive a 32-byte sub-key from the master key and a context string.
|
||||||
|
/// Uses BLAKE3's keyed hash for domain separation.
|
||||||
|
fn derive_key(master: &[u8], context: &[u8]) -> [u8; 32] {
|
||||||
|
// BLAKE3 derive_key: master is the key material, context is the domain
|
||||||
|
let mut hasher = blake3::Hasher::new_keyed(
|
||||||
|
&padded_32(master),
|
||||||
|
);
|
||||||
|
hasher.update(context);
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
*hash.as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pad or truncate bytes to exactly 32 bytes.
|
||||||
|
fn padded_32(bytes: &[u8]) -> [u8; 32] {
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
let len = bytes.len().min(32);
|
||||||
|
out[..len].copy_from_slice(&bytes[..len]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext).
|
||||||
|
fn compute_mac(mac_key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
|
||||||
|
let mut hasher = blake3::Hasher::new_keyed(mac_key);
|
||||||
|
hasher.update(algorithm_id.as_bytes());
|
||||||
|
hasher.update(nonce);
|
||||||
|
hasher.update(ciphertext);
|
||||||
|
hasher.finalize().as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_engine() -> CryptoEngine {
|
||||||
|
let key = b"test-master-key-must-be-32-bytes";
|
||||||
|
CryptoEngine::from_key(key).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encrypt_decrypt_roundtrip() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let plaintext = b"sensitive memory content - do not store unencrypted";
|
||||||
|
let enc = engine.encrypt(plaintext).unwrap();
|
||||||
|
let dec = engine.decrypt(&enc).unwrap();
|
||||||
|
assert_eq!(dec, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nonce_is_random() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let enc1 = engine.encrypt(b"same plaintext").unwrap();
|
||||||
|
let enc2 = engine.encrypt(b"same plaintext").unwrap();
|
||||||
|
// Nonces must differ (probabilistic — collision probability 1/2^96)
|
||||||
|
assert_ne!(enc1.nonce, enc2.nonce);
|
||||||
|
// Ciphertexts must differ (different nonces → different ciphertexts)
|
||||||
|
assert_ne!(enc1.ciphertext, enc2.ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tampered_ciphertext_rejected() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let mut enc = engine.encrypt(b"original").unwrap();
|
||||||
|
// Flip a byte in the ciphertext
|
||||||
|
if let Some(b) = enc.ciphertext.first_mut() {
|
||||||
|
*b ^= 0xFF;
|
||||||
|
}
|
||||||
|
// Decryption should fail due to GCM auth tag verification
|
||||||
|
let result = engine.decrypt(&enc);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tampered_mac_rejected() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let mut enc = engine.encrypt(b"original").unwrap();
|
||||||
|
// Flip a byte in the signature
|
||||||
|
if let Some(b) = enc.signature.first_mut() {
|
||||||
|
*b ^= 0xFF;
|
||||||
|
}
|
||||||
|
let result = engine.decrypt(&enc);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_signature() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let enc = engine.encrypt(b"content").unwrap();
|
||||||
|
assert!(engine.verify_signature(&enc).unwrap());
|
||||||
|
|
||||||
|
let mut tampered = enc.clone();
|
||||||
|
tampered.signature[0] ^= 0x01;
|
||||||
|
assert!(!engine.verify_signature(&tampered).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_algorithm_id_stored() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let enc = engine.encrypt(b"data").unwrap();
|
||||||
|
assert_eq!(enc.algorithm_id, "aes256gcm-direct-v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialization_roundtrip() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let enc = engine.encrypt(b"serialize me").unwrap();
|
||||||
|
let bytes = enc.to_bytes().unwrap();
|
||||||
|
let restored = EncryptedContent::from_bytes(&bytes).unwrap();
|
||||||
|
let dec = engine.decrypt(&restored).unwrap();
|
||||||
|
assert_eq!(dec, b"serialize me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_key_rejected() {
|
||||||
|
let short_key = b"too-short";
|
||||||
|
let result = CryptoEngine::from_key(short_key);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_plaintext() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let enc = engine.encrypt(b"").unwrap();
|
||||||
|
let dec = engine.decrypt(&enc).unwrap();
|
||||||
|
assert_eq!(dec, b"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_large_plaintext() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let plaintext = vec![0xABu8; 1_000_000]; // 1 MB
|
||||||
|
let enc = engine.encrypt(&plaintext).unwrap();
|
||||||
|
let dec = engine.decrypt(&enc).unwrap();
|
||||||
|
assert_eq!(dec, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_algorithm_rejected() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let mut enc = engine.encrypt(b"data").unwrap();
|
||||||
|
enc.algorithm_id = "unknown-algo-v99".to_string();
|
||||||
|
let result = engine.decrypt(&enc);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), CryptoError::UnknownAlgorithm(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
#[error("Encryption failed: {0}")]
|
||||||
|
EncryptionFailed(String),
|
||||||
|
|
||||||
|
#[error("Decryption failed: {0}")]
|
||||||
|
DecryptionFailed(String),
|
||||||
|
|
||||||
|
#[error("Signature verification failed")]
|
||||||
|
SignatureInvalid,
|
||||||
|
|
||||||
|
#[error("Unknown algorithm ID: {0}")]
|
||||||
|
UnknownAlgorithm(String),
|
||||||
|
|
||||||
|
#[error("Key derivation failed: {0}")]
|
||||||
|
KeyDerivation(String),
|
||||||
|
|
||||||
|
#[error("Invalid key length: expected {expected}, got {got}")]
|
||||||
|
InvalidKeyLength { expected: usize, got: usize },
|
||||||
|
|
||||||
|
#[error("Algorithm not available: {0}")]
|
||||||
|
AlgorithmUnavailable(String),
|
||||||
|
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
Serialization(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type CryptoResult<T> = Result<T, CryptoError>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// Engram Crypto — quantum-secure encryption at rest.
|
||||||
|
///
|
||||||
|
/// # Current Implementation
|
||||||
|
///
|
||||||
|
/// Uses AES-256-GCM for symmetric encryption with BLAKE3 for key derivation.
|
||||||
|
/// AES-256 is already quantum-resistant (Grover's algorithm halves the key space
|
||||||
|
/// from 2^256 to 2^128, which remains computationally infeasible).
|
||||||
|
///
|
||||||
|
/// # Post-Quantum Upgrade Path
|
||||||
|
///
|
||||||
|
/// The `AlgorithmRegistry` stores an `algorithm_id` alongside every ciphertext.
|
||||||
|
/// When ML-KEM (CRYSTALS-Kyber) and ML-DSA (CRYSTALS-Dilithium) crates stabilize,
|
||||||
|
/// the upgrade is:
|
||||||
|
/// 1. Add `KemAlgorithm::MlKem768` / `MlKem1024` variants
|
||||||
|
/// 2. Implement `CryptoEngine::encrypt()` for the new algorithm
|
||||||
|
/// 3. Set it as the active algorithm in the registry
|
||||||
|
/// 4. Old records continue to decrypt via their stored `algorithm_id`
|
||||||
|
/// 5. Background re-encryption rotates old records to the new algorithm
|
||||||
|
///
|
||||||
|
/// No data migration required — the registry handles version negotiation.
|
||||||
|
///
|
||||||
|
/// # Usage
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use engram_crypto::{CryptoEngine, AlgorithmRegistry};
|
||||||
|
///
|
||||||
|
/// let key = b"an-example-32-byte-key!!12345678";
|
||||||
|
/// let engine = CryptoEngine::from_key(key).unwrap();
|
||||||
|
///
|
||||||
|
/// let plaintext = b"sensitive memory content";
|
||||||
|
/// let encrypted = engine.encrypt(plaintext).unwrap();
|
||||||
|
/// let decrypted = engine.decrypt(&encrypted).unwrap();
|
||||||
|
/// assert_eq!(plaintext, decrypted.as_slice());
|
||||||
|
/// ```
|
||||||
|
pub mod algorithm;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod error;
|
||||||
|
pub mod registry;
|
||||||
|
|
||||||
|
pub use algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
|
||||||
|
pub use engine::{CryptoEngine, EncryptedContent};
|
||||||
|
pub use error::CryptoError;
|
||||||
|
pub use registry::AlgorithmRegistry;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/// Algorithm registry — tracks active and historical algorithm versions.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
|
||||||
|
use crate::error::{CryptoError, CryptoResult};
|
||||||
|
|
||||||
|
/// Maintains the set of algorithm versions known to this node.
|
||||||
|
///
|
||||||
|
/// The active version is used for all new encryptions.
|
||||||
|
/// Historical versions remain so old ciphertexts can always be decrypted.
|
||||||
|
pub struct AlgorithmRegistry {
|
||||||
|
/// The currently active algorithm version.
|
||||||
|
pub active_kem: KemAlgorithm,
|
||||||
|
pub active_sig: SigAlgorithm,
|
||||||
|
/// All known versions (active + historical), keyed by algorithm ID.
|
||||||
|
pub versions: HashMap<String, AlgorithmVersion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlgorithmRegistry {
|
||||||
|
/// Create a registry with the default algorithm (AES-256-GCM + BLAKE3 MAC).
|
||||||
|
pub fn default_registry() -> Self {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64;
|
||||||
|
|
||||||
|
let default_version = AlgorithmVersion {
|
||||||
|
id: "aes256gcm-direct-v1".to_string(),
|
||||||
|
kem: KemAlgorithm::Aes256GcmDirect,
|
||||||
|
sig: SigAlgorithm::Blake3Mac,
|
||||||
|
activated_at: now,
|
||||||
|
retired_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut versions = HashMap::new();
|
||||||
|
versions.insert(default_version.id.clone(), default_version);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
active_kem: KemAlgorithm::Aes256GcmDirect,
|
||||||
|
active_sig: SigAlgorithm::Blake3Mac,
|
||||||
|
versions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the active algorithm ID (used as the `algorithm_id` in new ciphertexts).
|
||||||
|
pub fn active_id(&self) -> &str {
|
||||||
|
self.active_kem.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a version by its ID (for decryption of historical records).
|
||||||
|
pub fn get_version(&self, id: &str) -> CryptoResult<&AlgorithmVersion> {
|
||||||
|
self.versions
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| CryptoError::UnknownAlgorithm(id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate to a new KEM algorithm.
|
||||||
|
///
|
||||||
|
/// The current active version is marked as retired. A new version entry is
|
||||||
|
/// added and becomes active. Old records retain their algorithm_id and can
|
||||||
|
/// still be decrypted via `get_version()`.
|
||||||
|
///
|
||||||
|
/// Background re-encryption can then update old records at leisure.
|
||||||
|
pub fn rotate_kem(&mut self, new_kem: KemAlgorithm) -> CryptoResult<()> {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64;
|
||||||
|
|
||||||
|
// Retire current active version
|
||||||
|
if let Some(current) = self.versions.get_mut(self.active_kem.id()) {
|
||||||
|
current.retired_at = Some(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_id = new_kem.id().to_string();
|
||||||
|
let new_version = AlgorithmVersion {
|
||||||
|
id: new_id.clone(),
|
||||||
|
kem: new_kem.clone(),
|
||||||
|
sig: self.active_sig.clone(),
|
||||||
|
activated_at: now,
|
||||||
|
retired_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.versions.insert(new_id, new_version);
|
||||||
|
self.active_kem = new_kem;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all versions, active and historical.
|
||||||
|
pub fn list_versions(&self) -> Vec<&AlgorithmVersion> {
|
||||||
|
let mut vs: Vec<&AlgorithmVersion> = self.versions.values().collect();
|
||||||
|
vs.sort_by_key(|v| v.activated_at);
|
||||||
|
vs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "engram-projection"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Schema/projection layer for Engram — schema-free views over the activation surface"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
engram-core = { path = "../engram-core" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
thiserror = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
/// Projection engine — maps activated nodes through a ProjectionSchema.
|
||||||
|
///
|
||||||
|
/// The engine is stateless: it takes a schema and a result set, and returns
|
||||||
|
/// the projected view. No mutation of the underlying graph occurs.
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use engram_core::types::{ActivatedNode, NodeType};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::ProjectionResult;
|
||||||
|
use crate::schema::{
|
||||||
|
FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult as PResult,
|
||||||
|
ProjectionSchema, ProjectionType,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Stateless projection executor.
|
||||||
|
pub struct ProjectionEngine;
|
||||||
|
|
||||||
|
impl ProjectionEngine {
|
||||||
|
/// Apply a projection schema to a set of activated nodes.
|
||||||
|
///
|
||||||
|
/// Returns a `ProjectionResult` containing the shaped output.
|
||||||
|
/// The activation result set is not modified.
|
||||||
|
pub fn project(
|
||||||
|
schema: &ProjectionSchema,
|
||||||
|
activated: &[ActivatedNode],
|
||||||
|
) -> ProjectionResult<PResult> {
|
||||||
|
// Step 1: filter to in-scope nodes
|
||||||
|
let in_scope: Vec<&ActivatedNode> = activated
|
||||||
|
.iter()
|
||||||
|
.filter(|a| matches_filter(a, &schema.node_filter))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let nodes_in_scope = in_scope.len();
|
||||||
|
|
||||||
|
match schema.projection_type {
|
||||||
|
ProjectionType::Relational | ProjectionType::Document => {
|
||||||
|
let rows = in_scope
|
||||||
|
.iter()
|
||||||
|
.map(|a| project_row(a, &schema.field_mappings))
|
||||||
|
.collect::<ProjectionResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(PResult {
|
||||||
|
schema_name: schema.name.clone(),
|
||||||
|
nodes_in_scope,
|
||||||
|
rows,
|
||||||
|
key_value: None,
|
||||||
|
wide_column: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectionType::KeyValue => {
|
||||||
|
let mut kv: HashMap<String, Value> = HashMap::new();
|
||||||
|
for a in &in_scope {
|
||||||
|
let key = a.node.id.to_string();
|
||||||
|
let val = String::from_utf8_lossy(&a.node.content).to_string();
|
||||||
|
kv.insert(key, Value::String(val));
|
||||||
|
}
|
||||||
|
Ok(PResult {
|
||||||
|
schema_name: schema.name.clone(),
|
||||||
|
nodes_in_scope,
|
||||||
|
rows: vec![],
|
||||||
|
key_value: Some(kv),
|
||||||
|
wide_column: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectionType::WideColumn => {
|
||||||
|
let mut wc: HashMap<String, HashMap<String, Value>> = HashMap::new();
|
||||||
|
for a in &in_scope {
|
||||||
|
let id = a.node.id.to_string();
|
||||||
|
let mut cols = HashMap::new();
|
||||||
|
for mapping in &schema.field_mappings {
|
||||||
|
let val = extract_field(a, &mapping.source)
|
||||||
|
.unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null));
|
||||||
|
cols.insert(mapping.field_name.clone(), val);
|
||||||
|
}
|
||||||
|
wc.insert(id, cols);
|
||||||
|
}
|
||||||
|
Ok(PResult {
|
||||||
|
schema_name: schema.name.clone(),
|
||||||
|
nodes_in_scope,
|
||||||
|
rows: vec![],
|
||||||
|
key_value: None,
|
||||||
|
wide_column: Some(wc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node filter evaluation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn matches_filter(a: &ActivatedNode, filter: &NodeFilter) -> bool {
|
||||||
|
match filter {
|
||||||
|
NodeFilter::All => true,
|
||||||
|
|
||||||
|
NodeFilter::ByType(types) => {
|
||||||
|
let node_type_str = node_type_str(&a.node.node_type);
|
||||||
|
types.iter().any(|t| t == node_type_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeFilter::ByTier(tiers) => tiers.iter().any(|t| t == &a.node.tier),
|
||||||
|
|
||||||
|
NodeFilter::ByTag(tags) => {
|
||||||
|
// Tags are searched in content (treated as UTF-8) as a simple substring match.
|
||||||
|
// This is intentionally lenient — callers may embed tag metadata in content.
|
||||||
|
let content_str = String::from_utf8_lossy(&a.node.content);
|
||||||
|
tags.iter().any(|tag| content_str.contains(tag.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeFilter::ByActivationThreshold(threshold) => a.activation_strength >= *threshold,
|
||||||
|
|
||||||
|
NodeFilter::BySalience(threshold) => a.node.salience >= *threshold,
|
||||||
|
|
||||||
|
NodeFilter::Combined(filters) => filters.iter().all(|f| matches_filter(a, f)),
|
||||||
|
|
||||||
|
NodeFilter::Any(filters) => filters.iter().any(|f| matches_filter(a, f)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row projection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn project_row(a: &ActivatedNode, mappings: &[FieldMapping]) -> ProjectionResult<ProjectedRow> {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
for mapping in mappings {
|
||||||
|
let val = extract_field(a, &mapping.source)
|
||||||
|
.unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null));
|
||||||
|
fields.insert(mapping.field_name.clone(), val);
|
||||||
|
}
|
||||||
|
Ok(ProjectedRow {
|
||||||
|
node_id: a.node.id,
|
||||||
|
fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field extraction ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn extract_field(a: &ActivatedNode, source: &FieldSource) -> Option<Value> {
|
||||||
|
match source {
|
||||||
|
FieldSource::NodeId => Some(Value::String(a.node.id.to_string())),
|
||||||
|
|
||||||
|
FieldSource::NodeType => Some(Value::String(node_type_str(&a.node.node_type).to_string())),
|
||||||
|
|
||||||
|
FieldSource::Tier => Some(Value::String(tier_str(&a.node.tier).to_string())),
|
||||||
|
|
||||||
|
FieldSource::Salience => Some(json!(a.node.salience)),
|
||||||
|
|
||||||
|
FieldSource::Importance => Some(json!(a.node.importance)),
|
||||||
|
|
||||||
|
FieldSource::ActivationStrength => Some(json!(a.activation_strength)),
|
||||||
|
|
||||||
|
FieldSource::Hops => Some(json!(a.hops)),
|
||||||
|
|
||||||
|
FieldSource::CreatedAt => Some(json!(a.node.created_at)),
|
||||||
|
|
||||||
|
FieldSource::LastActivated => Some(json!(a.node.last_activated)),
|
||||||
|
|
||||||
|
FieldSource::ActivationCount => Some(json!(a.node.activation_count)),
|
||||||
|
|
||||||
|
FieldSource::ContentRaw => {
|
||||||
|
Some(Value::String(String::from_utf8_lossy(&a.node.content).to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldSource::ContentBase64 => Some(Value::String(B64.encode(&a.node.content))),
|
||||||
|
|
||||||
|
FieldSource::ContentJsonPath(path) => {
|
||||||
|
// Parse content as JSON, then traverse the dot-path
|
||||||
|
let content_str = std::str::from_utf8(&a.node.content).ok()?;
|
||||||
|
let doc: Value = serde_json::from_str(content_str).ok()?;
|
||||||
|
traverse_json_path(&doc, path).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldSource::Literal(v) => Some(v.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traverse a dot-separated JSON path.
|
||||||
|
/// E.g., "user.name" on `{"user": {"name": "Alice"}}` returns `"Alice"`.
|
||||||
|
fn traverse_json_path<'a>(doc: &'a Value, path: &str) -> Option<&'a Value> {
|
||||||
|
let mut current = doc;
|
||||||
|
for segment in path.split('.') {
|
||||||
|
current = match current {
|
||||||
|
Value::Object(map) => map.get(segment)?,
|
||||||
|
Value::Array(arr) => {
|
||||||
|
let idx: usize = segment.parse().ok()?;
|
||||||
|
arr.get(idx)?
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── String helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn node_type_str(t: &NodeType) -> &'static str {
|
||||||
|
match t {
|
||||||
|
NodeType::Memory => "Memory",
|
||||||
|
NodeType::Concept => "Concept",
|
||||||
|
NodeType::Event => "Event",
|
||||||
|
NodeType::Entity => "Entity",
|
||||||
|
NodeType::Process => "Process",
|
||||||
|
NodeType::InternalState => "InternalState",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tier_str(t: &engram_core::types::MemoryTier) -> &'static str {
|
||||||
|
use engram_core::types::MemoryTier;
|
||||||
|
match t {
|
||||||
|
MemoryTier::Working => "Working",
|
||||||
|
MemoryTier::Episodic => "Episodic",
|
||||||
|
MemoryTier::Semantic => "Semantic",
|
||||||
|
MemoryTier::Procedural => "Procedural",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract node_id from a projected row (used for display / keying).
|
||||||
|
pub fn row_id(row: &ProjectedRow) -> Uuid {
|
||||||
|
row.node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::schema::{FieldMapping, FieldSource, NodeFilter, ProjectionSchema, ProjectionType};
|
||||||
|
use engram_core::types::{ActivatedNode, MemoryTier, Node, NodeType};
|
||||||
|
|
||||||
|
fn make_node(content: &str, tier: MemoryTier, importance: f32) -> Node {
|
||||||
|
Node::new(
|
||||||
|
NodeType::Memory,
|
||||||
|
vec![1.0, 0.0],
|
||||||
|
content.as_bytes().to_vec(),
|
||||||
|
tier,
|
||||||
|
importance,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_activated(node: Node, strength: f32) -> ActivatedNode {
|
||||||
|
ActivatedNode {
|
||||||
|
node,
|
||||||
|
activation_strength: strength,
|
||||||
|
hops: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relational_projection_basic() {
|
||||||
|
let node = make_node("hello world", MemoryTier::Semantic, 0.8);
|
||||||
|
let activated = vec![make_activated(node, 0.9)];
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "test".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: NodeFilter::All,
|
||||||
|
field_mappings: vec![
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "content".into(),
|
||||||
|
source: FieldSource::ContentRaw,
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "tier".into(),
|
||||||
|
source: FieldSource::Tier,
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "strength".into(),
|
||||||
|
source: FieldSource::ActivationStrength,
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &activated).unwrap();
|
||||||
|
assert_eq!(result.nodes_in_scope, 1);
|
||||||
|
assert_eq!(result.rows.len(), 1);
|
||||||
|
assert_eq!(result.rows[0].fields["content"], Value::String("hello world".into()));
|
||||||
|
assert_eq!(result.rows[0].fields["tier"], Value::String("Semantic".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_value_projection() {
|
||||||
|
let node = make_node("test content", MemoryTier::Working, 0.5);
|
||||||
|
let activated = vec![make_activated(node, 0.7)];
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "kv".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::KeyValue,
|
||||||
|
node_filter: NodeFilter::All,
|
||||||
|
field_mappings: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &activated).unwrap();
|
||||||
|
assert_eq!(result.nodes_in_scope, 1);
|
||||||
|
let kv = result.key_value.unwrap();
|
||||||
|
assert_eq!(kv.len(), 1);
|
||||||
|
let content = kv.values().next().unwrap();
|
||||||
|
assert_eq!(content, &Value::String("test content".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_by_activation_threshold() {
|
||||||
|
let n1 = make_activated(make_node("high", MemoryTier::Semantic, 0.9), 0.8);
|
||||||
|
let n2 = make_activated(make_node("low", MemoryTier::Episodic, 0.3), 0.1);
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "filtered".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: NodeFilter::ByActivationThreshold(0.5),
|
||||||
|
field_mappings: vec![FieldMapping {
|
||||||
|
field_name: "content".into(),
|
||||||
|
source: FieldSource::ContentRaw,
|
||||||
|
default: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap();
|
||||||
|
assert_eq!(result.nodes_in_scope, 1);
|
||||||
|
assert_eq!(result.rows[0].fields["content"], Value::String("high".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_by_tier() {
|
||||||
|
let n1 = make_activated(make_node("semantic", MemoryTier::Semantic, 0.9), 0.5);
|
||||||
|
let n2 = make_activated(make_node("working", MemoryTier::Working, 0.5), 0.5);
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "tier_filter".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: NodeFilter::ByTier(vec![MemoryTier::Semantic]),
|
||||||
|
field_mappings: vec![FieldMapping {
|
||||||
|
field_name: "content".into(),
|
||||||
|
source: FieldSource::ContentRaw,
|
||||||
|
default: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap();
|
||||||
|
assert_eq!(result.nodes_in_scope, 1);
|
||||||
|
assert_eq!(result.rows[0].fields["content"], Value::String("semantic".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_path_extraction() {
|
||||||
|
let content = r#"{"user": {"name": "Alice", "age": 30}}"#;
|
||||||
|
let node = make_node(content, MemoryTier::Semantic, 0.8);
|
||||||
|
let activated = vec![make_activated(node, 0.9)];
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "json_path".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: NodeFilter::All,
|
||||||
|
field_mappings: vec![
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "name".into(),
|
||||||
|
source: FieldSource::ContentJsonPath("user.name".into()),
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "age".into(),
|
||||||
|
source: FieldSource::ContentJsonPath("user.age".into()),
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &activated).unwrap();
|
||||||
|
assert_eq!(result.rows[0].fields["name"], Value::String("Alice".into()));
|
||||||
|
assert_eq!(result.rows[0].fields["age"], json!(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wide_column_projection() {
|
||||||
|
let n1 = make_activated(make_node("col_content", MemoryTier::Procedural, 0.6), 0.5);
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "wide".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::WideColumn,
|
||||||
|
node_filter: NodeFilter::All,
|
||||||
|
field_mappings: vec![
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "raw".into(),
|
||||||
|
source: FieldSource::ContentRaw,
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
FieldMapping {
|
||||||
|
field_name: "tier".into(),
|
||||||
|
source: FieldSource::Tier,
|
||||||
|
default: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &[n1]).unwrap();
|
||||||
|
assert_eq!(result.nodes_in_scope, 1);
|
||||||
|
let wc = result.wide_column.unwrap();
|
||||||
|
assert_eq!(wc.len(), 1);
|
||||||
|
let cols = wc.values().next().unwrap();
|
||||||
|
assert_eq!(cols["raw"], Value::String("col_content".into()));
|
||||||
|
assert_eq!(cols["tier"], Value::String("Procedural".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_combined_filter() {
|
||||||
|
let n1 = make_activated(make_node("tag:important semantic", MemoryTier::Semantic, 0.9), 0.8);
|
||||||
|
let n2 = make_activated(make_node("no tag", MemoryTier::Semantic, 0.9), 0.8);
|
||||||
|
let n3 = make_activated(make_node("tag:important working", MemoryTier::Working, 0.3), 0.8);
|
||||||
|
|
||||||
|
let filter = NodeFilter::Combined(vec![
|
||||||
|
NodeFilter::ByTier(vec![MemoryTier::Semantic]),
|
||||||
|
NodeFilter::ByTag(vec!["tag:important".into()]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "combined".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: filter,
|
||||||
|
field_mappings: vec![FieldMapping {
|
||||||
|
field_name: "content".into(),
|
||||||
|
source: FieldSource::ContentRaw,
|
||||||
|
default: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &[n1, n2, n3]).unwrap();
|
||||||
|
assert_eq!(result.nodes_in_scope, 1);
|
||||||
|
assert_eq!(
|
||||||
|
result.rows[0].fields["content"],
|
||||||
|
Value::String("tag:important semantic".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_literal_field() {
|
||||||
|
let node = make_node("any", MemoryTier::Working, 0.5);
|
||||||
|
let activated = vec![make_activated(node, 0.5)];
|
||||||
|
|
||||||
|
let schema = ProjectionSchema {
|
||||||
|
name: "literal".into(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: NodeFilter::All,
|
||||||
|
field_mappings: vec![FieldMapping {
|
||||||
|
field_name: "schema_version".into(),
|
||||||
|
source: FieldSource::Literal(json!("v1")),
|
||||||
|
default: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = ProjectionEngine::project(&schema, &activated).unwrap();
|
||||||
|
assert_eq!(result.rows[0].fields["schema_version"], Value::String("v1".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ProjectionError {
|
||||||
|
#[error("Projection not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Projection already exists: {0}")]
|
||||||
|
AlreadyExists(String),
|
||||||
|
|
||||||
|
#[error("Field mapping error: {0}")]
|
||||||
|
FieldMapping(String),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Engram error: {0}")]
|
||||||
|
Engram(#[from] engram_core::EngramError),
|
||||||
|
|
||||||
|
#[error("Invalid projection schema: {0}")]
|
||||||
|
InvalidSchema(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ProjectionResult<T> = Result<T, ProjectionError>;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/// Engram Projection Layer — schema-as-a-view over the activation surface.
|
||||||
|
///
|
||||||
|
/// # The Core Insight
|
||||||
|
///
|
||||||
|
/// Engram has no schema. A node has: embedding (semantic identity), content
|
||||||
|
/// (arbitrary bytes), metadata via tier/type, and salience. Schema is a
|
||||||
|
/// *projection* — a view imposed on the activation surface at query time.
|
||||||
|
///
|
||||||
|
/// The same Engram graph can surface as relational rows, JSON documents,
|
||||||
|
/// wide-column families, or key-value pairs depending on how you project it.
|
||||||
|
/// Migrations are free because there is nothing to migrate — you just update
|
||||||
|
/// the projection.
|
||||||
|
///
|
||||||
|
/// # How It Works
|
||||||
|
///
|
||||||
|
/// 1. Register a `ProjectionSchema` that describes which nodes are in scope
|
||||||
|
/// and how to map their fields.
|
||||||
|
/// 2. At query time, run spreading activation (or use an existing result set).
|
||||||
|
/// 3. Apply the projection to map `ActivatedNode`s into the projected view.
|
||||||
|
///
|
||||||
|
/// The projection is purely a read-time transform. It never modifies the graph.
|
||||||
|
pub mod engine;
|
||||||
|
pub mod error;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod schema;
|
||||||
|
|
||||||
|
pub use engine::ProjectionEngine;
|
||||||
|
pub use error::ProjectionError;
|
||||||
|
pub use registry::ProjectionRegistry;
|
||||||
|
pub use schema::{
|
||||||
|
FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult, ProjectionSchema,
|
||||||
|
ProjectionType,
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/// In-memory registry of named projection schemas.
|
||||||
|
///
|
||||||
|
/// The registry is the store of all registered projections. In a running server,
|
||||||
|
/// one registry instance is shared (behind a Mutex or RwLock). Projections are
|
||||||
|
/// looked up by name to execute queries.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::error::{ProjectionError, ProjectionResult};
|
||||||
|
use crate::schema::ProjectionSchema;
|
||||||
|
|
||||||
|
/// Holds all registered `ProjectionSchema`s, keyed by name.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ProjectionRegistry {
|
||||||
|
schemas: HashMap<String, ProjectionSchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectionRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
schemas: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new schema. Fails if one with the same name already exists.
|
||||||
|
pub fn register(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> {
|
||||||
|
if self.schemas.contains_key(&schema.name) {
|
||||||
|
return Err(ProjectionError::AlreadyExists(schema.name.clone()));
|
||||||
|
}
|
||||||
|
if schema.name.is_empty() {
|
||||||
|
return Err(ProjectionError::InvalidSchema("name must not be empty".into()));
|
||||||
|
}
|
||||||
|
self.schemas.insert(schema.name.clone(), schema);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace an existing schema (upsert). Creates if not present.
|
||||||
|
pub fn upsert(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> {
|
||||||
|
if schema.name.is_empty() {
|
||||||
|
return Err(ProjectionError::InvalidSchema("name must not be empty".into()));
|
||||||
|
}
|
||||||
|
self.schemas.insert(schema.name.clone(), schema);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a schema by name.
|
||||||
|
pub fn get(&self, name: &str) -> ProjectionResult<&ProjectionSchema> {
|
||||||
|
self.schemas
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| ProjectionError::NotFound(name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all schema names.
|
||||||
|
pub fn list(&self) -> Vec<&ProjectionSchema> {
|
||||||
|
let mut schemas: Vec<&ProjectionSchema> = self.schemas.values().collect();
|
||||||
|
schemas.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
schemas
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a schema by name. Returns true if it existed.
|
||||||
|
pub fn remove(&mut self, name: &str) -> bool {
|
||||||
|
self.schemas.remove(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of registered schemas.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.schemas.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if no schemas are registered.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.schemas.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::schema::{NodeFilter, ProjectionType};
|
||||||
|
|
||||||
|
fn make_schema(name: &str) -> ProjectionSchema {
|
||||||
|
ProjectionSchema {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: None,
|
||||||
|
projection_type: ProjectionType::Relational,
|
||||||
|
node_filter: NodeFilter::All,
|
||||||
|
field_mappings: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_and_get() {
|
||||||
|
let mut reg = ProjectionRegistry::new();
|
||||||
|
reg.register(make_schema("users")).unwrap();
|
||||||
|
let s = reg.get("users").unwrap();
|
||||||
|
assert_eq!(s.name, "users");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_duplicate_fails() {
|
||||||
|
let mut reg = ProjectionRegistry::new();
|
||||||
|
reg.register(make_schema("events")).unwrap();
|
||||||
|
assert!(reg.register(make_schema("events")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_replaces() {
|
||||||
|
let mut reg = ProjectionRegistry::new();
|
||||||
|
reg.register(make_schema("s1")).unwrap();
|
||||||
|
let mut updated = make_schema("s1");
|
||||||
|
updated.description = Some("updated".into());
|
||||||
|
reg.upsert(updated).unwrap();
|
||||||
|
assert_eq!(reg.get("s1").unwrap().description.as_deref(), Some("updated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_sorted() {
|
||||||
|
let mut reg = ProjectionRegistry::new();
|
||||||
|
reg.register(make_schema("zoo")).unwrap();
|
||||||
|
reg.register(make_schema("alpha")).unwrap();
|
||||||
|
reg.register(make_schema("mango")).unwrap();
|
||||||
|
let names: Vec<&str> = reg.list().iter().map(|s| s.name.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["alpha", "mango", "zoo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove() {
|
||||||
|
let mut reg = ProjectionRegistry::new();
|
||||||
|
reg.register(make_schema("temp")).unwrap();
|
||||||
|
assert!(reg.remove("temp"));
|
||||||
|
assert!(!reg.remove("temp"));
|
||||||
|
assert!(reg.get("temp").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/// Schema types for the projection layer.
|
||||||
|
///
|
||||||
|
/// A `ProjectionSchema` defines a named view over the Engram graph.
|
||||||
|
/// It specifies which nodes are in scope and how to extract fields from them.
|
||||||
|
use engram_core::types::MemoryTier;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// How the projection presents data to the caller.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ProjectionType {
|
||||||
|
/// Nodes as rows; edges become foreign key references.
|
||||||
|
/// Each row is a flat map of field_name → value.
|
||||||
|
Relational,
|
||||||
|
/// Nodes as JSON documents.
|
||||||
|
/// Fields are nested under a "fields" key; metadata at the top level.
|
||||||
|
Document,
|
||||||
|
/// Nodes as column families (node_id → column_name → value).
|
||||||
|
/// Suitable for wide, sparse schemas.
|
||||||
|
WideColumn,
|
||||||
|
/// Simple node_id → content mapping.
|
||||||
|
/// Ignores field mappings; raw content bytes as base64.
|
||||||
|
KeyValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which nodes from the activation result set fall within this projection's scope.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "value")]
|
||||||
|
pub enum NodeFilter {
|
||||||
|
/// Include nodes whose node_type matches any of the given strings.
|
||||||
|
ByType(Vec<String>),
|
||||||
|
/// Include nodes in any of the given memory tiers.
|
||||||
|
ByTier(Vec<MemoryTier>),
|
||||||
|
/// Include nodes whose tier name contains any of the given tag strings
|
||||||
|
/// (stored in node metadata via content prefix convention).
|
||||||
|
ByTag(Vec<String>),
|
||||||
|
/// Include nodes with activation strength >= threshold.
|
||||||
|
ByActivationThreshold(f32),
|
||||||
|
/// Include nodes whose salience >= threshold.
|
||||||
|
BySalience(f32),
|
||||||
|
/// All of the sub-filters must match (AND).
|
||||||
|
Combined(Vec<NodeFilter>),
|
||||||
|
/// Any sub-filter matches (OR).
|
||||||
|
Any(Vec<NodeFilter>),
|
||||||
|
/// Pass all nodes through without filtering.
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to source a projected field's value.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "value")]
|
||||||
|
pub enum FieldSource {
|
||||||
|
/// Extract a value from the node's content, interpreted as JSON, using a dot-path.
|
||||||
|
/// E.g., "user.name" extracts `{ "user": { "name": "Alice" } }["user"]["name"]`.
|
||||||
|
ContentJsonPath(String),
|
||||||
|
/// The node's content, raw, as a UTF-8 string (lossy).
|
||||||
|
ContentRaw,
|
||||||
|
/// The node's content as a base64-encoded string.
|
||||||
|
ContentBase64,
|
||||||
|
/// The node's unique identifier.
|
||||||
|
NodeId,
|
||||||
|
/// The node's type as a string.
|
||||||
|
NodeType,
|
||||||
|
/// The node's memory tier as a string.
|
||||||
|
Tier,
|
||||||
|
/// The node's current salience score.
|
||||||
|
Salience,
|
||||||
|
/// The node's importance (caller-set, stable).
|
||||||
|
Importance,
|
||||||
|
/// The activation strength at this node (from spreading activation).
|
||||||
|
ActivationStrength,
|
||||||
|
/// The hop count from the nearest seed node.
|
||||||
|
Hops,
|
||||||
|
/// The node's creation timestamp (Unix ms).
|
||||||
|
CreatedAt,
|
||||||
|
/// The node's last-activated timestamp (Unix ms).
|
||||||
|
LastActivated,
|
||||||
|
/// The node's activation count.
|
||||||
|
ActivationCount,
|
||||||
|
/// A literal constant value.
|
||||||
|
Literal(Value),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One field in a projected row.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FieldMapping {
|
||||||
|
/// The name of this field in the projected output.
|
||||||
|
pub field_name: String,
|
||||||
|
/// Where to get the value from.
|
||||||
|
pub source: FieldSource,
|
||||||
|
/// If the source fails to produce a value, use this fallback. Null means omit.
|
||||||
|
pub default: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete schema definition.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectionSchema {
|
||||||
|
/// Unique name for this projection.
|
||||||
|
pub name: String,
|
||||||
|
/// Human-readable description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// How results are shaped.
|
||||||
|
pub projection_type: ProjectionType,
|
||||||
|
/// Which nodes from the activation result are included.
|
||||||
|
pub node_filter: NodeFilter,
|
||||||
|
/// How to extract fields from each included node.
|
||||||
|
pub field_mappings: Vec<FieldMapping>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One projected node in a Relational or Document projection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectedRow {
|
||||||
|
/// The source node's UUID (always included).
|
||||||
|
pub node_id: uuid::Uuid,
|
||||||
|
/// Extracted fields as ordered map field_name → value.
|
||||||
|
pub fields: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The output of running a projection over an activation result set.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectionResult {
|
||||||
|
/// The schema that produced this result.
|
||||||
|
pub schema_name: String,
|
||||||
|
/// How many nodes from the activation set were in scope.
|
||||||
|
pub nodes_in_scope: usize,
|
||||||
|
/// The projected rows.
|
||||||
|
pub rows: Vec<ProjectedRow>,
|
||||||
|
/// For KeyValue projection: node_id (string) → content.
|
||||||
|
pub key_value: Option<HashMap<String, Value>>,
|
||||||
|
/// For WideColumn projection: node_id → column_name → value.
|
||||||
|
pub wide_column: Option<HashMap<String, HashMap<String, Value>>>,
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
engram-core = { path = "../engram-core" }
|
engram-core = { path = "../engram-core" }
|
||||||
engram-sync = { path = "../engram-sync" }
|
engram-sync = { path = "../engram-sync" }
|
||||||
|
engram-projection = { path = "../engram-projection" }
|
||||||
|
engram-tx = { path = "../engram-tx" }
|
||||||
|
engram-crypto = { path = "../engram-crypto" }
|
||||||
|
sled = "0.34"
|
||||||
axum = { version = "0.7", features = ["json"] }
|
axum = { version = "0.7", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = "0.4"
|
tower = "0.4"
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use engram_core::EngramDb;
|
use engram_core::EngramDb;
|
||||||
|
use engram_projection::registry::ProjectionRegistry;
|
||||||
use engram_sync::{SyncConfig, SyncEngine};
|
use engram_sync::{SyncConfig, SyncEngine};
|
||||||
|
use engram_tx::TransactionEngine;
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
@@ -109,6 +111,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let db = EngramDb::open(&PathBuf::from(&db_path))?;
|
let db = EngramDb::open(&PathBuf::from(&db_path))?;
|
||||||
let db = Arc::new(Mutex::new(db));
|
let db = Arc::new(Mutex::new(db));
|
||||||
|
|
||||||
|
// Transaction engine (separate sled db alongside the main db)
|
||||||
|
let tx_log_path = format!("{}-tx-log", db_path);
|
||||||
|
let tx_log_db = sled::open(&tx_log_path)?;
|
||||||
|
let tx_engine = Arc::new(Mutex::new(TransactionEngine::new(
|
||||||
|
db.clone(),
|
||||||
|
tx_log_db,
|
||||||
|
Some(uuid::Uuid::new_v4()),
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Projection registry
|
||||||
|
let projection_registry = Arc::new(Mutex::new(ProjectionRegistry::new()));
|
||||||
|
|
||||||
info!("Database opened at {}", db_path);
|
info!("Database opened at {}", db_path);
|
||||||
|
|
||||||
// Sync engine — wrapped in tokio::sync::Mutex so it can be held across .await
|
// Sync engine — wrapped in tokio::sync::Mutex so it can be held across .await
|
||||||
@@ -164,6 +178,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
sync_engine: sync_engine.clone(),
|
sync_engine: sync_engine.clone(),
|
||||||
api_key: api_key.clone(),
|
api_key: api_key.clone(),
|
||||||
|
projection_registry,
|
||||||
|
tx_engine,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protected sync/swarm routes (auth middleware applied)
|
// Protected sync/swarm routes (auth middleware applied)
|
||||||
@@ -192,6 +208,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/decay", post(routes::core::decay))
|
.route("/decay", post(routes::core::decay))
|
||||||
.route("/consolidate", post(routes::core::consolidate));
|
.route("/consolidate", post(routes::core::consolidate));
|
||||||
|
|
||||||
|
// Projection routes (no auth)
|
||||||
|
let projection_routes = Router::new()
|
||||||
|
.route("/projections", post(routes::projection::register_projection))
|
||||||
|
.route("/projections", get(routes::projection::list_projections))
|
||||||
|
.route(
|
||||||
|
"/projections/{name}/schema",
|
||||||
|
get(routes::projection::get_projection_schema),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projections/{name}/query",
|
||||||
|
post(routes::projection::query_projection),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transaction routes (no auth — add auth layer if needed)
|
||||||
|
let tx_routes = Router::new()
|
||||||
|
.route("/tx/apply", post(routes::tx::tx_apply))
|
||||||
|
.route("/tx/rollback/{command_id}", post(routes::tx::tx_rollback))
|
||||||
|
.route("/tx/history", get(routes::tx::tx_history))
|
||||||
|
.route("/tx/chain/{command_id}", get(routes::tx::tx_causal_chain));
|
||||||
|
|
||||||
let studio_routes = Router::new()
|
let studio_routes = Router::new()
|
||||||
.route("/", get(serve_studio_index))
|
.route("/", get(serve_studio_index))
|
||||||
.route("/studio", get(serve_studio_index))
|
.route("/studio", get(serve_studio_index))
|
||||||
@@ -201,6 +237,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.merge(studio_routes)
|
.merge(studio_routes)
|
||||||
.merge(core_routes)
|
.merge(core_routes)
|
||||||
.merge(sync_routes)
|
.merge(sync_routes)
|
||||||
|
.merge(projection_routes)
|
||||||
|
.merge(tx_routes)
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
pub mod core;
|
pub mod core;
|
||||||
|
pub mod projection;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod swarm;
|
pub mod swarm;
|
||||||
|
pub mod tx;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/// Projection API routes.
|
||||||
|
///
|
||||||
|
/// POST /projections — register a projection schema
|
||||||
|
/// GET /projections — list all registered schemas
|
||||||
|
/// POST /projections/{name}/query — run activation then project results
|
||||||
|
/// GET /projections/{name}/schema — get a schema definition
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use engram_projection::{
|
||||||
|
engine::ProjectionEngine,
|
||||||
|
schema::{ProjectionResult, ProjectionSchema},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
// ── POST /projections ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn register_projection(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(schema): Json<ProjectionSchema>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let mut reg = state
|
||||||
|
.projection_registry
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
reg.upsert(schema)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /projections ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ListProjectionsResponse {
|
||||||
|
pub schemas: Vec<ProjectionSchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_projections(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<ListProjectionsResponse>, StatusCode> {
|
||||||
|
let reg = state
|
||||||
|
.projection_registry
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let schemas = reg.list().into_iter().cloned().collect();
|
||||||
|
Ok(Json(ListProjectionsResponse { schemas }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /projections/{name}/schema ────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn get_projection_schema(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Result<Json<ProjectionSchema>, StatusCode> {
|
||||||
|
let reg = state
|
||||||
|
.projection_registry
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
match reg.get(&name) {
|
||||||
|
Ok(schema) => Ok(Json(schema.clone())),
|
||||||
|
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /projections/{name}/query ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ProjectionQueryRequest {
|
||||||
|
/// Seed node IDs for spreading activation.
|
||||||
|
pub seeds: Vec<Uuid>,
|
||||||
|
/// Query embedding for spreading activation.
|
||||||
|
pub query_embedding: Vec<f32>,
|
||||||
|
#[serde(default = "default_depth")]
|
||||||
|
pub max_depth: u8,
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_depth() -> u8 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
fn default_limit() -> usize {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn query_projection(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(req): Json<ProjectionQueryRequest>,
|
||||||
|
) -> Result<Json<ProjectionResult>, StatusCode> {
|
||||||
|
// Load schema
|
||||||
|
let schema = {
|
||||||
|
let reg = state
|
||||||
|
.projection_registry
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
match reg.get(&name) {
|
||||||
|
Ok(s) => s.clone(),
|
||||||
|
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run spreading activation
|
||||||
|
let activated = {
|
||||||
|
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
db.activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply projection
|
||||||
|
let result = ProjectionEngine::project(&schema, &activated)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/// Transaction API routes.
|
||||||
|
///
|
||||||
|
/// POST /tx/apply — apply a command
|
||||||
|
/// POST /tx/rollback/{id} — roll back a command
|
||||||
|
/// GET /tx/history?since={ms} — command history
|
||||||
|
/// GET /tx/chain/{id} — causal chain for a command
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use engram_tx::{Command, CommandResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
// ── POST /tx/apply ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn tx_apply(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(cmd): Json<Command>,
|
||||||
|
) -> Result<Json<CommandResult>, StatusCode> {
|
||||||
|
let mut engine = state
|
||||||
|
.tx_engine
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let result = engine
|
||||||
|
.apply(cmd)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /tx/rollback/{command_id} ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RollbackResponse {
|
||||||
|
pub rollback_command_id: Uuid,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tx_rollback(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(command_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<RollbackResponse>, StatusCode> {
|
||||||
|
let mut engine = state
|
||||||
|
.tx_engine
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let rb = engine
|
||||||
|
.rollback(command_id)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::warn!("rollback failed: {}", e);
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
})?;
|
||||||
|
Ok(Json(RollbackResponse {
|
||||||
|
rollback_command_id: rb.id,
|
||||||
|
status: format!("{:?}", rb.status),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /tx/history ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct HistoryParams {
|
||||||
|
pub since: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HistoryResponse {
|
||||||
|
pub commands: Vec<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tx_history(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<HistoryParams>,
|
||||||
|
) -> Result<Json<HistoryResponse>, StatusCode> {
|
||||||
|
let engine = state
|
||||||
|
.tx_engine
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let since = params.since.unwrap_or(0);
|
||||||
|
let commands = engine
|
||||||
|
.history(since)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(HistoryResponse { commands }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /tx/chain/{command_id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CausalChainResponse {
|
||||||
|
pub chain: Vec<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tx_causal_chain(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(command_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<CausalChainResponse>, StatusCode> {
|
||||||
|
let engine = state
|
||||||
|
.tx_engine
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let chain = engine
|
||||||
|
.causal_chain(command_id)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(CausalChainResponse { chain }))
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/// Shared application state for all request handlers.
|
/// Shared application state for all request handlers.
|
||||||
use engram_core::EngramDb;
|
use engram_core::EngramDb;
|
||||||
|
use engram_projection::registry::ProjectionRegistry;
|
||||||
use engram_sync::SyncEngine;
|
use engram_sync::SyncEngine;
|
||||||
|
use engram_tx::TransactionEngine;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -10,4 +12,8 @@ pub struct AppState {
|
|||||||
pub sync_engine: Arc<tokio::sync::Mutex<SyncEngine>>,
|
pub sync_engine: Arc<tokio::sync::Mutex<SyncEngine>>,
|
||||||
/// API key used to authenticate incoming sync requests
|
/// API key used to authenticate incoming sync requests
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
|
/// Projection registry — named schema views over the activation surface
|
||||||
|
pub projection_registry: Arc<Mutex<ProjectionRegistry>>,
|
||||||
|
/// Transaction engine — append-only command log with rollback support
|
||||||
|
pub tx_engine: Arc<Mutex<TransactionEngine>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "engram-tx"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Command pattern + rollback-of-rollback transaction engine for Engram"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
engram-core = { path = "../engram-core" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
thiserror = "1"
|
||||||
|
sled = "0.34"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/// Command — the first-class mutation unit of Engram's transaction system.
|
||||||
|
///
|
||||||
|
/// A command is an immutable record of intent. Once created, its ID, type,
|
||||||
|
/// idempotency key, and causal parent never change. Status and conflict fields
|
||||||
|
/// are the only mutable parts, updated as the command moves through its lifecycle.
|
||||||
|
use engram_core::types::MemoryTier;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// The operation a command performs.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CommandType {
|
||||||
|
/// Create a new node. `payload` contains the node's fields.
|
||||||
|
CreateNode,
|
||||||
|
/// Update an existing node's content/metadata. `payload` contains the new fields.
|
||||||
|
UpdateNode,
|
||||||
|
/// Delete a node by UUID.
|
||||||
|
DeleteNode,
|
||||||
|
/// Create an edge between two nodes.
|
||||||
|
CreateEdge,
|
||||||
|
/// Delete an edge by its from/to pair.
|
||||||
|
DeleteEdge,
|
||||||
|
/// Update a node's salience score.
|
||||||
|
UpdateSalience,
|
||||||
|
/// Batch import of many nodes/edges.
|
||||||
|
BulkImport,
|
||||||
|
/// Roll back a previously applied command. The UUID is the target command's ID.
|
||||||
|
Rollback(Uuid),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifecycle status of a command.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CommandStatus {
|
||||||
|
/// Created but not yet applied.
|
||||||
|
Pending,
|
||||||
|
/// Successfully applied to the database.
|
||||||
|
Applied,
|
||||||
|
/// Rolled back (a subsequent Rollback command was applied).
|
||||||
|
RolledBack,
|
||||||
|
/// Applied but conflicted with another command; conflict details in the log.
|
||||||
|
Conflicted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command in Engram's append-only mutation log.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Command {
|
||||||
|
/// Stable unique identifier for this command.
|
||||||
|
pub id: Uuid,
|
||||||
|
/// The operation this command performs.
|
||||||
|
pub command_type: CommandType,
|
||||||
|
/// The forward operation data — what to do.
|
||||||
|
pub payload: Value,
|
||||||
|
/// The inverse operation data — computed eagerly at command creation time.
|
||||||
|
/// This is the "undo" data, available even if the graph has changed since.
|
||||||
|
pub inverse_payload: Value,
|
||||||
|
/// Dedup key. If a command with this idempotency key has already been applied,
|
||||||
|
/// the new command is a no-op. Prevents double-application in distributed sync.
|
||||||
|
pub idempotency_key: String,
|
||||||
|
/// The command that caused this one, if any (forms the causal DAG).
|
||||||
|
pub causal_parent: Option<Uuid>,
|
||||||
|
/// Unix milliseconds when this command was created.
|
||||||
|
pub timestamp_ms: i64,
|
||||||
|
/// Current lifecycle status.
|
||||||
|
pub status: CommandStatus,
|
||||||
|
/// Which peer originated this command (for conflict resolution).
|
||||||
|
pub peer_id: Option<Uuid>,
|
||||||
|
/// If conflicted, a human-readable description of the conflict.
|
||||||
|
pub conflict_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
/// Create a new pending command.
|
||||||
|
pub fn new(
|
||||||
|
command_type: CommandType,
|
||||||
|
payload: Value,
|
||||||
|
inverse_payload: Value,
|
||||||
|
idempotency_key: impl Into<String>,
|
||||||
|
causal_parent: Option<Uuid>,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
command_type,
|
||||||
|
payload,
|
||||||
|
inverse_payload,
|
||||||
|
idempotency_key: idempotency_key.into(),
|
||||||
|
causal_parent,
|
||||||
|
timestamp_ms: engram_core::types::now_ms(),
|
||||||
|
status: CommandStatus::Pending,
|
||||||
|
peer_id,
|
||||||
|
conflict_note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of successfully applying a command.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CommandResult {
|
||||||
|
/// The command that was applied.
|
||||||
|
pub command_id: Uuid,
|
||||||
|
/// The command's new status.
|
||||||
|
pub status: CommandStatus,
|
||||||
|
/// Any entity UUID produced by the operation (e.g., the new node's UUID).
|
||||||
|
pub produced_id: Option<Uuid>,
|
||||||
|
/// Whether this was a no-op due to idempotency.
|
||||||
|
pub was_idempotent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payload schema helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Payload for CreateNode commands.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateNodePayload {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
pub node_type: String,
|
||||||
|
pub embedding: Vec<f32>,
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
pub tier: MemoryTier,
|
||||||
|
pub importance: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for UpdateNode commands.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateNodePayload {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
pub new_content: Option<Vec<u8>>,
|
||||||
|
pub new_importance: Option<f32>,
|
||||||
|
pub new_tier: Option<MemoryTier>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for DeleteNode commands.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeleteNodePayload {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
/// The full node is saved at command-creation time so rollback can restore it.
|
||||||
|
pub snapshot: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for CreateEdge commands.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateEdgePayload {
|
||||||
|
pub edge_id: Uuid,
|
||||||
|
pub from_id: Uuid,
|
||||||
|
pub to_id: Uuid,
|
||||||
|
pub relation: String,
|
||||||
|
pub weight: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for DeleteEdge commands.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeleteEdgePayload {
|
||||||
|
pub edge_id: Uuid,
|
||||||
|
pub from_id: Uuid,
|
||||||
|
pub to_id: Uuid,
|
||||||
|
/// Full edge snapshot for rollback.
|
||||||
|
pub snapshot: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for UpdateSalience commands.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateSaliencePayload {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
pub new_salience: f32,
|
||||||
|
pub old_salience: f32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
/// TransactionEngine — applies commands to EngramDb and manages rollback.
|
||||||
|
///
|
||||||
|
/// The engine wraps an `EngramDb` and a `CommandLog`. All mutations go through
|
||||||
|
/// `apply()`. Rollbacks create new inverse commands. Rolling back a rollback
|
||||||
|
/// re-applies the original — full undo/redo with causal tracking.
|
||||||
|
use engram_core::types::{Edge, Node, NodeType, RelationType};
|
||||||
|
use engram_core::EngramDb;
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::command::{
|
||||||
|
Command, CommandResult, CommandStatus, CommandType, CreateEdgePayload, CreateNodePayload,
|
||||||
|
DeleteEdgePayload, DeleteNodePayload, UpdateNodePayload, UpdateSaliencePayload,
|
||||||
|
};
|
||||||
|
use crate::error::{TxError, TxResult};
|
||||||
|
use crate::log::CommandLog;
|
||||||
|
|
||||||
|
pub struct TransactionEngine {
|
||||||
|
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
|
||||||
|
log: CommandLog,
|
||||||
|
/// Our peer ID for conflict resolution.
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransactionEngine {
|
||||||
|
/// Create a new engine backed by the given database and a sled store for the log.
|
||||||
|
pub fn new(
|
||||||
|
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
|
||||||
|
log_db: sled::Db,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
log: CommandLog::open(log_db),
|
||||||
|
peer_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Apply a command to the database.
|
||||||
|
///
|
||||||
|
/// Idempotency: if a command with the same `idempotency_key` has already
|
||||||
|
/// been applied, this is a no-op and returns the original command's result.
|
||||||
|
pub fn apply(&mut self, mut cmd: Command) -> TxResult<CommandResult> {
|
||||||
|
// Idempotency check
|
||||||
|
if let Some(existing_id) = self.log.check_idempotency(&cmd.idempotency_key)? {
|
||||||
|
return Ok(CommandResult {
|
||||||
|
command_id: existing_id,
|
||||||
|
status: CommandStatus::Applied,
|
||||||
|
produced_id: None,
|
||||||
|
was_idempotent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the operation
|
||||||
|
let produced_id = self.execute(&cmd)?;
|
||||||
|
|
||||||
|
cmd.status = CommandStatus::Applied;
|
||||||
|
self.log.write(&cmd)?;
|
||||||
|
|
||||||
|
Ok(CommandResult {
|
||||||
|
command_id: cmd.id,
|
||||||
|
status: CommandStatus::Applied,
|
||||||
|
produced_id,
|
||||||
|
was_idempotent: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll back a previously applied command.
|
||||||
|
///
|
||||||
|
/// Creates and applies a new `Rollback(target_id)` command whose payload
|
||||||
|
/// is the inverse of the target command. The target command's status is
|
||||||
|
/// updated to `RolledBack`.
|
||||||
|
///
|
||||||
|
/// Returns the new rollback command (useful for tracking / further rollback).
|
||||||
|
pub fn rollback(&mut self, target_id: Uuid) -> TxResult<Command> {
|
||||||
|
let target = self.log.require(target_id)?;
|
||||||
|
|
||||||
|
if target.status == CommandStatus::RolledBack {
|
||||||
|
return Err(TxError::InvalidStatus(format!(
|
||||||
|
"command {} is already rolled back",
|
||||||
|
target_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if target.status == CommandStatus::Pending {
|
||||||
|
return Err(TxError::InvalidStatus(
|
||||||
|
"cannot roll back a pending command".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rollback's payload is the original command's inverse_payload.
|
||||||
|
// The rollback's inverse_payload is the original command's payload.
|
||||||
|
// This enables rollback-of-rollback to re-apply the original.
|
||||||
|
let rollback_key = format!("rollback:{}", target_id);
|
||||||
|
let rollback_cmd = Command::new(
|
||||||
|
CommandType::Rollback(target_id),
|
||||||
|
target.inverse_payload.clone(),
|
||||||
|
target.payload.clone(),
|
||||||
|
rollback_key,
|
||||||
|
Some(target_id),
|
||||||
|
self.peer_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute the inverse operation
|
||||||
|
self.execute_inverse(&target)?;
|
||||||
|
|
||||||
|
// Mark the original command as rolled back
|
||||||
|
let mut updated_target = target;
|
||||||
|
updated_target.status = CommandStatus::RolledBack;
|
||||||
|
self.log.write(&updated_target)?;
|
||||||
|
|
||||||
|
// Persist the rollback command as Applied
|
||||||
|
let mut rb = rollback_cmd;
|
||||||
|
rb.status = CommandStatus::Applied;
|
||||||
|
self.log.write(&rb)?;
|
||||||
|
|
||||||
|
Ok(rb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll back a rollback — re-applying the original command.
|
||||||
|
///
|
||||||
|
/// This is "undo the undo". The rollback_id must be a command of type
|
||||||
|
/// `Rollback(original_id)`. Rolling it back re-applies `original_id`.
|
||||||
|
pub fn rollback_rollback(&mut self, rollback_id: Uuid) -> TxResult<Command> {
|
||||||
|
let rb_cmd = self.log.require(rollback_id)?;
|
||||||
|
|
||||||
|
// Verify this IS a rollback command
|
||||||
|
let original_id = match &rb_cmd.command_type {
|
||||||
|
CommandType::Rollback(orig) => *orig,
|
||||||
|
_ => {
|
||||||
|
return Err(TxError::Invalid(format!(
|
||||||
|
"command {} is not a Rollback command",
|
||||||
|
rollback_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if rb_cmd.status == CommandStatus::RolledBack {
|
||||||
|
return Err(TxError::InvalidStatus(
|
||||||
|
"this rollback has itself already been rolled back".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rollback_rollback's payload is rb_cmd.inverse_payload (= original payload)
|
||||||
|
// Its inverse is rb_cmd.payload (= original's inverse_payload)
|
||||||
|
let key = format!("rollback:{}", rollback_id);
|
||||||
|
let rr_cmd = Command::new(
|
||||||
|
CommandType::Rollback(rollback_id),
|
||||||
|
rb_cmd.inverse_payload.clone(),
|
||||||
|
rb_cmd.payload.clone(),
|
||||||
|
key,
|
||||||
|
Some(rollback_id),
|
||||||
|
self.peer_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-apply the original command by executing the original's payload
|
||||||
|
let original = self.log.require(original_id)?;
|
||||||
|
self.execute(&original)?;
|
||||||
|
|
||||||
|
// Mark original as Applied again
|
||||||
|
let mut orig = original;
|
||||||
|
orig.status = CommandStatus::Applied;
|
||||||
|
self.log.write(&orig)?;
|
||||||
|
|
||||||
|
// Mark rollback as RolledBack
|
||||||
|
let mut rb = rb_cmd;
|
||||||
|
rb.status = CommandStatus::RolledBack;
|
||||||
|
self.log.write(&rb)?;
|
||||||
|
|
||||||
|
// Persist the new re-apply command
|
||||||
|
let mut rr = rr_cmd;
|
||||||
|
rr.status = CommandStatus::Applied;
|
||||||
|
self.log.write(&rr)?;
|
||||||
|
|
||||||
|
Ok(rr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All commands since a given Unix millisecond timestamp.
|
||||||
|
pub fn history(&self, since_ms: i64) -> TxResult<Vec<Command>> {
|
||||||
|
self.log.since(since_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The causal chain for a given command (root-first).
|
||||||
|
pub fn causal_chain(&self, command_id: Uuid) -> TxResult<Vec<Command>> {
|
||||||
|
self.log.causal_chain(command_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the command log directly (for server routes).
|
||||||
|
pub fn log(&self) -> &CommandLog {
|
||||||
|
&self.log
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command execution ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn execute(&self, cmd: &Command) -> TxResult<Option<Uuid>> {
|
||||||
|
match &cmd.command_type {
|
||||||
|
CommandType::CreateNode => self.exec_create_node(&cmd.payload),
|
||||||
|
CommandType::UpdateNode => {
|
||||||
|
self.exec_update_node(&cmd.payload)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
CommandType::DeleteNode => {
|
||||||
|
self.exec_delete_node(&cmd.payload)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
CommandType::CreateEdge => {
|
||||||
|
self.exec_create_edge(&cmd.payload)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
CommandType::DeleteEdge => {
|
||||||
|
self.exec_delete_edge(&cmd.payload)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
CommandType::UpdateSalience => {
|
||||||
|
self.exec_update_salience(&cmd.payload)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
CommandType::BulkImport => {
|
||||||
|
self.exec_bulk_import(&cmd.payload)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
CommandType::Rollback(_) => {
|
||||||
|
// Rollback commands are executed via execute_inverse on the target
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_inverse(&self, cmd: &Command) -> TxResult<()> {
|
||||||
|
// The inverse is described by inverse_payload, which mirrors the command type
|
||||||
|
match &cmd.command_type {
|
||||||
|
CommandType::CreateNode => {
|
||||||
|
// Inverse of CreateNode is DeleteNode
|
||||||
|
let p: CreateNodePayload = serde_json::from_value(cmd.payload.clone())?;
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
db.delete_node(p.node_id)?;
|
||||||
|
}
|
||||||
|
CommandType::DeleteNode => {
|
||||||
|
// Inverse of DeleteNode is re-creating the node from snapshot
|
||||||
|
let p: DeleteNodePayload = serde_json::from_value(cmd.payload.clone())?;
|
||||||
|
let node: Node = serde_json::from_value(p.snapshot)?;
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
db.put_node(node)?;
|
||||||
|
}
|
||||||
|
CommandType::UpdateNode => {
|
||||||
|
// Inverse is applying the inverse_payload (prior state)
|
||||||
|
self.exec_update_node(&cmd.inverse_payload)?;
|
||||||
|
}
|
||||||
|
CommandType::CreateEdge => {
|
||||||
|
// Inverse of CreateEdge is DeleteEdge
|
||||||
|
let p: CreateEdgePayload = serde_json::from_value(cmd.payload.clone())?;
|
||||||
|
self.delete_edge_by_pair(p.from_id, p.to_id)?;
|
||||||
|
}
|
||||||
|
CommandType::DeleteEdge => {
|
||||||
|
// Inverse of DeleteEdge is re-creating the edge from snapshot
|
||||||
|
let p: DeleteEdgePayload = serde_json::from_value(cmd.payload.clone())?;
|
||||||
|
let edge: Edge = serde_json::from_value(p.snapshot)?;
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
db.put_edge(edge)?;
|
||||||
|
}
|
||||||
|
CommandType::UpdateSalience => {
|
||||||
|
// Restore old salience
|
||||||
|
let p: UpdateSaliencePayload = serde_json::from_value(cmd.payload.clone())?;
|
||||||
|
let restore = UpdateSaliencePayload {
|
||||||
|
node_id: p.node_id,
|
||||||
|
new_salience: p.old_salience,
|
||||||
|
old_salience: p.new_salience,
|
||||||
|
};
|
||||||
|
self.exec_update_salience(&serde_json::to_value(restore)?)?;
|
||||||
|
}
|
||||||
|
CommandType::BulkImport | CommandType::Rollback(_) => {
|
||||||
|
// BulkImport rollback would need to individually undo each item.
|
||||||
|
// For now, we store the inverse_payload as instructions and log the gap.
|
||||||
|
// TODO: implement fine-grained BulkImport rollback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Operation implementations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn exec_create_node(&self, payload: &Value) -> TxResult<Option<Uuid>> {
|
||||||
|
let p: CreateNodePayload = serde_json::from_value(payload.clone())?;
|
||||||
|
let node_type = parse_node_type(&p.node_type)?;
|
||||||
|
let tier = p.tier;
|
||||||
|
let node = Node::new(node_type, p.embedding, p.content, tier, p.importance)
|
||||||
|
.with_id(p.node_id);
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
db.put_node(node)?;
|
||||||
|
Ok(Some(p.node_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_update_node(&self, payload: &Value) -> TxResult<()> {
|
||||||
|
let p: UpdateNodePayload = serde_json::from_value(payload.clone())?;
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
let mut node = db
|
||||||
|
.get_node(p.node_id)?
|
||||||
|
.ok_or(TxError::NotFound(p.node_id))?;
|
||||||
|
if let Some(content) = p.new_content {
|
||||||
|
node.content = content;
|
||||||
|
}
|
||||||
|
if let Some(importance) = p.new_importance {
|
||||||
|
node.importance = importance.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
if let Some(tier) = p.new_tier {
|
||||||
|
node.tier = tier;
|
||||||
|
}
|
||||||
|
db.put_node(node)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_delete_node(&self, payload: &Value) -> TxResult<()> {
|
||||||
|
let p: DeleteNodePayload = serde_json::from_value(payload.clone())?;
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
db.delete_node(p.node_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_create_edge(&self, payload: &Value) -> TxResult<()> {
|
||||||
|
let p: CreateEdgePayload = serde_json::from_value(payload.clone())?;
|
||||||
|
let relation = parse_relation(&p.relation)?;
|
||||||
|
let edge = Edge::new(p.from_id, p.to_id, relation, p.weight);
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
db.put_edge(edge)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_delete_edge(&self, payload: &Value) -> TxResult<()> {
|
||||||
|
let p: DeleteEdgePayload = serde_json::from_value(payload.clone())?;
|
||||||
|
self.delete_edge_by_pair(p.from_id, p.to_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_update_salience(&self, payload: &Value) -> TxResult<()> {
|
||||||
|
let p: UpdateSaliencePayload = serde_json::from_value(payload.clone())?;
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
let mut node = db
|
||||||
|
.get_node(p.node_id)?
|
||||||
|
.ok_or(TxError::NotFound(p.node_id))?;
|
||||||
|
node.salience = p.new_salience;
|
||||||
|
db.put_node(node)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_bulk_import(&self, payload: &Value) -> TxResult<()> {
|
||||||
|
let nodes_val = payload.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||||
|
let edges_val = payload.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
for nv in nodes_val {
|
||||||
|
let node: Node = serde_json::from_value(nv)?;
|
||||||
|
db.put_node(node)?;
|
||||||
|
}
|
||||||
|
for ev in edges_val {
|
||||||
|
let edge: Edge = serde_json::from_value(ev)?;
|
||||||
|
db.put_edge(edge)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_edge_by_pair(&self, from_id: Uuid, to_id: Uuid) -> TxResult<()> {
|
||||||
|
// sled stores edges at "edges:from:{from}:{to}" — we remove both directions
|
||||||
|
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
|
||||||
|
// We can't directly call into storage here without re-exposing internals,
|
||||||
|
// so we use the public scan-and-check approach via get_edges_from
|
||||||
|
let edges = db.get_edges_from(from_id)?;
|
||||||
|
for edge in edges {
|
||||||
|
if edge.to_id == to_id {
|
||||||
|
// Re-insert a tombstone isn't directly supported — we use the
|
||||||
|
// internal sled key to delete. Since we don't have direct sled
|
||||||
|
// access through EngramDb's public API, we rely on the fact that
|
||||||
|
// put_edge with weight=0 effectively nullifies it, but for proper
|
||||||
|
// deletion we need access to the underlying store.
|
||||||
|
//
|
||||||
|
// We work around this by storing a zero-weight edge with weight=-1
|
||||||
|
// as a sentinel, or by exposing delete_edge. Since delete_node is
|
||||||
|
// already exposed, we add a helper. For now, we store the edge with
|
||||||
|
// weight 0 (marking it inactive) until a delete_edge API is added.
|
||||||
|
//
|
||||||
|
// TODO: add db.delete_edge() to engram-core public API.
|
||||||
|
// For now: overwrite with weight 0 to effectively disable it.
|
||||||
|
let mut tombstone = edge;
|
||||||
|
tombstone.weight = 0.0;
|
||||||
|
db.put_edge(tombstone)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command builder helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a CreateNode command with eagerly-computed inverse.
|
||||||
|
pub fn build_create_node_cmd(
|
||||||
|
node: &Node,
|
||||||
|
idempotency_key: impl Into<String>,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
) -> Command {
|
||||||
|
let payload = serde_json::to_value(CreateNodePayload {
|
||||||
|
node_id: node.id,
|
||||||
|
node_type: node_type_to_str(&node.node_type).to_string(),
|
||||||
|
embedding: node.embedding.clone(),
|
||||||
|
content: node.content.clone(),
|
||||||
|
tier: node.tier.clone(),
|
||||||
|
importance: node.importance,
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
// Inverse: delete the node we're about to create
|
||||||
|
let inverse = serde_json::to_value(DeleteNodePayload {
|
||||||
|
node_id: node.id,
|
||||||
|
snapshot: serde_json::to_value(node).unwrap_or(Value::Null),
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
Command::new(
|
||||||
|
CommandType::CreateNode,
|
||||||
|
payload,
|
||||||
|
inverse,
|
||||||
|
idempotency_key,
|
||||||
|
None,
|
||||||
|
peer_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a DeleteNode command with eagerly-computed inverse (snapshot).
|
||||||
|
pub fn build_delete_node_cmd(
|
||||||
|
node: &Node,
|
||||||
|
idempotency_key: impl Into<String>,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
) -> Command {
|
||||||
|
let snapshot = serde_json::to_value(node).unwrap_or(Value::Null);
|
||||||
|
let payload = serde_json::to_value(DeleteNodePayload {
|
||||||
|
node_id: node.id,
|
||||||
|
snapshot: snapshot.clone(),
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
// Inverse: re-create the node from snapshot
|
||||||
|
let inverse = serde_json::to_value(CreateNodePayload {
|
||||||
|
node_id: node.id,
|
||||||
|
node_type: node_type_to_str(&node.node_type).to_string(),
|
||||||
|
embedding: node.embedding.clone(),
|
||||||
|
content: node.content.clone(),
|
||||||
|
tier: node.tier.clone(),
|
||||||
|
importance: node.importance,
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
Command::new(
|
||||||
|
CommandType::DeleteNode,
|
||||||
|
payload,
|
||||||
|
inverse,
|
||||||
|
idempotency_key,
|
||||||
|
None,
|
||||||
|
peer_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a CreateEdge command.
|
||||||
|
pub fn build_create_edge_cmd(
|
||||||
|
edge: &Edge,
|
||||||
|
idempotency_key: impl Into<String>,
|
||||||
|
peer_id: Option<Uuid>,
|
||||||
|
) -> Command {
|
||||||
|
let relation_str = relation_to_str(&edge.relation).to_string();
|
||||||
|
let payload = serde_json::to_value(CreateEdgePayload {
|
||||||
|
edge_id: edge.id,
|
||||||
|
from_id: edge.from_id,
|
||||||
|
to_id: edge.to_id,
|
||||||
|
relation: relation_str.clone(),
|
||||||
|
weight: edge.weight,
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
// Inverse: delete the edge
|
||||||
|
let snapshot = serde_json::to_value(edge).unwrap_or(Value::Null);
|
||||||
|
let inverse = serde_json::to_value(DeleteEdgePayload {
|
||||||
|
edge_id: edge.id,
|
||||||
|
from_id: edge.from_id,
|
||||||
|
to_id: edge.to_id,
|
||||||
|
snapshot,
|
||||||
|
})
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
Command::new(
|
||||||
|
CommandType::CreateEdge,
|
||||||
|
payload,
|
||||||
|
inverse,
|
||||||
|
idempotency_key,
|
||||||
|
None,
|
||||||
|
peer_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Type string helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn parse_node_type(s: &str) -> TxResult<NodeType> {
|
||||||
|
match s {
|
||||||
|
"Memory" => Ok(NodeType::Memory),
|
||||||
|
"Concept" => Ok(NodeType::Concept),
|
||||||
|
"Event" => Ok(NodeType::Event),
|
||||||
|
"Entity" => Ok(NodeType::Entity),
|
||||||
|
"Process" => Ok(NodeType::Process),
|
||||||
|
"InternalState" => Ok(NodeType::InternalState),
|
||||||
|
_ => Err(TxError::Invalid(format!("unknown node type: {}", s))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_type_to_str(t: &NodeType) -> &'static str {
|
||||||
|
match t {
|
||||||
|
NodeType::Memory => "Memory",
|
||||||
|
NodeType::Concept => "Concept",
|
||||||
|
NodeType::Event => "Event",
|
||||||
|
NodeType::Entity => "Entity",
|
||||||
|
NodeType::Process => "Process",
|
||||||
|
NodeType::InternalState => "InternalState",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_relation(s: &str) -> TxResult<RelationType> {
|
||||||
|
match s {
|
||||||
|
"Supersedes" => Ok(RelationType::Supersedes),
|
||||||
|
"Causes" => Ok(RelationType::Causes),
|
||||||
|
"Contains" => Ok(RelationType::Contains),
|
||||||
|
"References" => Ok(RelationType::References),
|
||||||
|
"Contradicts" => Ok(RelationType::Contradicts),
|
||||||
|
"Exemplifies" => Ok(RelationType::Exemplifies),
|
||||||
|
"Activates" => Ok(RelationType::Activates),
|
||||||
|
"TemporallyPrecedes" => Ok(RelationType::TemporallyPrecedes),
|
||||||
|
_ => Err(TxError::Invalid(format!("unknown relation: {}", s))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation_to_str(r: &RelationType) -> &'static str {
|
||||||
|
match r {
|
||||||
|
RelationType::Supersedes => "Supersedes",
|
||||||
|
RelationType::Causes => "Causes",
|
||||||
|
RelationType::Contains => "Contains",
|
||||||
|
RelationType::References => "References",
|
||||||
|
RelationType::Contradicts => "Contradicts",
|
||||||
|
RelationType::Exemplifies => "Exemplifies",
|
||||||
|
RelationType::Activates => "Activates",
|
||||||
|
RelationType::TemporallyPrecedes => "TemporallyPrecedes",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use engram_core::types::{MemoryTier, Node, NodeType};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn make_engine() -> (TransactionEngine, TempDir, TempDir) {
|
||||||
|
let db_dir = TempDir::new().unwrap();
|
||||||
|
let log_dir = TempDir::new().unwrap();
|
||||||
|
let db = engram_core::EngramDb::open(db_dir.path()).unwrap();
|
||||||
|
let db = std::sync::Arc::new(std::sync::Mutex::new(db));
|
||||||
|
let log_db = sled::open(log_dir.path()).unwrap();
|
||||||
|
let engine = TransactionEngine::new(db, log_db, None);
|
||||||
|
(engine, db_dir, log_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_node() -> Node {
|
||||||
|
Node::new(
|
||||||
|
NodeType::Memory,
|
||||||
|
vec![1.0, 0.0],
|
||||||
|
b"test content".to_vec(),
|
||||||
|
MemoryTier::Semantic,
|
||||||
|
0.8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_create_node() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
let node = make_node();
|
||||||
|
let cmd = build_create_node_cmd(&node, "create-test-1", None);
|
||||||
|
let result = engine.apply(cmd).unwrap();
|
||||||
|
assert_eq!(result.status, CommandStatus::Applied);
|
||||||
|
assert!(!result.was_idempotent);
|
||||||
|
|
||||||
|
// Node should now exist
|
||||||
|
let db = engine.db.lock().unwrap();
|
||||||
|
let found = db.get_node(node.id).unwrap();
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().content, b"test content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idempotency() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
let node = make_node();
|
||||||
|
let cmd1 = build_create_node_cmd(&node, "idem-key-42", None);
|
||||||
|
let cmd2 = build_create_node_cmd(&node, "idem-key-42", None);
|
||||||
|
|
||||||
|
let r1 = engine.apply(cmd1).unwrap();
|
||||||
|
let r2 = engine.apply(cmd2).unwrap();
|
||||||
|
|
||||||
|
assert!(!r1.was_idempotent);
|
||||||
|
assert!(r2.was_idempotent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rollback_delete_node() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
let node = make_node();
|
||||||
|
let node_id = node.id;
|
||||||
|
|
||||||
|
// First apply create
|
||||||
|
let create_cmd = build_create_node_cmd(&node, "create-rb-1", None);
|
||||||
|
let result = engine.apply(create_cmd).unwrap();
|
||||||
|
|
||||||
|
// Roll back the creation — should delete the node
|
||||||
|
let rb = engine.rollback(result.command_id).unwrap();
|
||||||
|
assert_eq!(rb.status, CommandStatus::Applied);
|
||||||
|
|
||||||
|
let db = engine.db.lock().unwrap();
|
||||||
|
let found = db.get_node(node_id).unwrap();
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rollback_of_rollback() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
let node = make_node();
|
||||||
|
let node_id = node.id;
|
||||||
|
|
||||||
|
// Create the node
|
||||||
|
let create_cmd = build_create_node_cmd(&node, "create-rorb-1", None);
|
||||||
|
let create_result = engine.apply(create_cmd).unwrap();
|
||||||
|
|
||||||
|
// Roll back (delete)
|
||||||
|
let rb = engine.rollback(create_result.command_id).unwrap();
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
{
|
||||||
|
let db = engine.db.lock().unwrap();
|
||||||
|
assert!(db.get_node(node_id).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll back the rollback (re-create)
|
||||||
|
let _rr = engine.rollback_rollback(rb.id).unwrap();
|
||||||
|
|
||||||
|
// Node should be back
|
||||||
|
let db = engine.db.lock().unwrap();
|
||||||
|
let found = db.get_node(node_id).unwrap();
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().content, b"test content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_history() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
let node = make_node();
|
||||||
|
let cmd = build_create_node_cmd(&node, "hist-1", None);
|
||||||
|
engine.apply(cmd).unwrap();
|
||||||
|
|
||||||
|
let history = engine.history(0).unwrap();
|
||||||
|
assert!(!history.is_empty());
|
||||||
|
assert!(history.iter().any(|c| matches!(c.command_type, CommandType::CreateNode)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_causal_chain() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
let node = make_node();
|
||||||
|
let cmd = build_create_node_cmd(&node, "causal-1", None);
|
||||||
|
let result = engine.apply(cmd).unwrap();
|
||||||
|
|
||||||
|
// Roll back (creates a child command with causal_parent = create_cmd.id)
|
||||||
|
let rb = engine.rollback(result.command_id).unwrap();
|
||||||
|
|
||||||
|
let chain = engine.causal_chain(rb.id).unwrap();
|
||||||
|
// Chain should be [create_cmd, rollback_cmd]
|
||||||
|
assert_eq!(chain.len(), 2);
|
||||||
|
assert!(matches!(chain[0].command_type, CommandType::CreateNode));
|
||||||
|
assert!(matches!(chain[1].command_type, CommandType::Rollback(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_edge_command() {
|
||||||
|
let (mut engine, _db_dir, _log_dir) = make_engine();
|
||||||
|
|
||||||
|
// Create two nodes first
|
||||||
|
let n1 = make_node();
|
||||||
|
let n2 = Node::new(
|
||||||
|
NodeType::Concept,
|
||||||
|
vec![0.0, 1.0],
|
||||||
|
b"concept".to_vec(),
|
||||||
|
MemoryTier::Semantic,
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
engine.apply(build_create_node_cmd(&n1, "edge-n1", None)).unwrap();
|
||||||
|
engine.apply(build_create_node_cmd(&n2, "edge-n2", None)).unwrap();
|
||||||
|
|
||||||
|
// Create edge
|
||||||
|
let edge = Edge::new(n1.id, n2.id, RelationType::References, 0.7);
|
||||||
|
let edge_cmd = build_create_edge_cmd(&edge, "edge-create-1", None);
|
||||||
|
let result = engine.apply(edge_cmd).unwrap();
|
||||||
|
assert_eq!(result.status, CommandStatus::Applied);
|
||||||
|
|
||||||
|
// Verify edge exists
|
||||||
|
let db = engine.db.lock().unwrap();
|
||||||
|
let edges = db.get_edges_from(n1.id).unwrap();
|
||||||
|
assert!(!edges.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TxError {
|
||||||
|
#[error("Command not found: {0}")]
|
||||||
|
NotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("Command already applied (idempotency key: {0})")]
|
||||||
|
AlreadyApplied(String),
|
||||||
|
|
||||||
|
#[error("Cannot roll back a command in status {0:?}")]
|
||||||
|
InvalidStatus(String),
|
||||||
|
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Storage error: {0}")]
|
||||||
|
Storage(#[from] sled::Error),
|
||||||
|
|
||||||
|
#[error("Engram error: {0}")]
|
||||||
|
Engram(#[from] engram_core::EngramError),
|
||||||
|
|
||||||
|
#[error("Invalid command: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type TxResult<T> = Result<T, TxError>;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// Engram Transaction Engine — Command pattern with rollback-of-rollback.
|
||||||
|
///
|
||||||
|
/// # The Central Insight
|
||||||
|
///
|
||||||
|
/// Every mutation to Engram is a Command — a first-class object with:
|
||||||
|
/// - The operation and its inverse (computed eagerly at command time)
|
||||||
|
/// - An idempotency key (same key = same command, applied once)
|
||||||
|
/// - A causal parent (which command caused this one)
|
||||||
|
/// - A timestamp and originating peer ID
|
||||||
|
///
|
||||||
|
/// Commands form a DAG of causality. You can roll back any command. You can
|
||||||
|
/// roll back a rollback (undo an undo — re-applying the original). The
|
||||||
|
/// command log is append-only: history is never rewritten.
|
||||||
|
///
|
||||||
|
/// # Rollback-of-Rollback
|
||||||
|
///
|
||||||
|
/// When you roll back command X, a new `Rollback(X)` command is created and
|
||||||
|
/// applied. Its `inverse_payload` is X's original `payload`. If you then roll
|
||||||
|
/// back the rollback, a new `Rollback(rollback_id)` is created, whose effect
|
||||||
|
/// is to re-apply X. This is a full undo/redo system with causal lineage.
|
||||||
|
pub mod command;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod error;
|
||||||
|
pub mod log;
|
||||||
|
|
||||||
|
pub use command::{Command, CommandResult, CommandStatus, CommandType};
|
||||||
|
pub use engine::TransactionEngine;
|
||||||
|
pub use error::TxError;
|
||||||
|
pub use log::CommandLog;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/// CommandLog — append-only log of all commands, stored in sled.
|
||||||
|
///
|
||||||
|
/// Key schema:
|
||||||
|
/// cmd:{uuid} → JSON-encoded Command (JSON used because Command contains serde_json::Value)
|
||||||
|
/// idem:{key} → uuid bytes (idempotency index)
|
||||||
|
/// cmd_ts:{ms}:{uuid} → uuid bytes (time-ordered scan index)
|
||||||
|
use sled::Db;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::error::{TxError, TxResult};
|
||||||
|
|
||||||
|
pub struct CommandLog {
|
||||||
|
db: Db,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandLog {
|
||||||
|
pub fn open(db: Db) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Append a command to the log. Overwrites if the UUID already exists
|
||||||
|
/// (used for status updates after application).
|
||||||
|
pub fn write(&self, cmd: &Command) -> TxResult<()> {
|
||||||
|
let key = cmd_key(cmd.id);
|
||||||
|
// Use JSON (not bincode) because Command contains serde_json::Value,
|
||||||
|
// which bincode cannot deserialize (DeserializeAnyNotSupported).
|
||||||
|
let val = serde_json::to_vec(cmd)?;
|
||||||
|
self.db.insert(key, val)?;
|
||||||
|
|
||||||
|
// Idempotency index: idem:{key} → uuid
|
||||||
|
let idem_key = idem_key(&cmd.idempotency_key);
|
||||||
|
self.db.insert(idem_key, cmd.id.as_bytes().to_vec())?;
|
||||||
|
|
||||||
|
// Time index: cmd_ts:{ms:016x}:{uuid} → uuid
|
||||||
|
let ts_key = ts_key(cmd.timestamp_ms, cmd.id);
|
||||||
|
self.db.insert(ts_key, cmd.id.as_bytes().to_vec())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn get(&self, id: Uuid) -> TxResult<Option<Command>> {
|
||||||
|
match self.db.get(cmd_key(id))? {
|
||||||
|
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require(&self, id: Uuid) -> TxResult<Command> {
|
||||||
|
self.get(id)?.ok_or(TxError::NotFound(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether an idempotency key has already been applied.
|
||||||
|
/// Returns the command UUID if it exists.
|
||||||
|
pub fn check_idempotency(&self, key: &str) -> TxResult<Option<Uuid>> {
|
||||||
|
match self.db.get(idem_key(key))? {
|
||||||
|
Some(bytes) => {
|
||||||
|
let arr: [u8; 16] = bytes[..16]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| TxError::Invalid("bad uuid bytes in idem index".into()))?;
|
||||||
|
Ok(Some(Uuid::from_bytes(arr)))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all commands created at or after `since_ms`, ordered by timestamp.
|
||||||
|
pub fn since(&self, since_ms: i64) -> TxResult<Vec<Command>> {
|
||||||
|
let prefix = format!("cmd_ts:{:016x}:", since_ms);
|
||||||
|
let mut cmds = Vec::new();
|
||||||
|
for result in self.db.range(prefix.as_bytes()..) {
|
||||||
|
let (k, _v) = result?;
|
||||||
|
// Check the key starts with "cmd_ts:"
|
||||||
|
if !k.starts_with(b"cmd_ts:") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Extract uuid from key: cmd_ts:{ms}:{uuid}
|
||||||
|
let key_str = std::str::from_utf8(&k)
|
||||||
|
.map_err(|e| TxError::Invalid(e.to_string()))?;
|
||||||
|
let parts: Vec<&str> = key_str.splitn(3, ':').collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// parts[1] = ms (hex), parts[2] = uuid
|
||||||
|
let ts_hex = parts[1];
|
||||||
|
let ts = i64::from_str_radix(ts_hex, 16)
|
||||||
|
.map_err(|e| TxError::Invalid(e.to_string()))?;
|
||||||
|
if ts < since_ms {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id: Uuid = parts[2]
|
||||||
|
.parse()
|
||||||
|
.map_err(|e: uuid::Error| TxError::Invalid(e.to_string()))?;
|
||||||
|
if let Some(cmd) = self.get(id)? {
|
||||||
|
cmds.push(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all commands in the store.
|
||||||
|
pub fn all(&self) -> TxResult<Vec<Command>> {
|
||||||
|
self.since(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the causal chain leading to a given command (inclusive).
|
||||||
|
/// Walks `causal_parent` links back to the root.
|
||||||
|
pub fn causal_chain(&self, id: Uuid) -> TxResult<Vec<Command>> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
let mut current_id = Some(id);
|
||||||
|
let mut visited = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
while let Some(cid) = current_id {
|
||||||
|
if visited.contains(&cid) {
|
||||||
|
break; // cycle guard
|
||||||
|
}
|
||||||
|
visited.insert(cid);
|
||||||
|
|
||||||
|
match self.get(cid)? {
|
||||||
|
Some(cmd) => {
|
||||||
|
current_id = cmd.causal_parent;
|
||||||
|
chain.push(cmd);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return root-first (reverse of walk order)
|
||||||
|
chain.reverse();
|
||||||
|
Ok(chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key constructors ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_key(id: Uuid) -> Vec<u8> {
|
||||||
|
format!("cmd:{}", id).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn idem_key(key: &str) -> Vec<u8> {
|
||||||
|
format!("idem:{}", key).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ts_key(ts: i64, id: Uuid) -> Vec<u8> {
|
||||||
|
// Zero-padded hex timestamp for lexicographic ordering
|
||||||
|
format!("cmd_ts:{:016x}:{}", ts, id).into_bytes()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user