Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcce89f076 | |||
| 66d5b03495 | |||
| 7af751c888 | |||
| 9f2d5ea704 | |||
| 8428b75b96 | |||
| 282b616a0e | |||
| 12eaf7a3de | |||
| f28e4d87f0 | |||
| ba2e52f841 | |||
| 5f1938c7c3 | |||
| 5e2f959c01 | |||
| 8da04717b6 | |||
| c094828704 | |||
| ce658e58f7 | |||
| 7f0a642972 | |||
| 84ab2b3651 | |||
| 1f0b202135 | |||
| aa56a12846 | |||
| 10996f11f7 | |||
| 1352a61d70 | |||
| 66a3926adb | |||
| 917e602a87 | |||
| 56fadb364a | |||
| 8fa1c8ce8d | |||
| c17e78c6a8 | |||
| 61bf7ba468 | |||
| 9712c7e1f7 | |||
| a560df80ce | |||
| bb56574824 | |||
| 8363c8bdc9 | |||
| b375f263dc | |||
| e1fe419028 | |||
| 9ec4f26874 | |||
| 80eea82398 | |||
| e39cf656b2 | |||
| c4f56d9e85 | |||
| c1dbaad88d | |||
| d93184d41d | |||
| de3cec6a23 | |||
| 0a4cddafc4 | |||
| 6d2b1401aa | |||
| 0a8fb5dd1b | |||
| a8c90107b5 | |||
| ced653bfc0 | |||
| 153014be9d | |||
| 8d4b3b67fb |
11
.env.example
11
.env.example
@@ -1,10 +1,19 @@
|
|||||||
# file: .env.example
|
# file: .env.example
|
||||||
# version: 10
|
# version: 13
|
||||||
|
|
||||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||||
KSP_LOGS_DIRECTORY=logs
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
|
||||||
|
# PostgreSQL URI for the Devnet Store target.
|
||||||
|
KSP_SECRET_STORE_DEVNET_POSTGRES_URI=postgresql://...
|
||||||
|
|
||||||
|
# PostgreSQL URI for the Mainnet Store target.
|
||||||
|
KSP_SECRET_STORE_MAINNET_POSTGRES_URI=postgresql://...
|
||||||
|
|
||||||
|
# PostgreSQL URI for the Testnet Store target.
|
||||||
|
KSP_SECRET_STORE_TESTNET_POSTGRES_URI=postgresql://...
|
||||||
|
|
||||||
# KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended.
|
# KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended.
|
||||||
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
||||||
KSP_WALLETS_DIRECTORY=wallets
|
KSP_WALLETS_DIRECTORY=wallets
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,8 +1,30 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 19 -->
|
<!-- version: 22 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
|
## 0.3.3 — Store/PostgreSQL RawTransaction vertical slice — 2026-08-30
|
||||||
|
|
||||||
|
`0.3.3` complète la première vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` sans modifier les contrats backend-agnostic acquis dans `ksp-store-api`. `PostgresBackend` et la façade `Store` implémentent désormais les six capabilities `RawTransactionRead`, `RawTransactionWrite`, `RawTransactionObservationRead`, `RawTransactionObservationWrite`, `RawTransactionRetentionRead` et `RawTransactionRetentionWrite`. Une base PostgreSQL reste liée à un unique `RawNetworkId` par `ksp_store_identity`; le mauvais réseau est refusé avant I/O, les slots `u64` sont conservés exactement en `NUMERIC(20,0)`, et la migration logique V001 reste découpée en ressources tables/contraintes/indexes avec vérification de compatibilité du schéma effectif.
|
||||||
|
|
||||||
|
La persistence canonique utilise une transaction PostgreSQL unique pour `RawTransaction + observation`, tente l'insert sous contrainte d'unicité puis compare le contenu réel sous verrou en cas de collision : contenu identique -> idempotence, divergence -> `store_api.raw_conflict`. Les observations supplémentaires conservent leur provenance complète et ne créent jamais implicitement un canonical absent. La lecture reconstruit `Full` depuis le payload chaud et `Archived` depuis la relation archive ; `Purged` reste un tombstone minimal. La navigation est une keyset stricte `(slot, signature)` ASC/DESC sans `OFFSET`, avec cursor V1 opaque de 109 octets lié au réseau, à la direction, aux bornes et à la dernière clé. Aucun plafond métier de batch n'est introduit : seule la borne physique PostgreSQL de `LIMIT requested + 1` est appliquée.
|
||||||
|
|
||||||
|
La rétention physique supportée est `Full -> Archived -> Purged`, sérialisée par `FOR UPDATE`, avec outcomes `Applied`, `AlreadyAtTarget` et `ExpectedStateMismatch`. Le mode normal respecte le tombstone après purge ; `ForceRehydrate` reste explicite et atomique. `Compacted` demeure volontairement non supporté physiquement et retourne `store.postgres_retention_compaction_unsupported` plutôt que de présenter TOAST ou une compression implicite comme contrat KSP. Les erreurs backend sont réduites à des classes/phases statiques puis projetées vers des codes Store/API stables, sans URI, SQL, SQLSTATE, bind ni texte serveur.
|
||||||
|
|
||||||
|
La preuve PostgreSQL réelle a conduit à durcir l'introspection de schéma PostgreSQL 17 : canonicalisation ciblée des CHECK numériques reconstruits par le catalogue, conservation des littéraux texte, restauration des helpers de classification de schéma et distinction d'un drift d'une migration déjà enregistrée lorsque `schema_autoupdate=false`. Les ressources SQL V000/V001 et leurs checksums sont restés inchangés pendant ces corrections (`V000 d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450`, `V001 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`).
|
||||||
|
|
||||||
|
Le gate technique final `pre.011` passe audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, les tests ciblés Store/API/PostgreSQL/Config, les tests et checks façade avec `--no-default-features`, `cargo test --workspace` et les graphes Cargo. Le live `postgres_raw_transaction_live` est ensuite rejoué avec succès sur **PostgreSQL 17**, couvrant bootstrap/drift-repair, atomicité, concurrence identique/divergente, rollback sur collision et annulation, pagination/cursor, rétention/races, ForceRehydrate et réouverture durable. Aucun build Tauri supplémentaire n'est requis : `0.3.3` ne change ni resources applicatives ni packaging desktop. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. `prompts/023-V0_3_4_START_PROMPT.md` ouvre cette slice suivante sur les quatre capabilities `RawAccount*`, une migration additive au-dessus de V000/V001, puis la conformance finale des dix capabilities RAW ; l'archive historique kbot3 y reste une source de comparaison ciblée account/observation, jamais une architecture à recopier.
|
||||||
|
|
||||||
|
## 0.3.2 — Store/PostgreSQL runtime foundation — 2026-08-30
|
||||||
|
|
||||||
|
`0.3.2` introduit ensemble `ksp-store-lib` et `ksp-store-postgres-lib` comme fondation runtime/backend PostgreSQL au-dessus des contrats backend-agnostic de `ksp-store-api`, sans encore implémenter de capability RAW métier. La façade `Store` conserve un backend connu `Postgres` activé par défaut via Cargo feature, reste compilable avec `--no-default-features`, lie chaque instance à exactement un `RawNetworkId` et n'expose aucun `Pool`, `Client`, `Row`, `Statement`, driver ou SQL physique. `ksp-config-lib` possède désormais `std.store` avec trois targets nommés `devnet`, `mainnet` et `testnet`, chacun associé à un réseau explicite et à une URI PostgreSQL Secret indépendante ; Store/backend ne lisent directement ni `.env`, ni `KSP_*`, ni `PG*`, ni `.pgpass`.
|
||||||
|
|
||||||
|
Le backend de référence utilise `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2`, `tokio-postgres-rustls 0.14.0`, Rustls 0.23 avec roots système/AWS-LC et des modes TLS KSP limités à `Disabled` et `VerifyFull`. Les URI sont parsées puis normalisées sous la policy typée KSP, le pool et les deadlines connect/wait/create/recycle/shutdown sont bornés, et une ouverture réussie exige une connexion physique puis un bootstrap valide. Le moteur de migrations privé commence par `V000__bootstrap.sql`, enregistre version/nom/SHA-256 dans `ksp_store_schema_migrations`, sérialise les runners par advisory transaction lock borné et refuse checksum mismatch ou schema plus récent sans down automatique. La façade fournit également `runtime_snapshot()` et `health().await` avec une projection portable/redacted de readiness et des compteurs pool sûrs.
|
||||||
|
|
||||||
|
Les canaris de hardening verrouillent 84 exports crate-root côté façade, la frontière backend/Config, l'absence de bypass environnement, la redaction des URI/erreurs, la matrice de features et l'absence de persistence `RawTransaction`/`RawAccountState`. Le gate technique final a été rejoué après `cargo clean` : audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés de toutes les crates, `cargo test --workspace`, graphes Cargo et les trois builds Tauri Linux passent. Le smoke PostgreSQL réel passe sur **PostgreSQL 17** et prouve bootstrap initial/idempotent/concurrent, mismatch/recovery checksum, rollback transactionnel, health `Ready` et fermeture bornée ; la policy de fondation reste PostgreSQL >= 15 sans maximum KSP artificiel.
|
||||||
|
|
||||||
|
`prompts/022-V0_3_3_START_PROMPT.md` ouvre ensuite uniquement la vertical slice PostgreSQL `RawTransaction` complète sur les mêmes crates et la même fondation : six capabilities transaction/observation/rétention, atomicité, idempotence/conflit, get/list cursorisé, tombstone et `ForceRehydrate`. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. L'archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` reste obligatoire au `pre.001` de `0.3.3`, mais seulement pour réauditer l'ancien schéma/repository RAW transaction comme source d'héritage, jamais comme autorité KSP.
|
||||||
|
|
||||||
## 0.3.1 — Store API RAW foundation — 2026-08-29
|
## 0.3.1 — Store API RAW foundation — 2026-08-29
|
||||||
|
|
||||||
`0.3.1` introduit `ksp-store-api` comme contrat backend-agnostic de persistence N1 RAW, sans runtime Store ni backend physique. La release stabilise deux familles réellement convergentes : `RawTransaction` avec payload canonique opaque/versionné, identité réseau+signature et observations d’acquisition séparées, puis `RawAccountState` avec bytes complets, identité réseau+pubkey+slot+hash et observations pouvant conserver les enrichissements Yellowstone sans les confondre avec l’état canonique. `TransactionStatusObservation` reste reporté faute de convergence sémantique suffisante entre snapshot HTTP, transition WebSocket et update Yellowstone ; `logsSubscribe`, slot/root/slotsUpdates et vote restent event-only candidats, `RawBlock` reste une idée conditionnelle et Yellowstone `Entry` reste rejeté de la taxonomie active.
|
`0.3.1` introduit `ksp-store-api` comme contrat backend-agnostic de persistence N1 RAW, sans runtime Store ni backend physique. La release stabilise deux familles réellement convergentes : `RawTransaction` avec payload canonique opaque/versionné, identité réseau+signature et observations d’acquisition séparées, puis `RawAccountState` avec bytes complets, identité réseau+pubkey+slot+hash et observations pouvant conserver les enrichissements Yellowstone sans les confondre avec l’état canonique. `TransactionStatusObservation` reste reporté faute de convergence sémantique suffisante entre snapshot HTTP, transition WebSocket et update Yellowstone ; `logsSubscribe`, slot/root/slotsUpdates et vote restent event-only candidats, `RawBlock` reste une idée conditionnelle et Yellowstone `Entry` reste rejeté de la taxonomie active.
|
||||||
|
|||||||
16
Cargo.toml
16
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 331
|
# version: 363
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-wallet-lib"]
|
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.1"
|
version = "0.3.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
@@ -14,20 +14,24 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
argon2 = { version = "^0.5", default-features = false }
|
argon2 = { version = "^0.6", default-features = false }
|
||||||
base64 = { version = "^0.23" }
|
base64 = { version = "^0.23" }
|
||||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||||
chrono = { version = "^0.4", default-features = false }
|
chrono = { version = "^0.4", default-features = false }
|
||||||
|
deadpool-postgres = { version = "^0.14", default-features = false }
|
||||||
directories = { version = "^6.0" }
|
directories = { version = "^6.0" }
|
||||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||||
fs2 = { version = "^0.4" }
|
fs2 = { version = "^0.4" }
|
||||||
futures-util = { version = "^0.3", default-features = false }
|
futures-util = { version = "^0.3", default-features = false }
|
||||||
getrandom = { version = "^0.4", default-features = false }
|
getrandom = { version = "^0.4", default-features = false }
|
||||||
http = { version = "^1.5", default-features = false }
|
http = { version = "^1.5", default-features = false }
|
||||||
jsonschema = { version = "^0.51", default-features = false }
|
jsonschema = { version = "^0.52", default-features = false }
|
||||||
reqwest = { version = "^0.13", default-features = false }
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
|
rustls = { version = "^0.23", default-features = false }
|
||||||
|
rustls-native-certs = { version = "^0.8", default-features = false }
|
||||||
serde = { version = "^1.0" }
|
serde = { version = "^1.0" }
|
||||||
serde_json = { version = "^1.0" }
|
serde_json = { version = "^1.0" }
|
||||||
|
sha2 = { version = "^0.11", default-features = false }
|
||||||
solana-keypair = { version = "^3.1", default-features = false }
|
solana-keypair = { version = "^3.1", default-features = false }
|
||||||
solana-pubkey = { version = "^4.3", default-features = false }
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
tauri = { version = "^2.11" }
|
tauri = { version = "^2.11" }
|
||||||
@@ -39,6 +43,8 @@ tracing = { version = "^0.1", default-features = false }
|
|||||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||||
tracing-appender = { version = "^0.2", default-features = false }
|
tracing-appender = { version = "^0.2", default-features = false }
|
||||||
tokio = { version = "^1.53", default-features = false }
|
tokio = { version = "^1.53", default-features = false }
|
||||||
|
tokio-postgres = { version = "^0.7", default-features = false }
|
||||||
|
tokio-postgres-rustls = { version = "^0.14", default-features = false }
|
||||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||||
tonic = { version = "^0.14", default-features = false }
|
tonic = { version = "^0.14", default-features = false }
|
||||||
tonic-prost = { version = "^0.14", default-features = false }
|
tonic-prost = { version = "^0.14", default-features = false }
|
||||||
|
|||||||
11
ROADMAP.md
11
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 94 -->
|
<!-- version: 97 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
|||||||
## 0.3.x — RAW / acquisition persistée
|
## 0.3.x — RAW / acquisition persistée
|
||||||
|
|
||||||
- [X] `0.3.1` — `ksp-store-api` stable : modèles N1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
- [X] `0.3.1` — `ksp-store-api` stable : modèles N1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
||||||
- [ ] `0.3.2` — Introduire ensemble `ksp-store-lib` et `ksp-store-postgres-lib` pour la **fondation runtime/backend PostgreSQL uniquement** : façade Store, feature `postgres` par défaut, dispatch des backends compilés, Config `std.store`/secrets, connexion/pool/TLS à réauditer, bootstrap/migrations privés et health/readiness seulement si un contrat portable est réellement justifié. Aucun schéma `RawTransaction`/`RawAccountState` n’est ajouté dans cette slice.
|
- [X] `0.3.2` — `ksp-store-lib` + `ksp-store-postgres-lib` stables comme fondation runtime/backend PostgreSQL : feature `postgres` par défaut, `Store` lié à un unique `RawNetworkId`, Config `std.store` avec targets/bases `devnet`/`mainnet`/`testnet`, pool Deadpool borné, `tokio-postgres`, TLS Rustls `Disabled`/`VerifyFull`, moteur de migrations privé `V000` + SHA-256/advisory lock, health/readiness portable et close borné. Gate complet + PostgreSQL réel major 17 verts ; aucune table/capability `RawTransaction`/`RawAccountState` métier n'est encore ajoutée.
|
||||||
- [ ] `0.3.3` — Étendre le même couple `ksp-store-lib` + `ksp-store-postgres-lib` avec la vertical slice PostgreSQL `RawTransaction` complète : persistence/observation atomiques, get/list cursorisé, idempotence/conflit, rétention/tombstone/force-rehydrate, concurrence et rollback validés sur PostgreSQL réel.
|
- [X] `0.3.3` — Vertical slice PostgreSQL `RawTransaction` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : six capabilities transaction/observation/rétention, V001 physique liée à un réseau, acquisition canonical+observation atomique, idempotence/conflit, get/list keyset cursorisé, archive/purge/tombstone/ForceRehydrate, hardening des erreurs et du schéma, concurrence et rollback validés sur PostgreSQL 17.
|
||||||
- [ ] `0.3.4` — Étendre le même couple avec `RawAccountState` + observation, puis fermer la complétude/conformance RAW cross-family, les indexes/migrations physiques nécessaires et le hardening PostgreSQL final.
|
- [ ] `0.3.4` — Étendre le même couple avec `RawAccountState` + `RawAccountObservation` : quatre capabilities account, migration additive au-dessus de V000/V001, acquisition state+observation atomique, idempotence/conflit, get/list cursorisé, puis complétude des dix capabilities RAW, indexes justifiés par les queries et hardening PostgreSQL cross-family final.
|
||||||
- [ ] `0.3.5` — Étendre `ksp-interface-lib` uniquement avec les modèles passifs/events réellement partagés par les premiers consumers d’acquisition, sans dupliquer les modèles persistants de `ksp-store-api`.
|
- [ ] `0.3.5` — Étendre `ksp-interface-lib` uniquement avec les modèles passifs/events réellement partagés par les premiers consumers d’acquisition, sans dupliquer les modèles persistants de `ksp-store-api`.
|
||||||
- [ ] `0.3.6` — Introduire `ksp-job-api` et un premier job de backfill historique concret consommant `ksp-store-lib`, avec policy/batch-size/progression possédés par le job et non par Store.
|
- [ ] `0.3.6` — Introduire `ksp-job-api` et un premier job de backfill historique concret consommant `ksp-store-lib`, avec policy/batch-size/progression possédés par le job et non par Store.
|
||||||
- [ ] `0.3.7` — Introduire une application spécialisée de backfill/inspection RAW.
|
- [ ] `0.3.7` — Introduire une application spécialisée de backfill/inspection RAW.
|
||||||
@@ -113,7 +113,8 @@ RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
|||||||
- [ ] **REJET ACTUEL** — Yellowstone `Entry` : trop bas niveau et aucune destination replay/decomposition/event métier justifiant un modèle KSP n’est identifiée.
|
- [ ] **REJET ACTUEL** — Yellowstone `Entry` : trop bas niveau et aucune destination replay/decomposition/event métier justifiant un modèle KSP n’est identifiée.
|
||||||
- [ ] **TODO** — processing ledger : reprendre l’idée kbot2/kbot3 `stage + processor identity/version + input identity/hash + terminal status`, sans faire d’un `processed: bool` la preuve durable unique ; prévoir force replay/version upgrades lorsque les processors seront ouverts.
|
- [ ] **TODO** — processing ledger : reprendre l’idée kbot2/kbot3 `stage + processor identity/version + input identity/hash + terminal status`, sans faire d’un `processed: bool` la preuve durable unique ; prévoir force replay/version upgrades lorsque les processors seront ouverts.
|
||||||
- [X] lifecycle RAW logique — `RawRetentionState`, tombstone minimal, normal-skip et force-rehydrate sont stabilisés en `0.3.1` pour `RawTransaction`.
|
- [X] lifecycle RAW logique — `RawRetentionState`, tombstone minimal, normal-skip et force-rehydrate sont stabilisés en `0.3.1` pour `RawTransaction`.
|
||||||
- [ ] **TODO** — rétention physique : définir plus tard compression/archive backend, critères d’éligibilité fondés sur les preuves de processing et maintenance worker/job ; Store applique une transition demandée mais ne décide pas seul qu’un RAW peut être purgé.
|
- [X] rétention physique `RawTransaction` PostgreSQL — `0.3.3` matérialise `Full -> Archived -> Purged`, tombstone et ForceRehydrate atomiques ; `Compacted` reste explicitement unsupported tant qu’aucune représentation compactée réelle n’existe.
|
||||||
|
- [ ] **TODO** — policy de rétention/compaction : définir les critères d’éligibilité fondés sur les preuves de processing et la maintenance worker/job ; Store applique une transition demandée mais ne décide pas seul qu’un RAW peut être archivé/purgé, et la compaction physique ne sera ajoutée qu’avec un besoin réel.
|
||||||
- [X] frontière `ksp-interface-lib` / `ksp-store-api` — ownership documenté et canaris de non-duplication stabilisés en `0.3.1`; les events passifs non persistés restent Interface, les modèles persistants/replayables restent Store API.
|
- [X] frontière `ksp-interface-lib` / `ksp-store-api` — ownership documenté et canaris de non-duplication stabilisés en `0.3.1`; les events passifs non persistés restent Interface, les modèles persistants/replayables restent Store API.
|
||||||
- [ ] **IDEA** — réauditer la structure de processing/decode/materialization historique kbot2/kbot3 lors de l’ouverture de N2/N3 ; conserver l’isolation instruction/CPI et les statuts terminal/versionnés, sans reprendre automatiquement le schéma SQL historique.
|
- [ ] **IDEA** — réauditer la structure de processing/decode/materialization historique kbot2/kbot3 lors de l’ouverture de N2/N3 ; conserver l’isolation instruction/CPI et les statuts terminal/versionnés, sans reprendre automatiquement le schéma SQL historique.
|
||||||
|
|
||||||
|
|||||||
81
config/examples/std.store.example.json
Normal file
81
config/examples/std.store.example.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"default_profile": "devnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"network": "devnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"network": "mainnet-beta",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"network": "testnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
277
config/schemas/std.store.schema.json
Normal file
277
config/schemas/std.store.schema.json
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.store:v2",
|
||||||
|
"title": "KSP standard Store configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"enum": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 2
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profileV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"networkId": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128,
|
||||||
|
"pattern": "^[A-Za-z0-9_.:-]+$"
|
||||||
|
},
|
||||||
|
"duration100To60000": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 60000
|
||||||
|
},
|
||||||
|
"profileV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"network",
|
||||||
|
"backend",
|
||||||
|
"postgres"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"$ref": "#/$defs/networkId"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"const": "postgres"
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"$ref": "#/$defs/postgresV1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profileV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"network",
|
||||||
|
"backend",
|
||||||
|
"postgres"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"$ref": "#/$defs/networkId"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"const": "postgres"
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"$ref": "#/$defs/postgresV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgresV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"connection_uri",
|
||||||
|
"pool",
|
||||||
|
"tls",
|
||||||
|
"bootstrap",
|
||||||
|
"shutdown_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"connection_uri": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"$ref": "#/$defs/pool"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"$ref": "#/$defs/tls"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"$ref": "#/$defs/bootstrapV1"
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgresV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"connection_uri",
|
||||||
|
"pool",
|
||||||
|
"tls",
|
||||||
|
"bootstrap",
|
||||||
|
"shutdown_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"connection_uri": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"$ref": "#/$defs/pool"
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"$ref": "#/$defs/tls"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"$ref": "#/$defs/bootstrapV2"
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"max_connections",
|
||||||
|
"connect_timeout_ms",
|
||||||
|
"wait_timeout_ms",
|
||||||
|
"create_timeout_ms",
|
||||||
|
"recycle_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"max_connections": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 64
|
||||||
|
},
|
||||||
|
"connect_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
},
|
||||||
|
"wait_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
},
|
||||||
|
"create_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
},
|
||||||
|
"recycle_timeout_ms": {
|
||||||
|
"$ref": "#/$defs/duration100To60000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"verify_full"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bootstrapV1": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"auto_migrate",
|
||||||
|
"migration_timeout_ms",
|
||||||
|
"migration_lock_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"auto_migrate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"migration_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 300000
|
||||||
|
},
|
||||||
|
"migration_lock_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bootstrapV2": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_autocreate",
|
||||||
|
"schema_autoupdate",
|
||||||
|
"migration_timeout_ms",
|
||||||
|
"migration_lock_timeout_ms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_autocreate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"schema_autoupdate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"migration_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 300000
|
||||||
|
},
|
||||||
|
"migration_lock_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
config/std.store.json
Normal file
81
config/std.store.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"default_profile": "devnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"network": "devnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"network": "mainnet-beta",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"network": "testnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -56,11 +56,13 @@
|
|||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
"../../config/std.transport.json": "config/std.transport.json",
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||||
// version: 10
|
// version: 11
|
||||||
|
|
||||||
//! Desktop build/shell contract audits for Config Desk.
|
//! Desktop build/shell contract audits for Config Desk.
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab
|
|||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 11);
|
assert_eq!(resources.len(), 13);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||||
@@ -124,6 +124,11 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab
|
|||||||
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/schemas/std.store.schema.json"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||||
// version: 7
|
// version: 8
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_inventory_exposes_registered_profile_documents() {
|
fn profile_inventory_exposes_registered_profile_documents() {
|
||||||
@@ -15,6 +15,9 @@ fn profile_inventory_exposes_registered_profile_documents() {
|
|||||||
assert!(inventory.iter().any(|document| -> bool {
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
||||||
}));
|
}));
|
||||||
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
|
return document.file_id == ksp_config_lib::FILE_ID_STD_STORE;
|
||||||
|
}));
|
||||||
assert!(inventory.iter().any(|document| -> bool {
|
assert!(inventory.iter().any(|document| -> bool {
|
||||||
return document.file_id == ksp_config_lib::FILE_ID_STD_WALLET;
|
return document.file_id == ksp_config_lib::FILE_ID_STD_WALLET;
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -56,11 +56,13 @@
|
|||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
"../../config/std.transport.json": "config/std.transport.json",
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
||||||
// version: 9
|
// version: 10
|
||||||
|
|
||||||
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
||||||
|
|
||||||
@@ -85,19 +85,21 @@ fn pre_002_package_is_mixed_lib_bin_and_frontend_is_scaffold_only() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_003_packaging_contains_current_eleven_config_resources() {
|
fn pre_004_packaging_contains_current_thirteen_config_resources() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some());
|
assert!(resources.is_some());
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 11);
|
assert_eq!(resources.len(), 13);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||||
);
|
);
|
||||||
assert!(resources.contains_key("../../config/std.offchain_transport.json"));
|
assert!(resources.contains_key("../../config/std.offchain_transport.json"));
|
||||||
assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json"));
|
assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json"));
|
||||||
|
assert!(resources.contains_key("../../config/std.store.json"));
|
||||||
|
assert!(resources.contains_key("../../config/schemas/std.store.schema.json"));
|
||||||
}
|
}
|
||||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||||
|
|||||||
@@ -56,11 +56,13 @@
|
|||||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||||
"../../config/std.logging.json": "config/std.logging.json",
|
"../../config/std.logging.json": "config/std.logging.json",
|
||||||
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
"../../config/std.offchain_transport.json": "config/std.offchain_transport.json",
|
||||||
|
"../../config/std.store.json": "config/std.store.json",
|
||||||
"../../config/std.transport.json": "config/std.transport.json",
|
"../../config/std.transport.json": "config/std.transport.json",
|
||||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||||
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
"../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json",
|
||||||
|
"../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json",
|
||||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs
|
||||||
// version: 30
|
// version: 31
|
||||||
|
|
||||||
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
//! Desktop build, shell and Config-status contract audits for Wallet Desk.
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
|||||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||||
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
||||||
if let std::option::Option::Some(resources) = resources {
|
if let std::option::Option::Some(resources) = resources {
|
||||||
assert_eq!(resources.len(), 11);
|
assert_eq!(resources.len(), 13);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"),
|
||||||
@@ -449,6 +449,11 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi
|
|||||||
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/std.offchain_transport.json"),
|
std::option::Option::Some("config/std.offchain_transport.json"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),);
|
||||||
|
assert_eq!(
|
||||||
|
resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some("config/schemas/std.store.schema.json"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str),
|
||||||
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
|
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
|
||||||
// version: 9
|
// version: 10
|
||||||
|
|
||||||
//! Release-wide deterministic compliance canaries for Wallet Desk.
|
//! Release-wide deterministic compliance canaries for Wallet Desk.
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() {
|
|||||||
std::option::Option::Some(value) => value,
|
std::option::Option::Some(value) => value,
|
||||||
std::option::Option::None => return,
|
std::option::Option::None => return,
|
||||||
};
|
};
|
||||||
assert_eq!(resources.len(), 11);
|
assert_eq!(resources.len(), 13);
|
||||||
for (source, destination) in resources {
|
for (source, destination) in resources {
|
||||||
let destination = destination.as_str();
|
let destination = destination.as_str();
|
||||||
assert!(destination.is_some(), "resource destination must be textual");
|
assert!(destination.is_some(), "resource destination must be textual");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-config-lib/Cargo.toml
|
# file: crates/ksp-config-lib/Cargo.toml
|
||||||
# version: 9
|
# version: 10
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-config-lib"
|
name = "ksp-config-lib"
|
||||||
@@ -14,6 +14,7 @@ ksp-core-lib = { path = "../ksp-core-lib" }
|
|||||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
|
ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }
|
||||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/README.md -->
|
<!-- file: crates/ksp-config-lib/README.md -->
|
||||||
<!-- version: 10 -->
|
<!-- version: 11 -->
|
||||||
|
|
||||||
# ksp-config-lib
|
# ksp-config-lib
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
|||||||
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
- l'adapter du document Transport V1/V2/V3 vers `HttpTransportSettings`, `WsTransportSettings` et, en V3, `YellowstoneGrpcTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
- l'adapter du document Transport V1/V2/V3 vers `HttpTransportSettings`, `WsTransportSettings` et, en V3, `YellowstoneGrpcTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||||
- l'adapter de `cfg.std.offchain_transport` vers `ksp_offchain_transport_lib::MarketPriceService`, avec contrôle de provenance des credentials/public fields et sans rendre les limites provider configurables ;
|
- l'adapter de `cfg.std.offchain_transport` vers `ksp_offchain_transport_lib::MarketPriceService`, avec contrôle de provenance des credentials/public fields et sans rendre les limites provider configurables ;
|
||||||
|
- l'adapter de `cfg.std.store` vers `ksp_store_lib::StoreSettings`, avec sélection d'un target nommé, réseau explicite et URI PostgreSQL à provenance `Secret` ;
|
||||||
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||||
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
||||||
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
||||||
@@ -34,14 +35,17 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
|||||||
Le registre par défaut connaît :
|
Le registre par défaut connaît :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
|
cfg.composite.ksp-app-solprices-desk -> config/composite.ksp-app-solprices-desk.json
|
||||||
|
cfg.composite.ksp-app-wallet-desk -> config/composite.ksp-app-wallet-desk.json
|
||||||
cfg.std.logging -> config/std.logging.json
|
cfg.std.logging -> config/std.logging.json
|
||||||
cfg.std.offchain_transport -> config/std.offchain_transport.json
|
cfg.std.offchain_transport -> config/std.offchain_transport.json
|
||||||
|
cfg.std.store -> config/std.store.json
|
||||||
cfg.std.transport -> config/std.transport.json
|
cfg.std.transport -> config/std.transport.json
|
||||||
cfg.std.wallet -> config/std.wallet.json
|
cfg.std.wallet -> config/std.wallet.json
|
||||||
schema.composite -> config/schemas/composite.schema.json
|
schema.composite -> config/schemas/composite.schema.json
|
||||||
schema.std.logging -> config/schemas/std.logging.schema.json
|
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||||
schema.std.offchain_transport -> config/schemas/std.offchain_transport.schema.json
|
schema.std.offchain_transport -> config/schemas/std.offchain_transport.schema.json
|
||||||
|
schema.std.store -> config/schemas/std.store.schema.json
|
||||||
schema.std.transport -> config/schemas/std.transport.schema.json
|
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||||
schema.std.wallet -> config/schemas/std.wallet.schema.json
|
schema.std.wallet -> config/schemas/std.wallet.schema.json
|
||||||
```
|
```
|
||||||
@@ -50,7 +54,7 @@ schema.std.wallet -> config/schemas/std.wallet.schema.json
|
|||||||
|
|
||||||
`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté.
|
`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté.
|
||||||
|
|
||||||
`config/examples/composite.example.json` conserve l’exemple générique. `config/composite.ksp-app-wallet-desk.json` est le premier composite runtime concret : il sélectionne Logging, Transport et Wallet par `file_id`, sans dépendre de leurs filenames physiques.
|
`config/examples/composite.example.json` conserve l’exemple générique. Les composites runtime committed restent possédés par Config : `config/composite.ksp-app-solprices-desk.json` sélectionne Logging + Off-chain Transport, tandis que `config/composite.ksp-app-wallet-desk.json` sélectionne Logging + Off-chain Transport + On-chain Transport + Wallet. Tous référencent leurs documents par `file_id`, sans dépendre de filenames physiques.
|
||||||
|
|
||||||
Le fichier local d'environnement est :
|
Le fichier local d'environnement est :
|
||||||
|
|
||||||
@@ -68,11 +72,11 @@ Les autres crates et applications KSP ne doivent pas :
|
|||||||
- parser ou écrire directement `.env` ;
|
- parser ou écrire directement `.env` ;
|
||||||
- ouvrir directement les documents Config connus par leur filename physique ;
|
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||||
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
||||||
- reconstruire elles-mêmes la configuration Logging, On-chain Transport, Off-chain Transport ou Wallet depuis le JSON.
|
- reconstruire elles-mêmes la configuration Logging, On-chain Transport, Off-chain Transport, Store ou Wallet depuis le JSON.
|
||||||
|
|
||||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, de `ksp-onchain-transport-lib` pour construire le contrat runtime On-chain Transport et de `ksp-offchain-transport-lib` pour construire le service market-price dans la direction Config -> Transport. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, de `ksp-onchain-transport-lib` pour construire le contrat runtime On-chain Transport et de `ksp-offchain-transport-lib` pour construire le service market-price dans la direction Config -> Transport et de `ksp-store-lib` avec `default-features = false` pour construire les settings Store dans la direction Config -> Store sans forcer un backend physique. Le document Wallet reste un contrat de chemins/profils Config et n’introduit aucune dépendance Config -> `ksp-wallet-lib`.
|
||||||
|
|
||||||
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib`, `ksp-onchain-transport-lib` et `ksp-offchain-transport-lib` ne dépendent pas de Config.
|
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib`, `ksp-onchain-transport-lib`, `ksp-offchain-transport-lib` et les crates Store ne dépendent pas de Config.
|
||||||
|
|
||||||
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
|
|||||||
|
|
||||||
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||||
|
|
||||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.offchain_transport` exige une provenance `Secret` pour les API keys effectives et une provenance `Public` pour la paire DexScreener lorsqu'elle vient de l'environnement ; il ne permet ni URL provider arbitraire ni override de rate limit. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport accepte les valeurs secrètes pour les URLs HTTP/WebSocket et, en V3, pour `grpc_endpoints[].secret_metadata[]` : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted. Les metadata gRPC publiques et secrètes sont séparées et leur provenance Config est contrôlée avant mapping. `std.offchain_transport` exige une provenance `Secret` pour les API keys effectives et une provenance `Public` pour la paire DexScreener lorsqu'elle vient de l'environnement ; il ne permet ni URL provider arbitraire ni override de rate limit. `std.store` exige une provenance `Secret` pour chaque URI PostgreSQL effective et conserve des targets réseau-spécifiques indépendants (`devnet`, `mainnet`, `testnet`) sans exposer l'URI dans les projections sûres. `std.wallet` refuse également toute sensibilité `Secret` pour `wallets_directory`/`wallets_subdirectory`; les passwords Wallet restent un autre flux Config et ne sont jamais stockés dans ce JSON.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -94,6 +98,8 @@ Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configur
|
|||||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
|
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard Transport V3 HTTP + WebSocket + Yellowstone gRPC, avec lecture backward des V1/V2 ;
|
||||||
- [`../../config/std.offchain_transport.json`](../../config/std.offchain_transport.json) — document standard Off-chain Transport V1, actuellement limité au domaine `market_price` SOL/USD ;
|
- [`../../config/std.offchain_transport.json`](../../config/std.offchain_transport.json) — document standard Off-chain Transport V1, actuellement limité au domaine `market_price` SOL/USD ;
|
||||||
|
- [`../../config/std.store.json`](../../config/std.store.json) — targets Store PostgreSQL Devnet/Mainnet/Testnet et settings runtime bornés ;
|
||||||
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
|
- [`../../config/std.wallet.json`](../../config/std.wallet.json) — racine Wallet globale et sous-répertoire optionnel par profil ;
|
||||||
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Transport/Wallet de Wallet Desk ;
|
- [`../../config/composite.ksp-app-solprices-desk.json`](../../config/composite.ksp-app-solprices-desk.json) — composition Logging/Off-chain Transport de SOL Prices Desk ;
|
||||||
|
- [`../../config/composite.ksp-app-wallet-desk.json`](../../config/composite.ksp-app-wallet-desk.json) — composition Logging/Off-chain Transport/On-chain Transport/Wallet de Wallet Desk ;
|
||||||
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||||
<!-- version: 13 -->
|
<!-- version: 14 -->
|
||||||
|
|
||||||
# Utilisation de ksp-config-lib
|
# Utilisation de ksp-config-lib
|
||||||
|
|
||||||
@@ -28,8 +28,11 @@ Les arguments compris par Config sont :
|
|||||||
```text
|
```text
|
||||||
--cfgpath=/path/to/config
|
--cfgpath=/path/to/config
|
||||||
--schemapath=/path/to/schemas
|
--schemapath=/path/to/schemas
|
||||||
|
--filemap=cfg.composite.ksp-app-solprices-desk=my-solprices-desk.json
|
||||||
--filemap=cfg.composite.ksp-app-wallet-desk=my-wallet-desk.json
|
--filemap=cfg.composite.ksp-app-wallet-desk=my-wallet-desk.json
|
||||||
--filemap=cfg.std.logging=my-logging.json
|
--filemap=cfg.std.logging=my-logging.json
|
||||||
|
--filemap=cfg.std.offchain_transport=my-offchain-transport.json
|
||||||
|
--filemap=cfg.std.store=my-store.json
|
||||||
--filemap=cfg.std.transport=my-transport.json
|
--filemap=cfg.std.transport=my-transport.json
|
||||||
--filemap=cfg.std.wallet=my-wallet.json
|
--filemap=cfg.std.wallet=my-wallet.json
|
||||||
```
|
```
|
||||||
@@ -212,7 +215,56 @@ Config ne permet pas de fournir `base_url`, `endpoint_url`, `rate_limit` ou `req
|
|||||||
ksp-config-lib -> ksp-offchain-transport-lib
|
ksp-config-lib -> ksp-offchain-transport-lib
|
||||||
```
|
```
|
||||||
|
|
||||||
Off-chain Transport ne lit ni `.env`, ni `KSP_*`, ni les documents Config. Une application telle que la future `ksp-app-solprices-desk` peut recevoir le service déjà composé puis utiliser uniquement `registry()`, `refresh`, `refresh_many` et `refresh_all`.
|
Off-chain Transport ne lit ni `.env`, ni `KSP_*`, ni les documents Config. `ksp-app-solprices-desk` reçoit le service déjà composé puis utilise uniquement la surface provider-neutral `registry()`, `refresh`, `refresh_many` et `refresh_all`.
|
||||||
|
|
||||||
|
### 4.4 Construire le Store depuis Config
|
||||||
|
|
||||||
|
`cfg.std.store` définit des targets nommés. Chaque target sélectionne exactement un réseau logique, un backend et une URI PostgreSQL distincte. Config résout les secrets puis construit le contrat backend-neutral `ksp_store_lib::StoreSettings` sans activer la feature PostgreSQL du consumer :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let store_config = match engine.load_resolved_store_config(
|
||||||
|
std::option::Option::Some("devnet"),
|
||||||
|
&environment,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_id = store_config.target_id();
|
||||||
|
let network = store_config.settings().network();
|
||||||
|
let _ = (target_id, network);
|
||||||
|
|
||||||
|
let store_settings = store_config.into_settings();
|
||||||
|
let store = match ksp_store_lib::Store::open(store_settings).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let _health = store.health().await;
|
||||||
|
let closed = store.close().await;
|
||||||
|
if let std::result::Result::Err(error) = closed {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Targets committed :
|
||||||
|
|
||||||
|
```text
|
||||||
|
devnet -> network devnet -> KSP_SECRET_STORE_DEVNET_POSTGRES_URI
|
||||||
|
mainnet -> network mainnet-beta -> KSP_SECRET_STORE_MAINNET_POSTGRES_URI
|
||||||
|
testnet -> network testnet -> KSP_SECRET_STORE_TESTNET_POSTGRES_URI
|
||||||
|
```
|
||||||
|
|
||||||
|
`default_profile = "devnet"` choisit un seul target. La sélection d'un autre target se fait par le `profile_id` explicite ; `ksp-store-lib` ne multiplexe pas plusieurs bases ou réseaux dans une même instance.
|
||||||
|
|
||||||
|
Chaque `connection_uri` doit provenir d'un placeholder `KSP_SECRET_*`/`KSPB_SECRET_*`. Une URI littérale ou issue d'une variable non secrète est rejetée par l'adapter effectif. La valeur réelle est transmise au runtime Store, mais `ResolvedStoreConfig`, `StoreSettings` et les projections sûres ne l'affichent pas.
|
||||||
|
|
||||||
|
La direction de dépendance reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-store-lib (default-features = false)
|
||||||
|
ksp-store-lib -X-> ksp-config-lib
|
||||||
|
ksp-store-postgres-lib -X-> ksp-config-lib
|
||||||
|
```
|
||||||
|
|
||||||
## 5. Profils et composites
|
## 5. Profils et composites
|
||||||
|
|
||||||
@@ -237,7 +289,7 @@ let component = match composite.component("wallet") {
|
|||||||
let wallet = engine.resolve_wallet_config_profile(component.resolved(), &environment);
|
let wallet = engine.resolve_wallet_config_profile(component.resolved(), &environment);
|
||||||
```
|
```
|
||||||
|
|
||||||
La même forme existe pour Logging via `resolve_logging_config_profile`. Le composite concret `cfg.composite.ksp-app-wallet-desk` référence actuellement `logging`, `transport` et `wallet`; Wallet Desk valide ces trois frontières au bootstrap.
|
La même forme existe pour Logging via `resolve_logging_config_profile`. Le composite `cfg.composite.ksp-app-solprices-desk` référence `logging` et `offchain_transport`. Le composite `cfg.composite.ksp-app-wallet-desk` référence `logging`, `offchain_transport`, `transport` et `wallet`; chaque application valide ses frontières de composition au bootstrap.
|
||||||
|
|
||||||
## 6. Management de `std.logging.json`
|
## 6. Management de `std.logging.json`
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/lib.rs
|
// file: crates/ksp-config-lib/src/lib.rs
|
||||||
// version: 20
|
// version: 21
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||||
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||||
//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC) and Wallet documents map explicitly to their runtime consumer
|
//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC), Store and Wallet documents map explicitly to their runtime consumer
|
||||||
//! contracts, while the management surface provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic
|
//! contracts, while the management surface provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic
|
||||||
//! JSON/`.env` persistence.
|
//! JSON/`.env` persistence.
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ mod persistence;
|
|||||||
mod profile;
|
mod profile;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod sensitivity;
|
mod sensitivity;
|
||||||
|
mod store;
|
||||||
mod transport;
|
mod transport;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
|
||||||
@@ -164,6 +165,10 @@ pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
|||||||
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME;
|
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME;
|
||||||
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||||
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME;
|
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME;
|
||||||
|
/// Default physical filename for the standard Store configuration document.
|
||||||
|
pub use self::registry::DEFAULT_STD_STORE_FILENAME;
|
||||||
|
/// Default physical filename for the standard Store JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_STD_STORE_SCHEMA_FILENAME;
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
@@ -182,6 +187,8 @@ pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
|||||||
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||||
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT;
|
pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT;
|
||||||
|
/// Logical file identifier for the standard Store JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_STD_STORE;
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||||
@@ -190,6 +197,8 @@ pub use self::registry::FILE_ID_SCHEMA_STD_WALLET;
|
|||||||
pub use self::registry::FILE_ID_STD_LOGGING;
|
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||||
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||||
pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT;
|
pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT;
|
||||||
|
/// Logical file identifier for the standard Store configuration document.
|
||||||
|
pub use self::registry::FILE_ID_STD_STORE;
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||||
/// Logical file identifier for the standard Wallet configuration document.
|
/// Logical file identifier for the standard Wallet configuration document.
|
||||||
@@ -204,6 +213,8 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
|||||||
pub use self::sensitivity::ResolvedConfigJson;
|
pub use self::sensitivity::ResolvedConfigJson;
|
||||||
/// One resolved Config string preserving real/safe representations and provenance.
|
/// One resolved Config string preserving real/safe representations and provenance.
|
||||||
pub use self::sensitivity::ResolvedConfigText;
|
pub use self::sensitivity::ResolvedConfigText;
|
||||||
|
/// Effective standard Store configuration mapped to backend-neutral Store settings.
|
||||||
|
pub use self::store::ResolvedStoreConfig;
|
||||||
/// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
/// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings.
|
||||||
pub use self::transport::ResolvedTransportConfig;
|
pub use self::transport::ResolvedTransportConfig;
|
||||||
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/src/registry.rs
|
// file: crates/ksp-config-lib/src/registry.rs
|
||||||
// version: 12
|
// version: 13
|
||||||
|
|
||||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||||
pub const ARG_FILE_MAP: &str = "--filemap";
|
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||||
@@ -17,6 +17,10 @@ pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
|||||||
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json";
|
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json";
|
||||||
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||||
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json";
|
pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json";
|
||||||
|
/// Default physical filename for the standard Store configuration document.
|
||||||
|
pub const DEFAULT_STD_STORE_FILENAME: &str = "std.store.json";
|
||||||
|
/// Default physical filename for the standard Store JSON Schema document.
|
||||||
|
pub const DEFAULT_STD_STORE_SCHEMA_FILENAME: &str = "std.store.schema.json";
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||||
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
@@ -35,6 +39,8 @@ pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
|||||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||||
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport";
|
pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport";
|
||||||
|
/// Logical file identifier for the standard Store JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_STD_STORE: &str = "schema.std.store";
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document.
|
||||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||||
/// Logical file identifier for the standard Wallet JSON Schema document.
|
/// Logical file identifier for the standard Wallet JSON Schema document.
|
||||||
@@ -43,6 +49,8 @@ pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet";
|
|||||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||||
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||||
pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport";
|
pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport";
|
||||||
|
/// Logical file identifier for the standard Store configuration document.
|
||||||
|
pub const FILE_ID_STD_STORE: &str = "cfg.std.store";
|
||||||
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
/// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document.
|
||||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||||
/// Logical file identifier for the standard Wallet configuration document.
|
/// Logical file identifier for the standard Wallet configuration document.
|
||||||
@@ -214,6 +222,22 @@ impl ConfigFileRegistry {
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
|
let store = ConfigFileDescriptor::new(
|
||||||
|
FILE_ID_STD_STORE,
|
||||||
|
ConfigFileKind::Config,
|
||||||
|
DEFAULT_STD_STORE_FILENAME,
|
||||||
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_STORE),
|
||||||
|
);
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let store_schema =
|
||||||
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_STORE, ConfigFileKind::Schema, DEFAULT_STD_STORE_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
|
let store_schema = match store_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
let transport = ConfigFileDescriptor::new(
|
let transport = ConfigFileDescriptor::new(
|
||||||
FILE_ID_STD_TRANSPORT,
|
FILE_ID_STD_TRANSPORT,
|
||||||
ConfigFileKind::Config,
|
ConfigFileKind::Config,
|
||||||
@@ -254,6 +278,8 @@ impl ConfigFileRegistry {
|
|||||||
logging_schema,
|
logging_schema,
|
||||||
offchain_transport,
|
offchain_transport,
|
||||||
offchain_transport_schema,
|
offchain_transport_schema,
|
||||||
|
store,
|
||||||
|
store_schema,
|
||||||
transport,
|
transport,
|
||||||
transport_schema,
|
transport_schema,
|
||||||
wallet,
|
wallet,
|
||||||
|
|||||||
305
crates/ksp-config-lib/src/store.rs
Normal file
305
crates/ksp-config-lib/src/store.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/store.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
/// Effective standard Store configuration mapped to `ksp_store_lib::StoreSettings`.
|
||||||
|
pub struct ResolvedStoreConfig {
|
||||||
|
effective: crate::ResolvedConfigJson,
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
settings: ksp_store_lib::StoreSettings,
|
||||||
|
source_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedStoreConfig {
|
||||||
|
/// Returns the detailed environment-resolved Config view.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical Config file identifier used by this runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected standard Store profile identifier.
|
||||||
|
///
|
||||||
|
/// For `std.store`, the profile identifier is also the stable named Store target identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected named Store target identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn target_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that selected the standard Store profile.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrows the backend-neutral Store settings without exposing the connection URI.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &ksp_store_lib::StoreSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the resolved Config and returns the Store-owned runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_settings(self) -> ksp_store_lib::StoreSettings {
|
||||||
|
return self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical source Config document path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn source_path(&self) -> &std::path::Path {
|
||||||
|
return self.source_path.as_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedStoreConfig {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedStoreConfig")
|
||||||
|
.field("effective", &self.effective)
|
||||||
|
.field("file_id", &self.file_id)
|
||||||
|
.field("profile_id", &self.profile_id)
|
||||||
|
.field("selection_source", &self.selection_source)
|
||||||
|
.field("settings", &self.settings)
|
||||||
|
.field("source_path", &self.source_path)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads `std.store`, resolves one profile/environment and maps it to backend-neutral Store settings.
|
||||||
|
pub fn load_resolved_store_config(
|
||||||
|
&self,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_STORE);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return resolve_store_profile(&profile, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps an already resolved `cfg.std.store` profile while preserving its selection provenance.
|
||||||
|
pub fn resolve_store_config_profile(
|
||||||
|
&self,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||||
|
if profile.file_id().as_str() != crate::FILE_ID_STD_STORE {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Store document"));
|
||||||
|
}
|
||||||
|
let descriptor = self.registry().descriptor(profile.file_id());
|
||||||
|
if let std::result::Result::Err(error) = descriptor {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return resolve_store_profile(profile, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveStoreSource {
|
||||||
|
backend: String,
|
||||||
|
format_version: u32,
|
||||||
|
network: String,
|
||||||
|
postgres: EffectivePostgresSource,
|
||||||
|
profile_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectivePostgresSource {
|
||||||
|
bootstrap: EffectivePostgresBootstrapSource,
|
||||||
|
connection_uri: String,
|
||||||
|
pool: EffectivePostgresPoolSource,
|
||||||
|
shutdown_timeout_ms: u64,
|
||||||
|
tls: EffectivePostgresTlsSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectivePostgresPoolSource {
|
||||||
|
connect_timeout_ms: u64,
|
||||||
|
create_timeout_ms: u64,
|
||||||
|
max_connections: u32,
|
||||||
|
recycle_timeout_ms: u64,
|
||||||
|
wait_timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectivePostgresTlsSource {
|
||||||
|
mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectivePostgresBootstrapSource {
|
||||||
|
auto_migrate: std::option::Option<bool>,
|
||||||
|
migration_lock_timeout_ms: u64,
|
||||||
|
migration_timeout_ms: u64,
|
||||||
|
schema_autocreate: std::option::Option<bool>,
|
||||||
|
schema_autoupdate: std::option::Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_store_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Store Config profile");
|
||||||
|
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let provenance = validate_connection_uri_provenance(&effective, profile);
|
||||||
|
if let std::result::Result::Err(error) = provenance {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let source = serde_json::from_value::<EffectiveStoreSource>(effective.value().clone());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Store Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if source.format_version != 1 && source.format_version != 2 {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported"));
|
||||||
|
}
|
||||||
|
if source.profile_id != profile.profile_id() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Store profile_id does not match the selected profile"));
|
||||||
|
}
|
||||||
|
if source.backend != "postgres" {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Store backend is unsupported").with_context("backend", source.backend));
|
||||||
|
}
|
||||||
|
let tls_mode = match source.postgres.tls.mode.as_str() {
|
||||||
|
"disabled" => ksp_store_lib::PostgresTlsMode::Disabled,
|
||||||
|
"verify_full" => ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||||
|
_ => return std::result::Result::Err(effective_error(profile, "effective Store PostgreSQL TLS mode is unsupported")),
|
||||||
|
};
|
||||||
|
let pool = ksp_store_lib::PostgresPoolSettings::new(
|
||||||
|
source.postgres.pool.max_connections,
|
||||||
|
std::time::Duration::from_millis(source.postgres.pool.connect_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(source.postgres.pool.wait_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(source.postgres.pool.create_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(source.postgres.pool.recycle_timeout_ms),
|
||||||
|
);
|
||||||
|
let bootstrap = match source.format_version {
|
||||||
|
1 => {
|
||||||
|
let auto_migrate =
|
||||||
|
match (source.postgres.bootstrap.auto_migrate, source.postgres.bootstrap.schema_autocreate, source.postgres.bootstrap.schema_autoupdate) {
|
||||||
|
(std::option::Option::Some(value), std::option::Option::None, std::option::Option::None) => value,
|
||||||
|
_ => return std::result::Result::Err(effective_error(profile, "effective Store V1 bootstrap policy is invalid")),
|
||||||
|
};
|
||||||
|
ksp_store_lib::PostgresBootstrapSettings::new(
|
||||||
|
auto_migrate,
|
||||||
|
std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
2 => {
|
||||||
|
let (schema_autocreate, schema_autoupdate) =
|
||||||
|
match (source.postgres.bootstrap.auto_migrate, source.postgres.bootstrap.schema_autocreate, source.postgres.bootstrap.schema_autoupdate) {
|
||||||
|
(std::option::Option::None, std::option::Option::Some(autocreate), std::option::Option::Some(autoupdate)) => (autocreate, autoupdate),
|
||||||
|
_ => return std::result::Result::Err(effective_error(profile, "effective Store V2 bootstrap policy is invalid")),
|
||||||
|
};
|
||||||
|
ksp_store_lib::PostgresBootstrapSettings::with_schema_policy(
|
||||||
|
schema_autocreate,
|
||||||
|
schema_autoupdate,
|
||||||
|
std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
_ => return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported")),
|
||||||
|
};
|
||||||
|
let network = ksp_store_lib::RawNetworkId::new(source.network);
|
||||||
|
let network = match network {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(effective_error(profile, "effective Store network identifier is invalid")),
|
||||||
|
};
|
||||||
|
let postgres = ksp_store_lib::PostgresStoreSettings::new(source.postgres.connection_uri, pool, tls_mode, bootstrap);
|
||||||
|
let settings = ksp_store_lib::StoreSettings::new(
|
||||||
|
network,
|
||||||
|
ksp_store_lib::StoreBackendSettings::Postgres(postgres),
|
||||||
|
std::time::Duration::from_millis(source.postgres.shutdown_timeout_ms),
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(store_contract_error(profile, &error));
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
profile_id = profile.profile_id(),
|
||||||
|
network = settings.network().as_str(),
|
||||||
|
backend = settings.backend_kind().code(),
|
||||||
|
"mapped standard Store Config to Store settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(ResolvedStoreConfig {
|
||||||
|
effective,
|
||||||
|
file_id: profile.file_id().clone(),
|
||||||
|
profile_id: profile.profile_id().to_owned(),
|
||||||
|
selection_source: profile.selection_source(),
|
||||||
|
settings,
|
||||||
|
source_path: profile.path().to_path_buf(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_connection_uri_provenance(effective: &crate::ResolvedConfigJson, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<()> {
|
||||||
|
let provenance = match effective.provenance_at("/postgres/connection_uri") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI provenance is unavailable")),
|
||||||
|
};
|
||||||
|
let mut has_secret_environment = false;
|
||||||
|
for item in provenance {
|
||||||
|
let variable_name = match item.variable_name() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||||
|
let sensitivity = match sensitivity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !sensitivity.is_secret() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI may reference only secret environment variables"));
|
||||||
|
}
|
||||||
|
has_secret_environment = true;
|
||||||
|
}
|
||||||
|
if !has_secret_environment {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI requires secret environment provenance"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_contract_error(profile: &crate::ResolvedConfigProfile, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||||
|
return effective_error(profile, "effective Store settings fail the Store runtime contract")
|
||||||
|
.with_context("store_error_domain", error.code().domain())
|
||||||
|
.with_context("store_error_code", error.code().code());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||||
|
.with_context("file_id", profile.file_id().as_str())
|
||||||
|
.with_context("profile_id", profile.profile_id())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/store.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||||
// version: 8
|
// version: 9
|
||||||
|
|
||||||
//! Workspace ownership audits for KSP application configuration boundaries.
|
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||||
|
|
||||||
@@ -280,6 +280,49 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_config_adapter_does_not_force_backend_feature_or_reverse_dependency() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let config_manifest_path = root.join("crates/ksp-config-lib/Cargo.toml");
|
||||||
|
let config_manifest = std::fs::read_to_string(config_manifest_path.as_path());
|
||||||
|
assert!(config_manifest.is_ok(), "unable to read {}", config_manifest_path.display());
|
||||||
|
if let std::result::Result::Ok(config_manifest) = config_manifest {
|
||||||
|
assert!(
|
||||||
|
config_manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\", default-features = false }"),
|
||||||
|
"Config -> Store dependency must not force a physical backend feature"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for crate_name in ["ksp-store-lib", "ksp-store-postgres-lib"] {
|
||||||
|
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||||
|
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
||||||
|
if let std::result::Result::Ok(manifest) = manifest {
|
||||||
|
assert!(!manifest.contains("ksp-config-lib"), "{} must not depend back on Config", manifest_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_runtime_sources_do_not_bypass_config_for_environment_or_libpq_files() {
|
||||||
|
let root = workspace_root();
|
||||||
|
for crate_name in ["ksp-store-lib", "ksp-store-postgres-lib"] {
|
||||||
|
let source_root = root.join("crates").join(crate_name).join("src");
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(source_root.as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
for forbidden in ["std::env", "dotenv", "KSP_", "KSPB_", "\"PG", ".pgpass"] {
|
||||||
|
assert!(!source.contains(forbidden), "{} bypasses Config through forbidden Store environment/libpq token {forbidden}", rust_file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() {
|
fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() {
|
||||||
let source = r#"
|
let source = r#"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||||
// version: 25
|
// version: 26
|
||||||
|
|
||||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||||
//! Logging/Transport adapters and management contracts.
|
//! Logging/Transport/Store adapters and management contracts.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_contract_is_available_from_crate_root() {
|
fn bootstrap_contract_is_available_from_crate_root() {
|
||||||
@@ -80,19 +80,21 @@ fn registry_descriptor_inventory_is_available_from_crate_root() {
|
|||||||
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
if let std::result::Result::Ok(registry) = registry {
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
|
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||||
assert_eq!(descriptors.len(), 11);
|
assert_eq!(descriptors.len(), 13);
|
||||||
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||||
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK);
|
||||||
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_STORE);
|
||||||
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||||
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
||||||
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_STORE);
|
||||||
let schema_file_id = descriptors[5].schema_file_id();
|
assert_eq!(descriptors[11].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
|
assert_eq!(descriptors[12].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||||
|
let schema_file_id = descriptors[6].schema_file_id();
|
||||||
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
|
assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association");
|
||||||
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
||||||
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||||
@@ -211,6 +213,18 @@ fn logging_adapter_contract_is_available_from_crate_root() {
|
|||||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedLoggingConfig>() > 0);
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedLoggingConfig>() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_adapter_contract_is_available_from_crate_root() {
|
||||||
|
let adapter = ksp_config_lib::ConfigDocumentEngine::load_resolved_store_config;
|
||||||
|
let composite_adapter = ksp_config_lib::ConfigDocumentEngine::resolve_store_config_profile;
|
||||||
|
let _ = (adapter, composite_adapter);
|
||||||
|
assert_eq!(ksp_config_lib::FILE_ID_STD_STORE, "cfg.std.store");
|
||||||
|
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_STORE, "schema.std.store");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_STD_STORE_FILENAME, "std.store.json");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_STD_STORE_SCHEMA_FILENAME, "std.store.schema.json");
|
||||||
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedStoreConfig>() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn management_contracts_are_available_from_crate_root() {
|
fn management_contracts_are_available_from_crate_root() {
|
||||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
|||||||
81
crates/ksp-config-lib/unit_tests/fixtures/std.store.json
Normal file
81
crates/ksp-config-lib/unit_tests/fixtures/std.store.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"default_profile": "devnet",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "devnet",
|
||||||
|
"network": "devnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "mainnet",
|
||||||
|
"network": "mainnet-beta",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "testnet",
|
||||||
|
"network": "testnet",
|
||||||
|
"backend": "postgres",
|
||||||
|
"postgres": {
|
||||||
|
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||||
|
"pool": {
|
||||||
|
"max_connections": 8,
|
||||||
|
"connect_timeout_ms": 10000,
|
||||||
|
"wait_timeout_ms": 5000,
|
||||||
|
"create_timeout_ms": 10000,
|
||||||
|
"recycle_timeout_ms": 5000
|
||||||
|
},
|
||||||
|
"tls": {
|
||||||
|
"mode": "verify_full"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"schema_autocreate": true,
|
||||||
|
"schema_autoupdate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
// version: 10
|
// version: 11
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
||||||
@@ -7,7 +7,7 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
|||||||
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
if let std::result::Result::Ok(registry) = registry {
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
|
let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||||
assert_eq!(descriptors.len(), 11);
|
assert_eq!(descriptors.len(), 13);
|
||||||
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK);
|
||||||
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME));
|
assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME));
|
||||||
assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE));
|
assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE));
|
||||||
@@ -15,17 +15,19 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() {
|
|||||||
assert_eq!(descriptors[1].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
|
assert_eq!(descriptors[1].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME));
|
||||||
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
|
assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_LOGGING);
|
||||||
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_STORE);
|
||||||
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_STD_WALLET);
|
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
||||||
assert_eq!(descriptors[5].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
|
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_STD_WALLET);
|
||||||
assert_eq!(descriptors[5].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
|
assert_eq!(descriptors[6].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
|
||||||
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
|
assert_eq!(descriptors[6].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET));
|
||||||
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
|
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
|
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||||
assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
|
assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_STORE);
|
||||||
assert!(descriptors[0..6].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
|
assert_eq!(descriptors[11].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||||
assert!(descriptors[6..11].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
|
assert_eq!(descriptors[12].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
|
||||||
|
assert!(descriptors[0..7].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
|
||||||
|
assert!(descriptors[7..13].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +116,27 @@ fn defaults_register_offchain_transport_document_and_schema_with_distinct_roots(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_store_document_and_schema_with_distinct_roots() {
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok());
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let config_id = crate::ConfigFileId::new(crate::FILE_ID_STD_STORE);
|
||||||
|
let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_STORE);
|
||||||
|
if let (std::result::Result::Ok(config_id), std::result::Result::Ok(schema_id)) = (config_id, schema_id) {
|
||||||
|
let config = registry.descriptor(&config_id);
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
if let (std::result::Result::Ok(config), std::result::Result::Ok(schema)) = (config, schema) {
|
||||||
|
assert_eq!(config.kind(), crate::ConfigFileKind::Config);
|
||||||
|
assert_eq!(config.filename(), std::path::Path::new(crate::DEFAULT_STD_STORE_FILENAME));
|
||||||
|
assert_eq!(config.schema_file_id(), std::option::Option::Some(&schema_id));
|
||||||
|
assert_eq!(schema.kind(), crate::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_STORE_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
|
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
|
||||||
let registry = crate::ConfigFileRegistry::defaults();
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
|||||||
285
crates/ksp-config-lib/unit_tests/store.rs
Normal file
285
crates/ksp-config-lib/unit_tests/store.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/store.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_store_profile_maps_exact_runtime_settings_and_secret_fallback() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed Store profile should map without opening PostgreSQL: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_STORE);
|
||||||
|
assert_eq!(resolved.profile_id(), "devnet");
|
||||||
|
assert_eq!(resolved.target_id(), "devnet");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(resolved.settings().backend_kind(), ksp_store_lib::StoreBackendKind::Postgres);
|
||||||
|
assert_eq!(resolved.settings().network().as_str(), "devnet");
|
||||||
|
assert_eq!(resolved.settings().shutdown_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
let postgres = match resolved.settings().backend() {
|
||||||
|
ksp_store_lib::StoreBackendSettings::Postgres(postgres) => std::option::Option::Some(postgres),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
assert!(postgres.is_some(), "pre.004 fixture should map to the PostgreSQL Store backend");
|
||||||
|
if let std::option::Option::Some(postgres) = postgres {
|
||||||
|
assert_eq!(postgres.pool().max_connections(), 8);
|
||||||
|
assert_eq!(postgres.pool().connect_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
assert_eq!(postgres.pool().wait_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
assert_eq!(postgres.pool().create_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
assert_eq!(postgres.pool().recycle_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
assert_eq!(postgres.tls_mode(), ksp_store_lib::PostgresTlsMode::VerifyFull);
|
||||||
|
assert!(postgres.bootstrap().auto_migrate());
|
||||||
|
assert!(postgres.bootstrap().schema_autocreate());
|
||||||
|
assert!(postgres.bootstrap().schema_autoupdate());
|
||||||
|
assert_eq!(postgres.bootstrap().migration_timeout(), std::time::Duration::from_millis(30_000));
|
||||||
|
assert_eq!(postgres.bootstrap().migration_lock_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
}
|
||||||
|
assert!(resolved.effective().sensitivity().is_secret());
|
||||||
|
let safe = resolved.effective().safe_value().to_string();
|
||||||
|
assert!(!safe.contains("postgresql://localhost/ksp_devnet"));
|
||||||
|
assert!(safe.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains("postgresql://localhost/ksp_devnet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_store_uri_wins_and_remains_redacted_in_safe_views() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "postgresql://secret-user:secret-pass@db.example/ksp_devnet";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_STORE_DEVNET_POSTGRES_URI".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "secret process Store URI should map: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.effective().value().pointer("/postgres/connection_uri").and_then(serde_json::Value::as_str), std::option::Option::Some(canary));
|
||||||
|
assert!(!resolved.effective().safe_value().to_string().contains(canary));
|
||||||
|
assert!(!format!("{resolved:?}").contains(canary));
|
||||||
|
let provenance = resolved.effective().provenance_at("/postgres/connection_uri");
|
||||||
|
assert!(provenance.is_some());
|
||||||
|
if let std::option::Option::Some(provenance) = provenance {
|
||||||
|
assert!(provenance.iter().any(|item| return item.environment_source() == std::option::Option::Some(crate::ConfigEnvironmentSource::Process)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literal_or_nonsecret_store_uri_is_rejected_by_effective_adapter() {
|
||||||
|
for value in ["postgresql://literal.invalid/ksp", "${KSP_PUBLIC_STORE_POSTGRES_URI:-postgresql://public.invalid/ksp}"] {
|
||||||
|
let fixture = tempfile::tempdir();
|
||||||
|
assert!(fixture.is_ok());
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = committed_document_value();
|
||||||
|
let mut source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||||
|
if let std::option::Option::Some(profiles) = profiles
|
||||||
|
&& let std::option::Option::Some(profile) = profiles.first_mut()
|
||||||
|
{
|
||||||
|
profile["postgres"]["connection_uri"] = serde_json::Value::String(value.to_owned());
|
||||||
|
}
|
||||||
|
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||||
|
assert!(engine.is_ok());
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_err(), "Store URI without secret provenance must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
assert!(!format!("{error:?}").contains("literal.invalid"));
|
||||||
|
assert!(!format!("{error:?}").contains("public.invalid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_store_targets_select_one_network_and_database_without_runtime_multiplexing() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_STORE_DEVNET_POSTGRES_URI".to_owned(), "postgresql://devnet.invalid/ksp_devnet".to_owned());
|
||||||
|
process.insert("KSP_SECRET_STORE_MAINNET_POSTGRES_URI".to_owned(), "postgresql://mainnet.invalid/ksp_mainnet".to_owned());
|
||||||
|
process.insert("KSP_SECRET_STORE_TESTNET_POSTGRES_URI".to_owned(), "postgresql://testnet.invalid/ksp_testnet".to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
for (target_id, network, expected_uri) in [
|
||||||
|
("devnet", "devnet", "postgresql://devnet.invalid/ksp_devnet"),
|
||||||
|
("mainnet", "mainnet-beta", "postgresql://mainnet.invalid/ksp_mainnet"),
|
||||||
|
("testnet", "testnet", "postgresql://testnet.invalid/ksp_testnet"),
|
||||||
|
] {
|
||||||
|
let resolved = engine.load_resolved_store_config(std::option::Option::Some(target_id), &environment);
|
||||||
|
assert!(resolved.is_ok(), "named Store target should resolve independently: {target_id}: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.target_id(), target_id);
|
||||||
|
assert_eq!(resolved.profile_id(), target_id);
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
|
assert_eq!(resolved.settings().network().as_str(), network);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective().value().pointer("/postgres/connection_uri").and_then(serde_json::Value::as_str),
|
||||||
|
std::option::Option::Some(expected_uri),
|
||||||
|
);
|
||||||
|
assert!(!resolved.effective().safe_value().to_string().contains(expected_uri));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_fix_001_v1_auto_migrate_remains_backward_readable_and_maps_both_schema_policies() {
|
||||||
|
for auto_migrate in [false, true] {
|
||||||
|
let fixture = tempfile::tempdir();
|
||||||
|
assert!(fixture.is_ok());
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = committed_document_value();
|
||||||
|
let mut source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
source["format_version"] = serde_json::Value::from(1);
|
||||||
|
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||||
|
if let std::option::Option::Some(profiles) = profiles {
|
||||||
|
for profile in profiles {
|
||||||
|
let bootstrap = profile.pointer_mut("/postgres/bootstrap").and_then(serde_json::Value::as_object_mut);
|
||||||
|
if let std::option::Option::Some(bootstrap) = bootstrap {
|
||||||
|
bootstrap.remove("schema_autocreate");
|
||||||
|
bootstrap.remove("schema_autoupdate");
|
||||||
|
bootstrap.insert("auto_migrate".to_owned(), serde_json::Value::Bool(auto_migrate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||||
|
assert!(engine.is_ok());
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "Store Config V1 compatibility mapping failed: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved
|
||||||
|
&& let ksp_store_lib::StoreBackendSettings::Postgres(postgres) = resolved.settings().backend()
|
||||||
|
{
|
||||||
|
assert_eq!(postgres.bootstrap().schema_autocreate(), auto_migrate);
|
||||||
|
assert_eq!(postgres.bootstrap().schema_autoupdate(), auto_migrate);
|
||||||
|
assert_eq!(postgres.bootstrap().auto_migrate(), auto_migrate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_fix_001_v2_schema_creation_and_update_policies_map_independently() {
|
||||||
|
let fixture = tempfile::tempdir();
|
||||||
|
assert!(fixture.is_ok());
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = committed_document_value();
|
||||||
|
let mut source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||||
|
if let std::option::Option::Some(profiles) = profiles
|
||||||
|
&& let std::option::Option::Some(profile) = profiles.first_mut()
|
||||||
|
{
|
||||||
|
profile["postgres"]["bootstrap"]["schema_autocreate"] = serde_json::Value::Bool(false);
|
||||||
|
profile["postgres"]["bootstrap"]["schema_autoupdate"] = serde_json::Value::Bool(true);
|
||||||
|
}
|
||||||
|
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||||
|
assert!(engine.is_ok());
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "Store Config V2 split schema policy mapping failed: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved
|
||||||
|
&& let ksp_store_lib::StoreBackendSettings::Postgres(postgres) = resolved.settings().backend()
|
||||||
|
{
|
||||||
|
assert!(!postgres.bootstrap().schema_autocreate());
|
||||||
|
assert!(postgres.bootstrap().schema_autoupdate());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_engine_with_document(root: &std::path::Path, document: &serde_json::Value) -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let config_root = root.join("config");
|
||||||
|
if let std::result::Result::Err(error) = std::fs::create_dir_all(config_root.as_path()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config root cannot be created").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec_pretty(document);
|
||||||
|
let bytes = match bytes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "test Store Config cannot be encoded").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let path = config_root.join(crate::DEFAULT_STD_STORE_FILENAME);
|
||||||
|
if let std::result::Result::Err(error) = std::fs::write(path.as_path(), bytes) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Store Config cannot be written").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root, workspace_root().join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_document_value() -> std::result::Result<serde_json::Value, serde_json::Error> {
|
||||||
|
return serde_json::from_str(include_str!("../../../config/std.store.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
20
crates/ksp-store-lib/Cargo.toml
Normal file
20
crates/ksp-store-lib/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# file: crates/ksp-store-lib/Cargo.toml
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-store-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["postgres"]
|
||||||
|
postgres = ["dep:ksp-store-postgres-lib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
ksp-store-api = { path = "../ksp-store-api" }
|
||||||
|
ksp-store-postgres-lib = { path = "../ksp-store-postgres-lib", optional = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
108
crates/ksp-store-lib/README.md
Normal file
108
crates/ksp-store-lib/README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<!-- file: crates/ksp-store-lib/README.md -->
|
||||||
|
<!-- version: 4 -->
|
||||||
|
|
||||||
|
# ksp-store-lib
|
||||||
|
|
||||||
|
`ksp-store-lib` est la façade runtime Store commune de KSP.
|
||||||
|
|
||||||
|
Elle expose aux consumers une surface backend-neutral, réexporte les contrats RAW de `ksp-store-api`, sélectionne uniquement les backends compilés et masque leurs objets physiques. Le backend PostgreSQL officiel est activé par défaut via la feature `postgres` et reste implémenté dans `ksp-store-postgres-lib`.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
`ksp-store-lib` possède :
|
||||||
|
|
||||||
|
- `StoreSettings`, avec un réseau logique unique, un backend sélectionné et un timeout de fermeture borné ;
|
||||||
|
- les settings PostgreSQL publics KSP-owned : pool, TLS, bootstrap/migrations et URI sensible ;
|
||||||
|
- la feature `postgres` par défaut et le comportement explicite `backend_not_compiled` lorsque PostgreSQL est sélectionné sans cette feature ;
|
||||||
|
- `Store::open`, qui ne retourne une instance qu'après validation, ouverture physique du backend compilé et bootstrap/history réussis ;
|
||||||
|
- `Store::runtime_snapshot()` pour les compteurs runtime sûrs sans I/O ;
|
||||||
|
- `Store::health().await` pour la readiness portable et bornée ;
|
||||||
|
- `Store::close(self).await` pour la fermeture explicite bornée ;
|
||||||
|
- le mapping des erreurs backend vers des codes Store stables sans exposer les erreurs physiques ;
|
||||||
|
- les six capabilities `RawTransaction*` dispatchées vers le backend compilé ;
|
||||||
|
- une validation réseau backend-neutral avant dispatch pour toutes les opérations qui portent explicitement un réseau ;
|
||||||
|
- les réexports crate-root de `ksp-store-api` nécessaires aux consumers ordinaires.
|
||||||
|
|
||||||
|
## Une instance = un réseau
|
||||||
|
|
||||||
|
Une instance `Store` représente exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 Store = 1 RawNetworkId + 1 backend physique sélectionné
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runtime Store n'est pas un multiplexeur multi-database ou multi-réseau. La sélection d'un target nommé appartient à Config. Le document `std.store` peut donc définir plusieurs targets indépendants, mais un appel à `Store::open` reçoit les settings d'un seul target.
|
||||||
|
|
||||||
|
Cette séparation permet d'utiliser des bases PostgreSQL distinctes par réseau tout en conservant le réseau dans l'identité logique des données RAW.
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
Avec la feature par défaut :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-store-lib
|
||||||
|
-> ksp-store-api
|
||||||
|
-> ksp-logging-lib
|
||||||
|
-> ksp-store-postgres-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-store-lib` ne réexporte aucun type `tokio-postgres`, Deadpool ou Rustls.
|
||||||
|
|
||||||
|
Les modes TLS publics sont volontairement limités à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Disabled
|
||||||
|
VerifyFull
|
||||||
|
```
|
||||||
|
|
||||||
|
`VerifyFull` impose TLS avec vérification de la chaîne et de l'identité serveur. La policy typée Store prime sur les paramètres TLS présents dans l'URI.
|
||||||
|
|
||||||
|
## Config et secrets
|
||||||
|
|
||||||
|
Store ne lit ni `.env`, ni variables `KSP_*` / `KSPB_*`, ni variables/fichiers implicites libpq (`PG*`, `.pgpass`, fichiers TLS PostgreSQL).
|
||||||
|
|
||||||
|
`ksp-config-lib` possède `std.store`, la résolution des secrets et la sélection du target. Il construit ensuite un `StoreSettings` backend-neutral. L'URI PostgreSQL reste nécessaire au runtime mais n'a aucun getter public dans `ksp-store-lib` et son `Debug` est redacted.
|
||||||
|
|
||||||
|
## Surface RawTransaction
|
||||||
|
|
||||||
|
`Store` implémente les six capabilities transactionnelles acquises dans `ksp-store-api` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RawTransactionRead
|
||||||
|
RawTransactionWrite
|
||||||
|
RawTransactionObservationRead
|
||||||
|
RawTransactionObservationWrite
|
||||||
|
RawTransactionRetentionRead
|
||||||
|
RawTransactionRetentionWrite
|
||||||
|
```
|
||||||
|
|
||||||
|
Le consumer manipule uniquement les modèles et outcomes backend-neutral. Les erreurs physiques PostgreSQL sont projetées vers des codes Store stables sans exposer le backend.
|
||||||
|
|
||||||
|
La façade fournit ainsi :
|
||||||
|
|
||||||
|
- lecture d'une transaction canonique, de ses observations et de sa rétention ;
|
||||||
|
- écriture atomique transaction + observation ;
|
||||||
|
- ajout idempotent d'observations ;
|
||||||
|
- pagination keyset déterministe par cursor opaque ;
|
||||||
|
- application de transitions de rétention demandées par le caller ;
|
||||||
|
- validation réseau avant dispatch lorsqu'un input porte explicitement son réseau.
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
Sont volontairement hors de cette surface :
|
||||||
|
|
||||||
|
- persistence/query/rétention PostgreSQL de `RawAccountState` ;
|
||||||
|
- batch-size, priorité, backlog ou policy de worker/job ;
|
||||||
|
- transport d'acquisition, Program decoding et materialization ;
|
||||||
|
- exposition publique de SQL, pool, client, row, statement ou transaction PostgreSQL.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — guide pratique de construction, lifecycle et capabilities Store ;
|
||||||
|
- [`../ksp-store-postgres-lib/README.md`](../ksp-store-postgres-lib/README.md) — responsabilité du backend PostgreSQL physique ;
|
||||||
|
- [`../../config/std.store.json`](../../config/std.store.json) — targets Store committed ;
|
||||||
|
- [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) — architecture durable Store ;
|
||||||
|
- [`../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md`](../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md) — plan de fondation ;
|
||||||
|
- [`../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md`](../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md) — validation de fondation ;
|
||||||
|
- [`../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md`](../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md) — plan `RawTransaction` ;
|
||||||
|
- [`../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md`](../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md) — validation `RawTransaction`.
|
||||||
293
crates/ksp-store-lib/USAGE.md
Normal file
293
crates/ksp-store-lib/USAGE.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<!-- file: crates/ksp-store-lib/USAGE.md -->
|
||||||
|
<!-- version: 4 -->
|
||||||
|
|
||||||
|
# Utilisation de ksp-store-lib
|
||||||
|
|
||||||
|
## 1. Dépendance et backend compilé
|
||||||
|
|
||||||
|
Le consumer runtime dépend de la façade commune :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib" }
|
||||||
|
```
|
||||||
|
|
||||||
|
La feature par défaut compile le backend PostgreSQL :
|
||||||
|
|
||||||
|
```text
|
||||||
|
postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour compiler la façade sans backend physique :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans ce mode, les settings PostgreSQL restent représentables, mais `Store::open` retourne `ERROR_CODE_BACKEND_NOT_COMPILED` avant toute I/O si PostgreSQL est sélectionné.
|
||||||
|
|
||||||
|
Un consumer applicatif ordinaire ne dépend pas directement de `ksp-store-postgres-lib`.
|
||||||
|
|
||||||
|
## 2. Obtenir les settings depuis Config
|
||||||
|
|
||||||
|
Le chemin applicatif recommandé passe par `ksp-config-lib`, propriétaire de `std.store`, de la résolution `.env` et des secrets.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn resolve_store_settings(
|
||||||
|
engine: &ksp_config_lib::ConfigDocumentEngine,
|
||||||
|
environment: &ksp_config_lib::ConfigEnvironment,
|
||||||
|
target: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ksp_store_lib::StoreSettings> {
|
||||||
|
let resolved = engine.load_resolved_store_config(target, environment);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
return std::result::Result::Ok(resolved.into_settings());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque `StoreSettings` sélectionne exactement un réseau logique et un backend physique. La sélection d'un target nommé appartient à Config ; `Store` ne route pas automatiquement entre plusieurs targets.
|
||||||
|
|
||||||
|
## 3. Construire des settings programmatiquement
|
||||||
|
|
||||||
|
La construction directe est utile pour les tests et outils qui ne passent pas par Config.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn programmatic_store_settings(connection_uri: std::string::String) -> ksp_store_lib::Result<ksp_store_lib::StoreSettings> {
|
||||||
|
let network = ksp_store_lib::RawNetworkId::new("devnet");
|
||||||
|
let network = match network {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let postgres = ksp_store_lib::PostgresStoreSettings::new(
|
||||||
|
connection_uri,
|
||||||
|
ksp_store_lib::PostgresPoolSettings::default(),
|
||||||
|
ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||||
|
ksp_store_lib::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let settings = ksp_store_lib::StoreSettings::with_default_shutdown(
|
||||||
|
network,
|
||||||
|
ksp_store_lib::StoreBackendSettings::Postgres(postgres),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let std::result::Result::Err(error) = settings.validate() {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::result::Result::Ok(settings);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PostgresStoreSettings` ne fournit aucun getter public de l'URI. Son `Debug` remplace cette valeur par `<redacted>`.
|
||||||
|
|
||||||
|
## 4. Ouvrir, sonder et fermer un Store
|
||||||
|
|
||||||
|
`Store::open` est async et ne retourne un succès qu'après validation des settings, ouverture du backend compilé et bootstrap requis.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn use_store(settings: ksp_store_lib::StoreSettings) -> ksp_store_lib::Result<()> {
|
||||||
|
let store = ksp_store_lib::Store::open(settings).await;
|
||||||
|
let store = match store {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let runtime = store.runtime_snapshot();
|
||||||
|
let _network = runtime.network();
|
||||||
|
let _capacity = runtime.pool_capacity();
|
||||||
|
let _size = runtime.pool_size();
|
||||||
|
let _available = runtime.pool_available();
|
||||||
|
let _waiting = runtime.pool_waiting();
|
||||||
|
|
||||||
|
let health = store.health().await;
|
||||||
|
match health.state() {
|
||||||
|
ksp_store_lib::StoreHealthState::Ready => {}
|
||||||
|
ksp_store_lib::StoreHealthState::NotReady => {
|
||||||
|
let _safe_error_code = health.last_error_code();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.close().await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Store::close(self)` consomme l'instance. Une fermeture explicite ne peut donc pas être suivie d'une nouvelle opération via la même valeur.
|
||||||
|
|
||||||
|
## 5. Lire une transaction RAW
|
||||||
|
|
||||||
|
Importer le trait correspondant suffit pour utiliser la façade :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ksp_store_lib::RawTransactionRead;
|
||||||
|
|
||||||
|
async fn read_transaction(
|
||||||
|
store: &ksp_store_lib::Store,
|
||||||
|
reference: &ksp_store_lib::RawTransactionReference,
|
||||||
|
) -> ksp_store_lib::Result<std::option::Option<ksp_store_lib::RawTransaction>> {
|
||||||
|
return store.get_raw_transaction(reference).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Une transaction absente retourne `None`. Une transaction `Purged` retourne également `None` pour le payload canonique ; son tombstone reste accessible via la capability de rétention.
|
||||||
|
|
||||||
|
## 6. Paginer les références de transactions
|
||||||
|
|
||||||
|
La pagination est keyset et utilise un cursor opaque. Le consumer ne doit pas interpréter ses bytes.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ksp_store_lib::RawTransactionRead;
|
||||||
|
|
||||||
|
async fn first_transaction_page(
|
||||||
|
store: &ksp_store_lib::Store,
|
||||||
|
network: ksp_store_lib::RawNetworkId,
|
||||||
|
) -> ksp_store_lib::Result<ksp_store_lib::RawPage<ksp_store_lib::RawTransactionReference>> {
|
||||||
|
let limit = ksp_store_lib::RawPageLimit::new(100);
|
||||||
|
let limit = match limit {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let slots = ksp_store_lib::RawSlotRange::new(std::option::Option::None, std::option::Option::None);
|
||||||
|
let slots = match slots {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = ksp_store_lib::RawTransactionQuery::new(
|
||||||
|
network,
|
||||||
|
slots,
|
||||||
|
ksp_store_lib::RawSortDirection::Ascending,
|
||||||
|
ksp_store_lib::RawPageRequest::first(limit),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store.list_raw_transactions(&query).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour continuer, recopier le cursor retourné par `RawPage::next_cursor()` dans `RawPageRequest::after`. Le réseau, la direction et les bornes de slots doivent rester identiques à ceux de la query ayant produit le cursor.
|
||||||
|
|
||||||
|
## 7. Persister une acquisition canonique
|
||||||
|
|
||||||
|
La transaction canonique et son observation initiale forment une seule opération atomique.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ksp_store_lib::RawTransactionWrite;
|
||||||
|
|
||||||
|
async fn persist_acquisition(
|
||||||
|
store: &ksp_store_lib::Store,
|
||||||
|
transaction: ksp_store_lib::RawTransaction,
|
||||||
|
observation: ksp_store_lib::RawTransactionObservation,
|
||||||
|
) -> ksp_store_lib::Result<ksp_store_lib::RawAcquisitionWriteOutcome> {
|
||||||
|
return store
|
||||||
|
.persist_raw_transaction_acquisition(
|
||||||
|
transaction,
|
||||||
|
observation,
|
||||||
|
ksp_store_lib::RawTransactionAcquisitionMode::Normal,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le mode `Normal` respecte un tombstone `Purged`. `ForceRehydrate` doit être choisi explicitement lorsqu'un caller veut restaurer un payload purgé et que l'identité retenue est compatible.
|
||||||
|
|
||||||
|
Un contenu divergent sous la même identité produit `ERROR_CODE_RAW_CONFLICT`; Store ne remplace jamais silencieusement le contenu gagnant.
|
||||||
|
|
||||||
|
## 8. Lire et ajouter une observation
|
||||||
|
|
||||||
|
Une observation supplémentaire référence une transaction canonique déjà durable.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ksp_store_lib::RawTransactionObservationRead;
|
||||||
|
use ksp_store_lib::RawTransactionObservationWrite;
|
||||||
|
|
||||||
|
async fn use_observation(
|
||||||
|
store: &ksp_store_lib::Store,
|
||||||
|
key: &ksp_store_lib::RawObservationKey,
|
||||||
|
observation: ksp_store_lib::RawTransactionObservation,
|
||||||
|
) -> ksp_store_lib::Result<ksp_store_lib::RawObservationWriteOutcome> {
|
||||||
|
let existing = store.get_raw_transaction_observation(key).await;
|
||||||
|
if let std::result::Result::Err(error) = existing {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.record_raw_transaction_observation(observation).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`record_raw_transaction_observation` ne crée pas implicitement le canonique. Une référence absente est signalée par `ERROR_CODE_RAW_REFERENCE_NOT_FOUND`.
|
||||||
|
|
||||||
|
## 9. Lire la rétention et le tombstone
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ksp_store_lib::RawTransactionRetentionRead;
|
||||||
|
|
||||||
|
async fn read_retention(
|
||||||
|
store: &ksp_store_lib::Store,
|
||||||
|
reference: &ksp_store_lib::RawTransactionReference,
|
||||||
|
) -> ksp_store_lib::Result<std::option::Option<ksp_store_lib::RawRetentionState>> {
|
||||||
|
let tombstone = store.get_raw_transaction_tombstone(reference).await;
|
||||||
|
if let std::result::Result::Err(error) = tombstone {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.get_raw_transaction_retention_state(reference).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le tombstone minimal est utile uniquement lorsque le payload a été purgé ; il ne remplace pas le modèle canonique lorsqu'un payload est encore disponible.
|
||||||
|
|
||||||
|
## 10. Appliquer une transition de rétention
|
||||||
|
|
||||||
|
La policy qui décide qu'une transition est autorisée appartient au caller. Store applique uniquement la transition demandée de manière atomique.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ksp_store_lib::RawTransactionRetentionWrite;
|
||||||
|
|
||||||
|
async fn archive_transaction(
|
||||||
|
store: &ksp_store_lib::Store,
|
||||||
|
reference: ksp_store_lib::RawTransactionReference,
|
||||||
|
) -> ksp_store_lib::Result<ksp_store_lib::RawRetentionWriteOutcome> {
|
||||||
|
let transition = ksp_store_lib::RawTransactionRetentionTransition::try_new(
|
||||||
|
reference,
|
||||||
|
ksp_store_lib::RawRetentionState::Full,
|
||||||
|
ksp_store_lib::RawRetentionState::Archived,
|
||||||
|
);
|
||||||
|
let transition = match transition {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
return store.transition_raw_transaction_retention(transition).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend PostgreSQL supporte physiquement `Full -> Archived -> Purged`. Une transition impliquant `Compacted` est rejetée par ce backend tant qu'aucune représentation compactée réelle n'est implémentée.
|
||||||
|
|
||||||
|
## 11. Diagnostics et erreurs
|
||||||
|
|
||||||
|
Les snapshots et erreurs de façade n'exposent ni URI, host, user, database, SQL, handle backend, valeur de bind ni texte d'erreur PostgreSQL.
|
||||||
|
|
||||||
|
Les codes Store utiles incluent notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
store.wrong_network
|
||||||
|
store.raw_reference_not_found
|
||||||
|
store.postgres_read_failed
|
||||||
|
store.postgres_write_failed
|
||||||
|
store.postgres_data_invalid
|
||||||
|
store.postgres_page_limit_unsupported
|
||||||
|
store.postgres_retention_compaction_unsupported
|
||||||
|
```
|
||||||
|
|
||||||
|
Les conflits et queries invalides utilisent les codes backend-neutral `store_api.raw_conflict` et `store_api.raw_query_invalid`.
|
||||||
|
|
||||||
|
## 12. Limites de la façade
|
||||||
|
|
||||||
|
La façade ne fournit pas d'accès public au SQL, au pool, aux clients ou transactions PostgreSQL. Les capabilities `RawAccount*` réexportées par l'API commune ne sont pas encore dispatchées par `Store`.
|
||||||
|
|
||||||
|
La taille de page est une primitive de navigation. Les décisions de batch, priorité, backlog et scheduling appartiennent aux workers/jobs, pas à Store.
|
||||||
7
crates/ksp-store-lib/src/constants.rs
Normal file
7
crates/ksp-store-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/constants.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Store facade-owned constants.
|
||||||
|
|
||||||
|
/// Owning tracing target reserved for events emitted by the common Store runtime facade.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "ksp-store-lib";
|
||||||
44
crates/ksp-store-lib/src/error.rs
Normal file
44
crates/ksp-store-lib/src/error.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/error.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
/// Error code reserved for operations attempted after a Store backend has entered its closed state.
|
||||||
|
pub const ERROR_CODE_BACKEND_CLOSED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_closed");
|
||||||
|
/// Error code used when a known Store backend was selected but its Cargo feature is not compiled.
|
||||||
|
pub const ERROR_CODE_BACKEND_NOT_COMPILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_not_compiled");
|
||||||
|
/// Error code used when a compiled Store backend cannot complete its bounded opening lifecycle.
|
||||||
|
pub const ERROR_CODE_BACKEND_OPEN_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_open_failed");
|
||||||
|
/// Error code used when the PostgreSQL backend rejects or cannot normalize its physical connection configuration.
|
||||||
|
pub const ERROR_CODE_POSTGRES_CONFIG_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_config_invalid");
|
||||||
|
/// Error code used when PostgreSQL physical connection establishment fails without exposing remote or credential details.
|
||||||
|
pub const ERROR_CODE_POSTGRES_CONNECT_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_connect_failed");
|
||||||
|
/// Error code used when PostgreSQL returns persisted RAW data incompatible with the stable Store contract.
|
||||||
|
pub const ERROR_CODE_POSTGRES_DATA_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_data_invalid");
|
||||||
|
/// Error code used when a lightweight PostgreSQL health/readiness probe fails safely.
|
||||||
|
pub const ERROR_CODE_POSTGRES_HEALTH_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_health_failed");
|
||||||
|
/// Error code used when PostgreSQL migration/bootstrap execution fails without exposing server text or SQL.
|
||||||
|
pub const ERROR_CODE_POSTGRES_MIGRATION_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_failed");
|
||||||
|
/// Error code used when persisted PostgreSQL migration history diverges from the embedded immutable KSP history.
|
||||||
|
pub const ERROR_CODE_POSTGRES_MIGRATION_MISMATCH: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_mismatch");
|
||||||
|
/// Error code used when the requested RAW page size exceeds the exact PostgreSQL LIMIT representation boundary.
|
||||||
|
pub const ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_page_limit_unsupported");
|
||||||
|
/// Error code used when a bounded PostgreSQL pool wait, create or recycle operation reaches its deadline.
|
||||||
|
pub const ERROR_CODE_POSTGRES_POOL_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_pool_timeout");
|
||||||
|
/// Error code used when a PostgreSQL RAW read fails without exposing SQL, bind values or server text.
|
||||||
|
pub const ERROR_CODE_POSTGRES_READ_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_read_failed");
|
||||||
|
/// Error code used when PostgreSQL cannot represent a requested RAW retention compaction state.
|
||||||
|
pub const ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED: ksp_store_api::ErrorCode =
|
||||||
|
ksp_store_api::ErrorCode::new("store", "postgres_retention_compaction_unsupported");
|
||||||
|
/// Error code used when PostgreSQL history contains a migration newer than this Store runtime understands.
|
||||||
|
pub const ERROR_CODE_POSTGRES_SCHEMA_NEWER: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_schema_newer");
|
||||||
|
/// Error code used when verified PostgreSQL TLS setup or negotiation cannot be completed safely.
|
||||||
|
pub const ERROR_CODE_POSTGRES_TLS_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_tls_failed");
|
||||||
|
/// Error code used when a PostgreSQL RAW write fails without exposing SQL, bind values or server text.
|
||||||
|
pub const ERROR_CODE_POSTGRES_WRITE_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_write_failed");
|
||||||
|
/// Error code used when a RAW write requires a canonical reference that is not durable.
|
||||||
|
pub const ERROR_CODE_RAW_REFERENCE_NOT_FOUND: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "raw_reference_not_found");
|
||||||
|
/// Error code used when backend-neutral Store settings violate runtime bounds or invariants.
|
||||||
|
pub const ERROR_CODE_SETTINGS_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "settings_invalid");
|
||||||
|
/// Error code used when a Store cannot complete its explicit shutdown inside the configured bound.
|
||||||
|
pub const ERROR_CODE_SHUTDOWN_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "shutdown_timeout");
|
||||||
|
/// Error code used when a network-scoped Store operation targets a network different from the opened Store binding.
|
||||||
|
pub const ERROR_CODE_WRONG_NETWORK: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "wrong_network");
|
||||||
132
crates/ksp-store-lib/src/health.rs
Normal file
132
crates/ksp-store-lib/src/health.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/health.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Portable Store health state independent from the selected physical backend.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum StoreHealthState {
|
||||||
|
/// The selected Store backend answered the bounded readiness probe and its migration foundation is current.
|
||||||
|
Ready,
|
||||||
|
/// The Store instance exists but its latest bounded readiness probe could not prove readiness.
|
||||||
|
NotReady,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe synchronous Store runtime snapshot without performing backend I/O.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StoreRuntimeSnapshot {
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
pool_available: u32,
|
||||||
|
pool_capacity: u32,
|
||||||
|
pool_size: u32,
|
||||||
|
pool_waiting: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreRuntimeSnapshot {
|
||||||
|
/// Creates one portable runtime projection from backend-owned safe counters.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn new(
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
pool_capacity: u32,
|
||||||
|
pool_size: u32,
|
||||||
|
pool_available: u32,
|
||||||
|
pool_waiting: u32,
|
||||||
|
) -> Self {
|
||||||
|
return Self { backend_kind, network, pool_available, pool_capacity, pool_size, pool_waiting };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected backend identity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn backend_kind(&self) -> crate::StoreBackendKind {
|
||||||
|
return self.backend_kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the one logical network bound to this Store instance.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn network(&self) -> &ksp_store_api::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of currently available pooled backend objects.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_available(&self) -> u32 {
|
||||||
|
return self.pool_available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum pooled backend object count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_capacity(&self) -> u32 {
|
||||||
|
return self.pool_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current pooled backend object count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_size(&self) -> u32 {
|
||||||
|
return self.pool_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tasks currently waiting for a pooled backend object.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_waiting(&self) -> u32 {
|
||||||
|
return self.pool_waiting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Portable Store readiness projection containing only safe runtime and migration diagnostics.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StoreHealthSnapshot {
|
||||||
|
last_error_code: std::option::Option<ksp_store_api::ErrorCode>,
|
||||||
|
migration_version: std::option::Option<u64>,
|
||||||
|
pending_migration_count: u32,
|
||||||
|
runtime: StoreRuntimeSnapshot,
|
||||||
|
state: StoreHealthState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreHealthSnapshot {
|
||||||
|
/// Creates one safe health projection from already classified backend diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn new(
|
||||||
|
state: StoreHealthState,
|
||||||
|
runtime: StoreRuntimeSnapshot,
|
||||||
|
migration_version: std::option::Option<u64>,
|
||||||
|
pending_migration_count: u32,
|
||||||
|
last_error_code: std::option::Option<ksp_store_api::ErrorCode>,
|
||||||
|
) -> Self {
|
||||||
|
return Self { last_error_code, migration_version, pending_migration_count, runtime, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest safe error code when readiness could not be proven.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn last_error_code(&self) -> std::option::Option<ksp_store_api::ErrorCode> {
|
||||||
|
return self.last_error_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the migration version observed by the readiness probe when available.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn migration_version(&self) -> std::option::Option<u64> {
|
||||||
|
return self.migration_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of embedded migrations newer than the observed applied version.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pending_migration_count(&self) -> u32 {
|
||||||
|
return self.pending_migration_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe synchronous runtime projection captured for this health probe.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn runtime(&self) -> &StoreRuntimeSnapshot {
|
||||||
|
return &self.runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this probe proved the Store ready.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn state(&self) -> StoreHealthState {
|
||||||
|
return self.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/health.rs"]
|
||||||
|
mod tests;
|
||||||
213
crates/ksp-store-lib/src/lib.rs
Normal file
213
crates/ksp-store-lib/src/lib.rs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/lib.rs
|
||||||
|
// version: 8
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Common backend-neutral Store runtime facade for KSP.
|
||||||
|
//!
|
||||||
|
//! The runtime facade owns backend selection, lifecycle, safe diagnostics and
|
||||||
|
//! backend-neutral capability dispatch. `0.3.3-pre.008` completes the PostgreSQL
|
||||||
|
//! `RawTransaction` vertical slice by implementing the six transaction capabilities
|
||||||
|
//! on both the physical backend and this common facade without exposing physical types.
|
||||||
|
//!
|
||||||
|
//! The default `postgres` feature compiles the official PostgreSQL backend as
|
||||||
|
//! an optional implementation dependency. No backend implementation type is
|
||||||
|
//! part of this crate's public surface.
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod error;
|
||||||
|
mod health;
|
||||||
|
mod settings;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
/// Error code reserved for operations attempted after a Store backend is closed.
|
||||||
|
pub use self::error::ERROR_CODE_BACKEND_CLOSED;
|
||||||
|
/// Error code used when a known Store backend is selected without its compiled feature.
|
||||||
|
pub use self::error::ERROR_CODE_BACKEND_NOT_COMPILED;
|
||||||
|
/// Error code used when a compiled Store backend cannot complete opening.
|
||||||
|
pub use self::error::ERROR_CODE_BACKEND_OPEN_FAILED;
|
||||||
|
/// Error code used when PostgreSQL physical configuration is malformed or unsupported.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_CONFIG_INVALID;
|
||||||
|
/// Error code used when PostgreSQL physical connection establishment fails.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_CONNECT_FAILED;
|
||||||
|
/// Error code used when PostgreSQL returns persisted RAW data incompatible with the Store contract.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_DATA_INVALID;
|
||||||
|
/// Error code used when a lightweight PostgreSQL health/readiness probe fails safely.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_HEALTH_FAILED;
|
||||||
|
/// Error code used when PostgreSQL migration/bootstrap execution fails safely.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_FAILED;
|
||||||
|
/// Error code used when PostgreSQL migration history diverges from the embedded immutable KSP history.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH;
|
||||||
|
/// Error code used when a requested RAW page size exceeds PostgreSQL's exact physical LIMIT boundary.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED;
|
||||||
|
/// Error code used when a bounded PostgreSQL pool operation reaches its deadline.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_POOL_TIMEOUT;
|
||||||
|
/// Error code used when a PostgreSQL RAW read statement fails safely.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_READ_FAILED;
|
||||||
|
/// Error code used when PostgreSQL cannot represent a requested RAW retention compaction state.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED;
|
||||||
|
/// Error code used when PostgreSQL schema history is newer than this Store runtime.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_SCHEMA_NEWER;
|
||||||
|
/// Error code used when PostgreSQL verified TLS setup or negotiation fails.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_TLS_FAILED;
|
||||||
|
/// Error code used when a PostgreSQL RAW write statement or transaction fails safely.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_WRITE_FAILED;
|
||||||
|
/// Error code used when a RAW write requires a canonical reference that is not durable.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_REFERENCE_NOT_FOUND;
|
||||||
|
/// Error code used when Store settings violate backend-neutral bounds or invariants.
|
||||||
|
pub use self::error::ERROR_CODE_SETTINGS_INVALID;
|
||||||
|
/// Error code used when explicit Store shutdown exceeds its configured deadline.
|
||||||
|
pub use self::error::ERROR_CODE_SHUTDOWN_TIMEOUT;
|
||||||
|
/// Error code used when a network-scoped operation targets a network different from the Store binding.
|
||||||
|
pub use self::error::ERROR_CODE_WRONG_NETWORK;
|
||||||
|
/// Portable Store health/readiness projection containing only safe diagnostics.
|
||||||
|
pub use self::health::StoreHealthSnapshot;
|
||||||
|
/// Portable Store health state independent from physical backend types.
|
||||||
|
pub use self::health::StoreHealthState;
|
||||||
|
/// Safe synchronous Store runtime snapshot containing backend-neutral pool counters.
|
||||||
|
pub use self::health::StoreRuntimeSnapshot;
|
||||||
|
/// Bounded PostgreSQL bootstrap and migration settings owned by the Store facade.
|
||||||
|
pub use self::settings::PostgresBootstrapSettings;
|
||||||
|
/// Bounded PostgreSQL connection-pool settings owned by the Store facade.
|
||||||
|
pub use self::settings::PostgresPoolSettings;
|
||||||
|
/// PostgreSQL settings owned by the Store facade without exposing backend implementation types.
|
||||||
|
pub use self::settings::PostgresStoreSettings;
|
||||||
|
/// TLS policy accepted by the backend-neutral PostgreSQL settings surface.
|
||||||
|
pub use self::settings::PostgresTlsMode;
|
||||||
|
/// Backend identity understood independently from compiled Cargo features.
|
||||||
|
pub use self::settings::StoreBackendKind;
|
||||||
|
/// Backend-specific settings selected through the common Store facade.
|
||||||
|
pub use self::settings::StoreBackendSettings;
|
||||||
|
/// Complete backend-neutral settings consumed by the common Store runtime facade.
|
||||||
|
pub use self::settings::StoreSettings;
|
||||||
|
/// Opaque common Store runtime facade with consuming async shutdown.
|
||||||
|
pub use self::store::Store;
|
||||||
|
/// Error code used when a RAW write collides with divergent content for the same logical identity.
|
||||||
|
pub use ksp_store_api::ERROR_CODE_RAW_CONFLICT;
|
||||||
|
/// Error code used when a RAW Store model violates one of its backend-agnostic invariants.
|
||||||
|
pub use ksp_store_api::ERROR_CODE_RAW_MODEL_INVALID;
|
||||||
|
/// Error code used when a KSP-owned RAW persistence payload violates its format or admission contract.
|
||||||
|
pub use ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID;
|
||||||
|
/// Error code used when acquisition provenance is malformed, unsafe or internally inconsistent.
|
||||||
|
pub use ksp_store_api::ERROR_CODE_RAW_PROVENANCE_INVALID;
|
||||||
|
/// Error code used when one RAW query or cursor violates backend-agnostic query invariants.
|
||||||
|
pub use ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID;
|
||||||
|
/// Error code used when a RAW retention transition violates the logical lifecycle contract.
|
||||||
|
pub use ksp_store_api::ERROR_CODE_RAW_RETENTION_INVALID;
|
||||||
|
/// Common KSP error type used by Store-facing contracts.
|
||||||
|
pub use ksp_store_api::Error;
|
||||||
|
/// Stable structured code identifying a KSP error category and condition.
|
||||||
|
pub use ksp_store_api::ErrorCode;
|
||||||
|
/// Structured contextual field attached to a KSP error.
|
||||||
|
pub use ksp_store_api::ErrorContext;
|
||||||
|
/// Maximum complete RAW account-data length admitted by the Store API.
|
||||||
|
pub use ksp_store_api::MAX_RAW_ACCOUNT_DATA_BYTES;
|
||||||
|
/// Maximum UTF-8 byte length accepted for one safe logical RAW/provenance code.
|
||||||
|
pub use ksp_store_api::MAX_RAW_CODE_BYTES;
|
||||||
|
/// Maximum opaque query cursor length admitted by the Store API.
|
||||||
|
pub use ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES;
|
||||||
|
/// Maximum KSP-owned canonical RAW payload admitted by the Store API.
|
||||||
|
pub use ksp_store_api::MAX_RAW_PAYLOAD_BYTES;
|
||||||
|
/// Maximum source-wire payload size recorded as acquisition metadata.
|
||||||
|
pub use ksp_store_api::MAX_RAW_SOURCE_PAYLOAD_BYTES;
|
||||||
|
/// Maximum supported Unix millisecond timestamp.
|
||||||
|
pub use ksp_store_api::MAX_RAW_UNIX_MILLIS;
|
||||||
|
/// Canonical Solana account address primitive shared by persistent models.
|
||||||
|
pub use ksp_store_api::Pubkey;
|
||||||
|
/// Persistable acquisition observation linked to one complete canonical RAW account state.
|
||||||
|
pub use ksp_store_api::RawAccountObservation;
|
||||||
|
/// Read capability for persisted RAW account-state observations.
|
||||||
|
pub use ksp_store_api::RawAccountObservationRead;
|
||||||
|
/// Write capability for additional observations of already persisted RAW account states.
|
||||||
|
pub use ksp_store_api::RawAccountObservationWrite;
|
||||||
|
/// Canonical complete N1 RAW account state independent from acquisition transport.
|
||||||
|
pub use ksp_store_api::RawAccountState;
|
||||||
|
/// Backend-independent list query for complete canonical RAW account states.
|
||||||
|
pub use ksp_store_api::RawAccountStateQuery;
|
||||||
|
/// Read capability for complete canonical RAW account states.
|
||||||
|
pub use ksp_store_api::RawAccountStateRead;
|
||||||
|
/// Durable backend-independent identity of one canonical RAW account state.
|
||||||
|
pub use ksp_store_api::RawAccountStateReference;
|
||||||
|
/// Write capability for complete canonical RAW account-state acquisitions.
|
||||||
|
pub use ksp_store_api::RawAccountStateWrite;
|
||||||
|
/// Origin category describing why one acquisition was performed.
|
||||||
|
pub use ksp_store_api::RawAcquisitionOrigin;
|
||||||
|
/// Safe source-independent acquisition provenance attached to one persisted observation.
|
||||||
|
pub use ksp_store_api::RawAcquisitionProvenance;
|
||||||
|
/// Combined outcome of one atomic canonical RAW entity plus observation acquisition.
|
||||||
|
pub use ksp_store_api::RawAcquisitionWriteOutcome;
|
||||||
|
/// Fixed-size digest identifying canonical or source bytes without retaining them.
|
||||||
|
pub use ksp_store_api::RawContentHash;
|
||||||
|
/// Outcome for one canonical RAW entity in an idempotent persistence operation.
|
||||||
|
pub use ksp_store_api::RawEntityWriteOutcome;
|
||||||
|
/// Bounded identifier of one KSP-owned source-independent RAW persistence format.
|
||||||
|
pub use ksp_store_api::RawFormatId;
|
||||||
|
/// Bounded logical network/cluster identifier used in backend-independent Store identities.
|
||||||
|
pub use ksp_store_api::RawNetworkId;
|
||||||
|
/// Stable deterministic idempotence key for one persisted acquisition observation.
|
||||||
|
pub use ksp_store_api::RawObservationKey;
|
||||||
|
/// Outcome for one deterministic acquisition observation write.
|
||||||
|
pub use ksp_store_api::RawObservationWriteOutcome;
|
||||||
|
/// One deterministic page of backend-independent Store results.
|
||||||
|
pub use ksp_store_api::RawPage;
|
||||||
|
/// Opaque backend-owned cursor returned by one deterministic Store query.
|
||||||
|
pub use ksp_store_api::RawPageCursor;
|
||||||
|
/// Caller-requested page size without an arbitrary KSP policy ceiling.
|
||||||
|
pub use ksp_store_api::RawPageLimit;
|
||||||
|
/// Opaque-cursor page request used by backend-independent list operations.
|
||||||
|
pub use ksp_store_api::RawPageRequest;
|
||||||
|
/// Bounded source-independent KSP RAW persistence payload.
|
||||||
|
pub use ksp_store_api::RawPayload;
|
||||||
|
/// Bounded logical code used by acquisition provenance fields.
|
||||||
|
pub use ksp_store_api::RawProvenanceCode;
|
||||||
|
/// Logical availability state of one canonical RAW payload.
|
||||||
|
pub use ksp_store_api::RawRetentionState;
|
||||||
|
/// Outcome of one atomic RAW retention transition.
|
||||||
|
pub use ksp_store_api::RawRetentionWriteOutcome;
|
||||||
|
/// Optional inclusive Solana slot bounds for one Store query.
|
||||||
|
pub use ksp_store_api::RawSlotRange;
|
||||||
|
/// Deterministic traversal direction for Store list queries.
|
||||||
|
pub use ksp_store_api::RawSortDirection;
|
||||||
|
/// Bounded UTC timestamp represented as whole milliseconds since Unix epoch.
|
||||||
|
pub use ksp_store_api::RawTimestamp;
|
||||||
|
/// Canonical source-independent N1 RAW transaction persisted by Store backends.
|
||||||
|
pub use ksp_store_api::RawTransaction;
|
||||||
|
/// Explicit write mode for canonical RAW transaction acquisitions.
|
||||||
|
pub use ksp_store_api::RawTransactionAcquisitionMode;
|
||||||
|
/// Persistable acquisition observation linked to one canonical RAW transaction.
|
||||||
|
pub use ksp_store_api::RawTransactionObservation;
|
||||||
|
/// Read capability for persisted RAW transaction observations.
|
||||||
|
pub use ksp_store_api::RawTransactionObservationRead;
|
||||||
|
/// Write capability for additional observations of already persisted RAW transactions.
|
||||||
|
pub use ksp_store_api::RawTransactionObservationWrite;
|
||||||
|
/// Backend-independent list query for canonical RAW transactions.
|
||||||
|
pub use ksp_store_api::RawTransactionQuery;
|
||||||
|
/// Read capability for canonical RAW transactions.
|
||||||
|
pub use ksp_store_api::RawTransactionRead;
|
||||||
|
/// Durable backend-independent identity of one canonical RAW transaction.
|
||||||
|
pub use ksp_store_api::RawTransactionReference;
|
||||||
|
/// Read capability for canonical RAW transaction retention metadata.
|
||||||
|
pub use ksp_store_api::RawTransactionRetentionRead;
|
||||||
|
/// Requested compare-and-transition operation for one RAW transaction retention state.
|
||||||
|
pub use ksp_store_api::RawTransactionRetentionTransition;
|
||||||
|
/// Write capability for policy-authorized RAW transaction retention transitions.
|
||||||
|
pub use ksp_store_api::RawTransactionRetentionWrite;
|
||||||
|
/// Canonical 64-byte Solana transaction signature used by Store identities.
|
||||||
|
pub use ksp_store_api::RawTransactionSignature;
|
||||||
|
/// Minimal durable identity retained after a canonical RAW transaction payload is purged.
|
||||||
|
pub use ksp_store_api::RawTransactionTombstone;
|
||||||
|
/// Write capability for canonical RAW transaction acquisitions.
|
||||||
|
pub use ksp_store_api::RawTransactionWrite;
|
||||||
|
/// Common KSP result alias using [`Error`].
|
||||||
|
pub use ksp_store_api::Result;
|
||||||
|
/// Boxed async operation returned by object-safe Store capability contracts.
|
||||||
|
pub use ksp_store_api::StoreApiFuture;
|
||||||
|
|
||||||
|
/// Crate-owned tracing target reserved for Store runtime behavior.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
|
|
||||||
|
// Keep the mandatory crate-owned tracing target part of the compiled scaffold
|
||||||
|
// without inventing runtime logging before the first behavioral log site.
|
||||||
|
const _: &str = crate::TRACING_TARGET;
|
||||||
423
crates/ksp-store-lib/src/settings.rs
Normal file
423
crates/ksp-store-lib/src/settings.rs
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/settings.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||||||
|
const DEFAULT_MAX_CONNECTIONS: u32 = 8;
|
||||||
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS: u64 = 10_000;
|
||||||
|
const DEFAULT_MIGRATION_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
const DEFAULT_POOL_CREATE_TIMEOUT_MS: u64 = 10_000;
|
||||||
|
const DEFAULT_POOL_RECYCLE_TIMEOUT_MS: u64 = 5_000;
|
||||||
|
const DEFAULT_POOL_WAIT_TIMEOUT_MS: u64 = 5_000;
|
||||||
|
const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 5_000;
|
||||||
|
const MAX_CONNECTIONS: u32 = 64;
|
||||||
|
const MAX_CONNECT_TIMEOUT_MS: u64 = 60_000;
|
||||||
|
const MAX_MIGRATION_LOCK_TIMEOUT_MS: u64 = 120_000;
|
||||||
|
const MAX_MIGRATION_TIMEOUT_MS: u64 = 300_000;
|
||||||
|
const MAX_POOL_CREATE_TIMEOUT_MS: u64 = 60_000;
|
||||||
|
const MAX_POOL_RECYCLE_TIMEOUT_MS: u64 = 60_000;
|
||||||
|
const MAX_POOL_WAIT_TIMEOUT_MS: u64 = 60_000;
|
||||||
|
const MAX_SHUTDOWN_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
const MIN_CONNECTIONS: u32 = 1;
|
||||||
|
const MIN_CONNECT_TIMEOUT_MS: u64 = 100;
|
||||||
|
const MIN_MIGRATION_LOCK_TIMEOUT_MS: u64 = 100;
|
||||||
|
const MIN_MIGRATION_TIMEOUT_MS: u64 = 1_000;
|
||||||
|
const MIN_POOL_CREATE_TIMEOUT_MS: u64 = 100;
|
||||||
|
const MIN_POOL_RECYCLE_TIMEOUT_MS: u64 = 100;
|
||||||
|
const MIN_POOL_WAIT_TIMEOUT_MS: u64 = 100;
|
||||||
|
const MIN_SHUTDOWN_TIMEOUT_MS: u64 = 100;
|
||||||
|
|
||||||
|
/// Backend identity understood by the common Store runtime independently from compiled Cargo features.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum StoreBackendKind {
|
||||||
|
/// Official PostgreSQL Store backend.
|
||||||
|
Postgres,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreBackendKind {
|
||||||
|
/// Returns the stable safe backend code used in diagnostics and configuration mapping.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> &'static str {
|
||||||
|
return match self {
|
||||||
|
Self::Postgres => "postgres",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLS policy accepted by the backend-neutral PostgreSQL settings surface.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum PostgresTlsMode {
|
||||||
|
/// Connect without TLS.
|
||||||
|
Disabled,
|
||||||
|
/// Require TLS and verify both the certificate chain and requested server identity.
|
||||||
|
VerifyFull,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded PostgreSQL connection-pool settings owned by the Store facade.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresPoolSettings {
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
create_timeout: std::time::Duration,
|
||||||
|
max_connections: u32,
|
||||||
|
recycle_timeout: std::time::Duration,
|
||||||
|
wait_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresPoolSettings {
|
||||||
|
/// Creates explicit PostgreSQL pool bounds without performing any I/O.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
max_connections: u32,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
wait_timeout: std::time::Duration,
|
||||||
|
create_timeout: std::time::Duration,
|
||||||
|
recycle_timeout: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
return Self { connect_timeout, create_timeout, max_connections, recycle_timeout, wait_timeout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the timeout for establishing one physical PostgreSQL connection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn connect_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.connect_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the timeout for creating one pooled PostgreSQL object.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn create_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.create_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum number of physical PostgreSQL connections owned by the pool.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_connections(&self) -> u32 {
|
||||||
|
return self.max_connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the timeout for recycling one pooled PostgreSQL object.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn recycle_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.recycle_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum time one acquisition can wait for pool capacity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn wait_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.wait_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates all pool bounds without opening a connection.
|
||||||
|
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||||
|
if self.max_connections < MIN_CONNECTIONS || self.max_connections > MAX_CONNECTIONS {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_store_api::Error::new(crate::ERROR_CODE_SETTINGS_INVALID, "PostgreSQL pool connection bound is invalid")
|
||||||
|
.with_context("field", "postgres.pool.max_connections")
|
||||||
|
.with_context("minimum", MIN_CONNECTIONS.to_string())
|
||||||
|
.with_context("maximum", MAX_CONNECTIONS.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let connect_validation = validate_duration("postgres.pool.connect_timeout", self.connect_timeout, MIN_CONNECT_TIMEOUT_MS, MAX_CONNECT_TIMEOUT_MS);
|
||||||
|
if let std::result::Result::Err(error) = connect_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let wait_validation = validate_duration("postgres.pool.wait_timeout", self.wait_timeout, MIN_POOL_WAIT_TIMEOUT_MS, MAX_POOL_WAIT_TIMEOUT_MS);
|
||||||
|
if let std::result::Result::Err(error) = wait_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let create_validation = validate_duration("postgres.pool.create_timeout", self.create_timeout, MIN_POOL_CREATE_TIMEOUT_MS, MAX_POOL_CREATE_TIMEOUT_MS);
|
||||||
|
if let std::result::Result::Err(error) = create_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let recycle_validation =
|
||||||
|
validate_duration("postgres.pool.recycle_timeout", self.recycle_timeout, MIN_POOL_RECYCLE_TIMEOUT_MS, MAX_POOL_RECYCLE_TIMEOUT_MS);
|
||||||
|
if let std::result::Result::Err(error) = recycle_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for PostgresPoolSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self::new(
|
||||||
|
DEFAULT_MAX_CONNECTIONS,
|
||||||
|
std::time::Duration::from_millis(DEFAULT_CONNECT_TIMEOUT_MS),
|
||||||
|
std::time::Duration::from_millis(DEFAULT_POOL_WAIT_TIMEOUT_MS),
|
||||||
|
std::time::Duration::from_millis(DEFAULT_POOL_CREATE_TIMEOUT_MS),
|
||||||
|
std::time::Duration::from_millis(DEFAULT_POOL_RECYCLE_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded PostgreSQL bootstrap settings owned by the Store facade.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresBootstrapSettings {
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
schema_autocreate: bool,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBootstrapSettings {
|
||||||
|
/// Creates bootstrap settings using the legacy single migration switch for source compatibility.
|
||||||
|
///
|
||||||
|
/// The supplied value is mapped to both schema auto-creation and schema auto-update. New code should prefer
|
||||||
|
/// [`Self::with_schema_policy`] when these policies need to differ.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(auto_migrate: bool, migration_timeout: std::time::Duration, migration_lock_timeout: std::time::Duration) -> Self {
|
||||||
|
return Self::with_schema_policy(auto_migrate, auto_migrate, migration_timeout, migration_lock_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates explicit schema creation/update policy and migration deadlines.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_schema_policy(
|
||||||
|
schema_autocreate: bool,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
return Self { migration_lock_timeout, migration_timeout, schema_autocreate, schema_autoupdate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the legacy pending-migration switch, mapped to the schema auto-update policy.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn auto_migrate(&self) -> bool {
|
||||||
|
return self.schema_autoupdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether an absent KSP-managed schema may be created or adopted during Store opening.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn schema_autocreate(&self) -> bool {
|
||||||
|
return self.schema_autocreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether pending migrations and safe additive schema repairs may be applied during Store opening.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn schema_autoupdate(&self) -> bool {
|
||||||
|
return self.schema_autoupdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded wait allowed for the private PostgreSQL migration lock.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn migration_lock_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.migration_lock_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bounded duration allowed for one migration/bootstrap run.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn migration_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.migration_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates bootstrap and migration deadlines without contacting PostgreSQL.
|
||||||
|
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||||
|
let migration_validation =
|
||||||
|
validate_duration("postgres.bootstrap.migration_timeout", self.migration_timeout, MIN_MIGRATION_TIMEOUT_MS, MAX_MIGRATION_TIMEOUT_MS);
|
||||||
|
if let std::result::Result::Err(error) = migration_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let lock_validation = validate_duration(
|
||||||
|
"postgres.bootstrap.migration_lock_timeout",
|
||||||
|
self.migration_lock_timeout,
|
||||||
|
MIN_MIGRATION_LOCK_TIMEOUT_MS,
|
||||||
|
MAX_MIGRATION_LOCK_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = lock_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for PostgresBootstrapSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
return Self::with_schema_policy(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
std::time::Duration::from_millis(DEFAULT_MIGRATION_TIMEOUT_MS),
|
||||||
|
std::time::Duration::from_millis(DEFAULT_MIGRATION_LOCK_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostgreSQL settings owned by the Store facade and independent from Config or backend implementation types.
|
||||||
|
pub struct PostgresStoreSettings {
|
||||||
|
bootstrap: PostgresBootstrapSettings,
|
||||||
|
connection_uri: std::string::String,
|
||||||
|
pool: PostgresPoolSettings,
|
||||||
|
tls_mode: PostgresTlsMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresStoreSettings {
|
||||||
|
/// Creates PostgreSQL Store settings from an explicitly supplied sensitive connection URI and typed runtime bounds.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
connection_uri: impl std::convert::Into<std::string::String>,
|
||||||
|
pool: PostgresPoolSettings,
|
||||||
|
tls_mode: PostgresTlsMode,
|
||||||
|
bootstrap: PostgresBootstrapSettings,
|
||||||
|
) -> Self {
|
||||||
|
return Self { bootstrap, connection_uri: connection_uri.into(), pool, tls_mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the PostgreSQL bootstrap settings without exposing the sensitive connection URI.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bootstrap(&self) -> PostgresBootstrapSettings {
|
||||||
|
return self.bootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitive PostgreSQL connection URI only to the compiled backend bridge.
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn connection_uri(&self) -> &str {
|
||||||
|
return self.connection_uri.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the PostgreSQL pool settings without exposing the sensitive connection URI.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool(&self) -> PostgresPoolSettings {
|
||||||
|
return self.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected PostgreSQL TLS policy without exposing the sensitive connection URI.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn tls_mode(&self) -> PostgresTlsMode {
|
||||||
|
return self.tls_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates backend-neutral PostgreSQL settings without parsing the URI or performing I/O.
|
||||||
|
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||||
|
if self.connection_uri.is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_store_api::Error::new(crate::ERROR_CODE_SETTINGS_INVALID, "PostgreSQL connection URI is required")
|
||||||
|
.with_context("field", "postgres.connection_uri"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pool_validation = self.pool.validate();
|
||||||
|
if let std::result::Result::Err(error) = pool_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let bootstrap_validation = self.bootstrap.validate();
|
||||||
|
if let std::result::Result::Err(error) = bootstrap_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for PostgresStoreSettings {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("PostgresStoreSettings")
|
||||||
|
.field("connection_uri", &"<redacted>")
|
||||||
|
.field("pool", &self.pool)
|
||||||
|
.field("tls_mode", &self.tls_mode)
|
||||||
|
.field("bootstrap", &self.bootstrap)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backend-specific settings selected through the common Store facade.
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum StoreBackendSettings {
|
||||||
|
/// Settings for the known PostgreSQL backend, whether or not its Cargo feature is compiled.
|
||||||
|
Postgres(PostgresStoreSettings),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreBackendSettings {
|
||||||
|
/// Returns the stable backend identity represented by these settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> StoreBackendKind {
|
||||||
|
return match self {
|
||||||
|
Self::Postgres(_) => StoreBackendKind::Postgres,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates backend-specific settings without performing I/O.
|
||||||
|
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||||
|
return match self {
|
||||||
|
Self::Postgres(settings) => settings.validate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete backend-neutral settings consumed by the common Store runtime facade.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StoreSettings {
|
||||||
|
backend: StoreBackendSettings,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
shutdown_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreSettings {
|
||||||
|
/// Creates complete Store runtime settings for one explicit logical network, backend and shutdown bound.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(network: ksp_store_api::RawNetworkId, backend: StoreBackendSettings, shutdown_timeout: std::time::Duration) -> Self {
|
||||||
|
return Self { backend, network, shutdown_timeout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected backend settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn backend(&self) -> &StoreBackendSettings {
|
||||||
|
return &self.backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected stable backend identity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn backend_kind(&self) -> StoreBackendKind {
|
||||||
|
return self.backend.kind();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the single logical network bound to this Store instance.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn network(&self) -> &ksp_store_api::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum duration allowed for explicit Store shutdown.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn shutdown_timeout(&self) -> std::time::Duration {
|
||||||
|
return self.shutdown_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates all backend-neutral Store settings before any backend I/O can start.
|
||||||
|
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||||
|
let shutdown_validation = validate_duration("shutdown_timeout", self.shutdown_timeout, MIN_SHUTDOWN_TIMEOUT_MS, MAX_SHUTDOWN_TIMEOUT_MS);
|
||||||
|
if let std::result::Result::Err(error) = shutdown_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let backend_validation = self.backend.validate();
|
||||||
|
if let std::result::Result::Err(error) = backend_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreSettings {
|
||||||
|
/// Creates settings using the common default shutdown bound while keeping network and backend construction explicit.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_default_shutdown(network: ksp_store_api::RawNetworkId, backend: StoreBackendSettings) -> Self {
|
||||||
|
return Self::new(network, backend, std::time::Duration::from_millis(DEFAULT_SHUTDOWN_TIMEOUT_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_duration(field: &'static str, value: std::time::Duration, minimum_ms: u64, maximum_ms: u64) -> ksp_store_api::Result<()> {
|
||||||
|
let minimum = std::time::Duration::from_millis(minimum_ms);
|
||||||
|
let maximum = std::time::Duration::from_millis(maximum_ms);
|
||||||
|
if value < minimum || value > maximum {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_store_api::Error::new(crate::ERROR_CODE_SETTINGS_INVALID, "Store runtime duration is outside the supported resource bound")
|
||||||
|
.with_context("field", field)
|
||||||
|
.with_context("minimum_ms", minimum_ms.to_string())
|
||||||
|
.with_context("maximum_ms", maximum_ms.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/settings.rs"]
|
||||||
|
mod tests;
|
||||||
501
crates/ksp-store-lib/src/store.rs
Normal file
501
crates/ksp-store-lib/src/store.rs
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/store.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
/// Opaque common Store runtime facade.
|
||||||
|
///
|
||||||
|
/// A successful value is returned only after the selected compiled backend has completed its bounded physical opening path. PostgreSQL pool, client, TLS and
|
||||||
|
/// driver types remain private to the backend crate.
|
||||||
|
pub struct Store {
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
runtime: StoreRuntime,
|
||||||
|
shutdown_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// Validates settings, selects the requested backend and opens one ready Store instance for exactly one logical network.
|
||||||
|
///
|
||||||
|
/// A known backend whose Cargo feature is absent is rejected before any I/O. A successful PostgreSQL result proves that one physical pooled connection has
|
||||||
|
/// been established under the typed TLS and timeout policy.
|
||||||
|
pub async fn open(settings: crate::StoreSettings) -> ksp_store_api::Result<Self> {
|
||||||
|
let validation = settings.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let backend_kind = settings.backend_kind();
|
||||||
|
let network = settings.network().clone();
|
||||||
|
let shutdown_timeout = settings.shutdown_timeout();
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
backend = backend_kind.code(),
|
||||||
|
network = network.as_str(),
|
||||||
|
"opening Store runtime"
|
||||||
|
);
|
||||||
|
return match settings.backend() {
|
||||||
|
crate::StoreBackendSettings::Postgres(postgres) => open_postgres(backend_kind, network, shutdown_timeout, postgres).await,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a safe synchronous runtime snapshot without performing backend I/O.
|
||||||
|
#[must_use]
|
||||||
|
pub fn runtime_snapshot(&self) -> crate::StoreRuntimeSnapshot {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => map_postgres_runtime_snapshot(backend.runtime_snapshot(), self.backend_kind, self.network.clone()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
return crate::StoreRuntimeSnapshot::new(self.backend_kind, self.network.clone(), 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the selected backend's lightweight bounded readiness probe and returns only portable redacted diagnostics.
|
||||||
|
pub async fn health(&self) -> crate::StoreHealthSnapshot {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let snapshot = backend.health().await;
|
||||||
|
map_postgres_health_snapshot(snapshot, self.backend_kind, self.network.clone())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
return crate::StoreHealthSnapshot::new(
|
||||||
|
crate::StoreHealthState::NotReady,
|
||||||
|
self.runtime_snapshot(),
|
||||||
|
std::option::Option::None,
|
||||||
|
0,
|
||||||
|
std::option::Option::Some(crate::ERROR_CODE_BACKEND_NOT_COMPILED),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly closes the Store runtime, consumes its facade handle and applies the configured bounded shutdown deadline.
|
||||||
|
pub async fn close(self) -> ksp_store_api::Result<()> {
|
||||||
|
let backend_kind = self.backend_kind;
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
let network = self.network;
|
||||||
|
let shutdown_timeout = self.shutdown_timeout;
|
||||||
|
let result = match self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => backend.close(shutdown_timeout).await,
|
||||||
|
};
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(()) => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
backend = backend_kind.code(),
|
||||||
|
network = network.as_str(),
|
||||||
|
"Store runtime closed"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(map_postgres_error(error, backend_kind, network.as_str())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(backend_kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Store {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("Store")
|
||||||
|
.field("backend_kind", &self.backend_kind)
|
||||||
|
.field("network", &self.network)
|
||||||
|
.field("shutdown_timeout", &self.shutdown_timeout)
|
||||||
|
.finish_non_exhaustive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRead for Store {
|
||||||
|
fn get_raw_transaction<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawTransactionReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransaction>>> {
|
||||||
|
let network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = network_check {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.get_raw_transaction(reference).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = reference;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_raw_transactions<'a>(
|
||||||
|
&'a self,
|
||||||
|
query: &'a ksp_store_api::RawTransactionQuery,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>>> {
|
||||||
|
let network_check = validate_operation_network(&self.network, query.network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = network_check {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.list_raw_transactions(query).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = query;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionWrite for Store {
|
||||||
|
fn persist_raw_transaction_acquisition<'a>(
|
||||||
|
&'a self,
|
||||||
|
transaction: ksp_store_api::RawTransaction,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
mode: ksp_store_api::RawTransactionAcquisitionMode,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawAcquisitionWriteOutcome>> {
|
||||||
|
let transaction_network = validate_operation_network(&self.network, transaction.reference().network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = transaction_network {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let observation_network = validate_operation_network(&self.network, observation.transaction().network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = observation_network {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.persist_raw_transaction_acquisition(transaction, observation, mode).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = transaction;
|
||||||
|
let _ = observation;
|
||||||
|
let _ = mode;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionObservationRead for Store {
|
||||||
|
fn get_raw_transaction_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation_key: &'a ksp_store_api::RawObservationKey,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionObservation>>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.get_raw_transaction_observation(observation_key).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = observation_key;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionObservationWrite for Store {
|
||||||
|
fn record_raw_transaction_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawObservationWriteOutcome>> {
|
||||||
|
let network_check = validate_operation_network(&self.network, observation.transaction().network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = network_check {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.record_raw_transaction_observation(observation).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = observation;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRetentionRead for Store {
|
||||||
|
fn get_raw_transaction_retention_state<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawTransactionReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawRetentionState>>> {
|
||||||
|
let network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = network_check {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.get_raw_transaction_retention_state(reference).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = reference;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_raw_transaction_tombstone<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawTransactionReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionTombstone>>> {
|
||||||
|
let network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = network_check {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.get_raw_transaction_tombstone(reference).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = reference;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRetentionWrite for Store {
|
||||||
|
fn transition_raw_transaction_retention<'a>(
|
||||||
|
&'a self,
|
||||||
|
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawRetentionWriteOutcome>> {
|
||||||
|
let network_check = validate_operation_network(&self.network, transition.reference().network(), self.backend_kind);
|
||||||
|
if let std::result::Result::Err(error) = network_check {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
{
|
||||||
|
return match &self.runtime {
|
||||||
|
StoreRuntime::Postgres(backend) => {
|
||||||
|
let result = backend.transition_raw_transaction_retention(transition).await;
|
||||||
|
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
{
|
||||||
|
let _ = transition;
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_operation_network(
|
||||||
|
store_network: &ksp_store_api::RawNetworkId,
|
||||||
|
operation_network: &ksp_store_api::RawNetworkId,
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
) -> ksp_store_api::Result<()> {
|
||||||
|
if store_network != operation_network {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_store_api::Error::new(crate::ERROR_CODE_WRONG_NETWORK, "Store operation targeted a different logical network")
|
||||||
|
.with_context("backend", backend_kind.code())
|
||||||
|
.with_context("network", store_network.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
enum StoreRuntime {
|
||||||
|
Postgres(ksp_store_postgres_lib::PostgresBackend),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
async fn open_postgres(
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
shutdown_timeout: std::time::Duration,
|
||||||
|
settings: &crate::PostgresStoreSettings,
|
||||||
|
) -> ksp_store_api::Result<Store> {
|
||||||
|
let bootstrap = settings.bootstrap();
|
||||||
|
let pool = settings.pool();
|
||||||
|
let tls_mode = match settings.tls_mode() {
|
||||||
|
crate::PostgresTlsMode::Disabled => ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled,
|
||||||
|
crate::PostgresTlsMode::VerifyFull => ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||||
|
};
|
||||||
|
let backend_settings = ksp_store_postgres_lib::PostgresBackendSettings::with_schema_policy(
|
||||||
|
network.clone(),
|
||||||
|
settings.connection_uri(),
|
||||||
|
pool.max_connections(),
|
||||||
|
pool.connect_timeout(),
|
||||||
|
pool.wait_timeout(),
|
||||||
|
pool.create_timeout(),
|
||||||
|
pool.recycle_timeout(),
|
||||||
|
tls_mode,
|
||||||
|
bootstrap.schema_autocreate(),
|
||||||
|
bootstrap.schema_autoupdate(),
|
||||||
|
bootstrap.migration_timeout(),
|
||||||
|
bootstrap.migration_lock_timeout(),
|
||||||
|
);
|
||||||
|
let opened = ksp_store_postgres_lib::PostgresBackend::open(backend_settings).await;
|
||||||
|
return match opened {
|
||||||
|
std::result::Result::Ok(backend) => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
backend = backend_kind.code(),
|
||||||
|
network = network.as_str(),
|
||||||
|
"Store backend is physically ready"
|
||||||
|
);
|
||||||
|
std::result::Result::Ok(Store { backend_kind, network, runtime: StoreRuntime::Postgres(backend), shutdown_timeout })
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(map_postgres_error(error, backend_kind, network.as_str())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
async fn open_postgres(
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
_network: ksp_store_api::RawNetworkId,
|
||||||
|
_shutdown_timeout: std::time::Duration,
|
||||||
|
_settings: &crate::PostgresStoreSettings,
|
||||||
|
) -> ksp_store_api::Result<Store> {
|
||||||
|
return std::result::Result::Err(unavailable_runtime_error(backend_kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
fn map_postgres_error(error: ksp_store_postgres_lib::PostgresBackendError, backend_kind: crate::StoreBackendKind, network: &str) -> ksp_store_api::Error {
|
||||||
|
let code = postgres_error_code(error.kind());
|
||||||
|
return ksp_store_api::Error::new(code, "PostgreSQL Store backend operation failed")
|
||||||
|
.with_context("backend", backend_kind.code())
|
||||||
|
.with_context("network", network)
|
||||||
|
.with_context("phase", error.phase());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
fn map_postgres_runtime_snapshot(
|
||||||
|
snapshot: ksp_store_postgres_lib::PostgresBackendRuntimeSnapshot,
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
) -> crate::StoreRuntimeSnapshot {
|
||||||
|
return crate::StoreRuntimeSnapshot::new(
|
||||||
|
backend_kind,
|
||||||
|
network,
|
||||||
|
snapshot.pool_capacity(),
|
||||||
|
snapshot.pool_size(),
|
||||||
|
snapshot.pool_available(),
|
||||||
|
snapshot.pool_waiting(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
fn map_postgres_health_snapshot(
|
||||||
|
snapshot: ksp_store_postgres_lib::PostgresBackendHealthSnapshot,
|
||||||
|
backend_kind: crate::StoreBackendKind,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
) -> crate::StoreHealthSnapshot {
|
||||||
|
let state = if snapshot.is_ready() { crate::StoreHealthState::Ready } else { crate::StoreHealthState::NotReady };
|
||||||
|
let error_code = snapshot.error_kind().map(|kind| return postgres_error_code(kind));
|
||||||
|
let runtime = map_postgres_runtime_snapshot(snapshot.runtime().clone(), backend_kind, network);
|
||||||
|
return crate::StoreHealthSnapshot::new(state, runtime, snapshot.migration_version(), snapshot.pending_migration_count(), error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
fn postgres_error_code(kind: ksp_store_postgres_lib::PostgresBackendErrorKind) -> ksp_store_api::ErrorCode {
|
||||||
|
return match kind {
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => crate::ERROR_CODE_POSTGRES_CONFIG_INVALID,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => crate::ERROR_CODE_POSTGRES_CONNECT_FAILED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict => ksp_store_api::ERROR_CODE_RAW_CONFLICT,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid => crate::ERROR_CODE_POSTGRES_DATA_INVALID,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed => crate::ERROR_CODE_POSTGRES_HEALTH_FAILED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed => crate::ERROR_CODE_POSTGRES_MIGRATION_FAILED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => crate::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported => crate::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid => ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed => crate::ERROR_CODE_POSTGRES_READ_FAILED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound => crate::ERROR_CODE_RAW_REFERENCE_NOT_FOUND,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported => crate::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => crate::ERROR_CODE_POSTGRES_SCHEMA_NEWER,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => crate::ERROR_CODE_SHUTDOWN_TIMEOUT,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => crate::ERROR_CODE_POSTGRES_TLS_FAILED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed => crate::ERROR_CODE_POSTGRES_WRITE_FAILED,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork => crate::ERROR_CODE_WRONG_NETWORK,
|
||||||
|
_ => crate::ERROR_CODE_BACKEND_OPEN_FAILED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
fn unavailable_runtime_error(backend_kind: crate::StoreBackendKind) -> ksp_store_api::Error {
|
||||||
|
return ksp_store_api::Error::new(crate::ERROR_CODE_BACKEND_NOT_COMPILED, "Selected Store backend is not compiled")
|
||||||
|
.with_context("backend", backend_kind.code());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/store.rs"]
|
||||||
|
mod tests;
|
||||||
93
crates/ksp-store-lib/tests/dependency_boundary.rs
Normal file
93
crates/ksp-store-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/dependency_boundary.rs
|
||||||
|
// version: 7
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Cargo-feature and dependency-boundary canaries for the common Store runtime facade.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_manifest_keeps_backend_physical_dependencies_out_of_facade() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
assert!(manifest.contains("default = [\"postgres\"]"));
|
||||||
|
assert!(manifest.contains("postgres = [\"dep:ksp-store-postgres-lib\"]"));
|
||||||
|
assert!(manifest.contains("ksp-logging-lib = { path = \"../ksp-logging-lib\" }"));
|
||||||
|
assert!(manifest.contains("ksp-store-api = { path = \"../ksp-store-api\" }"));
|
||||||
|
assert!(manifest.contains("ksp-store-postgres-lib = { path = \"../ksp-store-postgres-lib\", optional = true }"));
|
||||||
|
for forbidden in [
|
||||||
|
"ksp-config-lib",
|
||||||
|
"ksp-materializer",
|
||||||
|
"ksp-program",
|
||||||
|
"ksp-onchain-transport-lib",
|
||||||
|
"ksp-offchain-transport-lib",
|
||||||
|
"tokio-postgres",
|
||||||
|
"deadpool-postgres",
|
||||||
|
"rustls",
|
||||||
|
] {
|
||||||
|
assert!(!manifest.contains(forbidden), "forbidden Store facade dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_facade_exposes_no_physical_postgres_types_or_environment_bypass() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("pub use self::settings::StoreSettings;"));
|
||||||
|
assert!(crate_root.contains("pub use self::health::StoreHealthSnapshot;"));
|
||||||
|
assert!(crate_root.contains("pub use self::health::StoreRuntimeSnapshot;"));
|
||||||
|
assert!(crate_root.contains("pub use self::store::Store;"));
|
||||||
|
assert!(crate_root.contains("pub use ksp_store_api::RawTransaction;"));
|
||||||
|
assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;"));
|
||||||
|
for forbidden in [
|
||||||
|
"pub mod ",
|
||||||
|
"pub use ksp_store_postgres_lib",
|
||||||
|
"tokio_postgres",
|
||||||
|
"deadpool_postgres",
|
||||||
|
"rustls::",
|
||||||
|
"deadpool::managed::Pool",
|
||||||
|
"tokio_postgres::Client",
|
||||||
|
"tokio_postgres::Row",
|
||||||
|
"tokio_postgres::Statement",
|
||||||
|
] {
|
||||||
|
assert!(!crate_root.contains(forbidden), "forbidden physical backend facade surface detected: {forbidden}");
|
||||||
|
}
|
||||||
|
let production = format!("{}\n{}\n{}", include_str!("../src/health.rs"), include_str!("../src/settings.rs"), include_str!("../src/store.rs"));
|
||||||
|
for forbidden in ["ksp_config_lib", "std::env", "dotenv", "PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", ".pgpass", "tokio_postgres", "deadpool_postgres"] {
|
||||||
|
assert!(!production.contains(forbidden), "forbidden Store facade ownership bypass detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_facade_dispatches_six_raw_transaction_capabilities_without_physical_leak() {
|
||||||
|
let store = include_str!("../src/store.rs");
|
||||||
|
for required in [
|
||||||
|
"impl ksp_store_api::RawTransactionRead for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionWrite for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationRead for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationWrite for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionRead for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionWrite for Store",
|
||||||
|
"validate_operation_network",
|
||||||
|
"StoreRuntime::Postgres(backend)",
|
||||||
|
"map_postgres_error",
|
||||||
|
] {
|
||||||
|
assert!(store.contains(required), "missing pre.008 Store capability dispatch contract: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"impl ksp_store_api::RawAccountStateRead for Store",
|
||||||
|
"impl ksp_store_api::RawAccountStateWrite for Store",
|
||||||
|
"impl ksp_store_api::RawAccountObservationRead for Store",
|
||||||
|
"impl ksp_store_api::RawAccountObservationWrite for Store",
|
||||||
|
"tokio_postgres::",
|
||||||
|
"deadpool_postgres::",
|
||||||
|
"CREATE TABLE",
|
||||||
|
"INSERT INTO",
|
||||||
|
"UPDATE ksp_",
|
||||||
|
"DELETE FROM",
|
||||||
|
] {
|
||||||
|
assert!(!store.contains(forbidden), "pre.008 facade leaked physical or RawAccount scope: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
55
crates/ksp-store-lib/tests/feature_mismatch.rs
Normal file
55
crates/ksp-store-lib/tests/feature_mismatch.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/feature_mismatch.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Feature-selection and pre-I/O PostgreSQL failure canaries for the common Store facade.
|
||||||
|
|
||||||
|
fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||||
|
let mut future = std::boxed::Box::pin(future);
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
return match std::future::Future::poll(future.as_mut(), &mut context) {
|
||||||
|
std::task::Poll::Ready(value) => value,
|
||||||
|
std::task::Poll::Pending => panic!("Store pre-I/O feature/config rejection unexpectedly became pending"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_network() -> ksp_store_lib::RawNetworkId {
|
||||||
|
return match ksp_store_lib::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid test network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(connection_uri: &str) -> ksp_store_lib::StoreSettings {
|
||||||
|
let postgres = ksp_store_lib::PostgresStoreSettings::new(
|
||||||
|
connection_uri,
|
||||||
|
ksp_store_lib::PostgresPoolSettings::default(),
|
||||||
|
ksp_store_lib::PostgresTlsMode::Disabled,
|
||||||
|
ksp_store_lib::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
return ksp_store_lib::StoreSettings::with_default_shutdown(valid_network(), ksp_store_lib::StoreBackendSettings::Postgres(postgres));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
#[test]
|
||||||
|
fn pre_005_known_postgres_without_feature_returns_stable_error_before_io() {
|
||||||
|
let result = poll_ready(ksp_store_lib::Store::open(settings("postgresql://operator-supplied-sensitive-value@localhost/ksp")));
|
||||||
|
let error = result.err();
|
||||||
|
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(ksp_store_lib::ERROR_CODE_BACKEND_NOT_COMPILED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
#[test]
|
||||||
|
fn pre_005_compiled_postgres_rejects_malformed_uri_without_secret_leak_before_io() {
|
||||||
|
let secret_canary = "not-a-postgresql-uri-secret-canary";
|
||||||
|
let result = poll_ready(ksp_store_lib::Store::open(settings(secret_canary)));
|
||||||
|
let error = result.err();
|
||||||
|
assert_eq!(error.as_ref().map(|value| return value.code()), std::option::Option::Some(ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID));
|
||||||
|
assert!(!format!("{error:?}").contains(secret_canary));
|
||||||
|
return;
|
||||||
|
}
|
||||||
289
crates/ksp-store-lib/tests/hardening_completeness.rs
Normal file
289
crates/ksp-store-lib/tests/hardening_completeness.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/hardening_completeness.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Hardening, exact-surface and feature-matrix canaries for the common Store facade.
|
||||||
|
|
||||||
|
const SECRET_CANARY: &str = "KSP-STORE-SECRET-CANARY-PRE009";
|
||||||
|
|
||||||
|
fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||||
|
let mut future = std::boxed::Box::pin(future);
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
return match std::future::Future::poll(future.as_mut(), &mut context) {
|
||||||
|
std::task::Poll::Ready(value) => value,
|
||||||
|
std::task::Poll::Pending => panic!("Store hardening pre-I/O rejection unexpectedly became pending"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_network() -> ksp_store_lib::RawNetworkId {
|
||||||
|
return match ksp_store_lib::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid hardening network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hostile_settings(connection_uri: &str) -> ksp_store_lib::StoreSettings {
|
||||||
|
let postgres = ksp_store_lib::PostgresStoreSettings::new(
|
||||||
|
connection_uri,
|
||||||
|
ksp_store_lib::PostgresPoolSettings::default(),
|
||||||
|
ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||||
|
ksp_store_lib::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
return ksp_store_lib::StoreSettings::with_default_shutdown(valid_network(), ksp_store_lib::StoreBackendSettings::Postgres(postgres));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_reexport_names(source: &str) -> std::vec::Vec<&str> {
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.starts_with("pub use ") || !trimmed.ends_with(';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let without_semicolon = trimmed.trim_end_matches(';');
|
||||||
|
let name = match without_semicolon.rsplit("::").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_dependency_names(source: &str) -> std::vec::Vec<&str> {
|
||||||
|
let dependencies_tail = match source.split("[dependencies]").nth(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in dependencies.lines() {
|
||||||
|
let content = match line.split('#').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim(),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if content.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = match content.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !key.is_empty() {
|
||||||
|
names.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_facade_modules_and_crate_root_exports_are_exact() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
for required in ["mod constants;", "mod error;", "mod health;", "mod settings;", "mod store;"] {
|
||||||
|
assert!(crate_root.contains(required), "missing Store facade module: {required}");
|
||||||
|
}
|
||||||
|
assert!(!crate_root.contains("pub mod "));
|
||||||
|
let actual = public_reexport_names(crate_root);
|
||||||
|
let mut expected = [
|
||||||
|
"ERROR_CODE_BACKEND_CLOSED",
|
||||||
|
"ERROR_CODE_BACKEND_NOT_COMPILED",
|
||||||
|
"ERROR_CODE_BACKEND_OPEN_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_CONFIG_INVALID",
|
||||||
|
"ERROR_CODE_POSTGRES_CONNECT_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_DATA_INVALID",
|
||||||
|
"ERROR_CODE_POSTGRES_HEALTH_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_MIGRATION_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_MIGRATION_MISMATCH",
|
||||||
|
"ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED",
|
||||||
|
"ERROR_CODE_POSTGRES_POOL_TIMEOUT",
|
||||||
|
"ERROR_CODE_POSTGRES_READ_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED",
|
||||||
|
"ERROR_CODE_POSTGRES_SCHEMA_NEWER",
|
||||||
|
"ERROR_CODE_POSTGRES_TLS_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_WRITE_FAILED",
|
||||||
|
"ERROR_CODE_RAW_CONFLICT",
|
||||||
|
"ERROR_CODE_RAW_MODEL_INVALID",
|
||||||
|
"ERROR_CODE_RAW_PAYLOAD_INVALID",
|
||||||
|
"ERROR_CODE_RAW_PROVENANCE_INVALID",
|
||||||
|
"ERROR_CODE_RAW_QUERY_INVALID",
|
||||||
|
"ERROR_CODE_RAW_REFERENCE_NOT_FOUND",
|
||||||
|
"ERROR_CODE_RAW_RETENTION_INVALID",
|
||||||
|
"ERROR_CODE_SETTINGS_INVALID",
|
||||||
|
"ERROR_CODE_SHUTDOWN_TIMEOUT",
|
||||||
|
"ERROR_CODE_WRONG_NETWORK",
|
||||||
|
"Error",
|
||||||
|
"ErrorCode",
|
||||||
|
"ErrorContext",
|
||||||
|
"MAX_RAW_ACCOUNT_DATA_BYTES",
|
||||||
|
"MAX_RAW_CODE_BYTES",
|
||||||
|
"MAX_RAW_PAGE_CURSOR_BYTES",
|
||||||
|
"MAX_RAW_PAYLOAD_BYTES",
|
||||||
|
"MAX_RAW_SOURCE_PAYLOAD_BYTES",
|
||||||
|
"MAX_RAW_UNIX_MILLIS",
|
||||||
|
"PostgresBootstrapSettings",
|
||||||
|
"PostgresPoolSettings",
|
||||||
|
"PostgresStoreSettings",
|
||||||
|
"PostgresTlsMode",
|
||||||
|
"Pubkey",
|
||||||
|
"RawAccountObservation",
|
||||||
|
"RawAccountObservationRead",
|
||||||
|
"RawAccountObservationWrite",
|
||||||
|
"RawAccountState",
|
||||||
|
"RawAccountStateQuery",
|
||||||
|
"RawAccountStateRead",
|
||||||
|
"RawAccountStateReference",
|
||||||
|
"RawAccountStateWrite",
|
||||||
|
"RawAcquisitionOrigin",
|
||||||
|
"RawAcquisitionProvenance",
|
||||||
|
"RawAcquisitionWriteOutcome",
|
||||||
|
"RawContentHash",
|
||||||
|
"RawEntityWriteOutcome",
|
||||||
|
"RawFormatId",
|
||||||
|
"RawNetworkId",
|
||||||
|
"RawObservationKey",
|
||||||
|
"RawObservationWriteOutcome",
|
||||||
|
"RawPage",
|
||||||
|
"RawPageCursor",
|
||||||
|
"RawPageLimit",
|
||||||
|
"RawPageRequest",
|
||||||
|
"RawPayload",
|
||||||
|
"RawProvenanceCode",
|
||||||
|
"RawRetentionState",
|
||||||
|
"RawRetentionWriteOutcome",
|
||||||
|
"RawSlotRange",
|
||||||
|
"RawSortDirection",
|
||||||
|
"RawTimestamp",
|
||||||
|
"RawTransaction",
|
||||||
|
"RawTransactionAcquisitionMode",
|
||||||
|
"RawTransactionObservation",
|
||||||
|
"RawTransactionObservationRead",
|
||||||
|
"RawTransactionObservationWrite",
|
||||||
|
"RawTransactionQuery",
|
||||||
|
"RawTransactionRead",
|
||||||
|
"RawTransactionReference",
|
||||||
|
"RawTransactionRetentionRead",
|
||||||
|
"RawTransactionRetentionTransition",
|
||||||
|
"RawTransactionRetentionWrite",
|
||||||
|
"RawTransactionSignature",
|
||||||
|
"RawTransactionTombstone",
|
||||||
|
"RawTransactionWrite",
|
||||||
|
"Result",
|
||||||
|
"Store",
|
||||||
|
"StoreApiFuture",
|
||||||
|
"StoreBackendKind",
|
||||||
|
"StoreBackendSettings",
|
||||||
|
"StoreHealthSnapshot",
|
||||||
|
"StoreHealthState",
|
||||||
|
"StoreRuntimeSnapshot",
|
||||||
|
"StoreSettings",
|
||||||
|
];
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(actual.as_slice(), expected.as_slice());
|
||||||
|
assert_eq!(actual.len(), 91);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_facade_manifest_and_feature_contract_are_exact() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
assert!(manifest.contains("default = [\"postgres\"]"));
|
||||||
|
assert!(manifest.contains("postgres = [\"dep:ksp-store-postgres-lib\"]"));
|
||||||
|
let actual = manifest_dependency_names(manifest);
|
||||||
|
let expected = ["ksp-logging-lib", "ksp-store-api", "ksp-store-postgres-lib"];
|
||||||
|
assert_eq!(actual.as_slice(), expected.as_slice());
|
||||||
|
for forbidden in ["tokio-postgres", "deadpool-postgres", "rustls", "sqlx", "ksp-config-lib", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib"] {
|
||||||
|
assert!(!manifest.contains(forbidden), "forbidden Store facade dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_secret_canary_never_crosses_settings_or_pre_io_error_debug() {
|
||||||
|
let malformed = std::format!("not-a-postgresql-uri-{SECRET_CANARY}");
|
||||||
|
let settings = hostile_settings(malformed.as_str());
|
||||||
|
let rendered = std::format!("{settings:?}");
|
||||||
|
assert!(!rendered.contains(SECRET_CANARY));
|
||||||
|
assert!(rendered.contains("<redacted>"));
|
||||||
|
let result = poll_ready(ksp_store_lib::Store::open(settings));
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => panic!("hostile Store settings unexpectedly opened"),
|
||||||
|
};
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
assert_eq!(error.code(), ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID);
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
assert_eq!(error.code(), ksp_store_lib::ERROR_CODE_BACKEND_NOT_COMPILED);
|
||||||
|
assert!(!std::format!("{error}").contains(SECRET_CANARY));
|
||||||
|
assert!(!std::format!("{error:?}").contains(SECRET_CANARY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_facade_production_sources_keep_config_env_physical_sql_and_backend_handles_out() {
|
||||||
|
let production = std::format!(
|
||||||
|
"{}
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
{}",
|
||||||
|
include_str!("../src/error.rs"),
|
||||||
|
include_str!("../src/health.rs"),
|
||||||
|
include_str!("../src/settings.rs"),
|
||||||
|
include_str!("../src/store.rs")
|
||||||
|
);
|
||||||
|
for forbidden in [
|
||||||
|
"ksp_config_lib",
|
||||||
|
"std::env",
|
||||||
|
"dotenv",
|
||||||
|
"PGHOST",
|
||||||
|
"PGPORT",
|
||||||
|
"PGUSER",
|
||||||
|
"PGPASSWORD",
|
||||||
|
".pgpass",
|
||||||
|
"tokio_postgres::Client",
|
||||||
|
"tokio_postgres::Row",
|
||||||
|
"tokio_postgres::Statement",
|
||||||
|
"deadpool_postgres::Pool",
|
||||||
|
"sqlx::",
|
||||||
|
"CREATE TABLE",
|
||||||
|
"INSERT INTO",
|
||||||
|
"UPDATE ",
|
||||||
|
"DELETE FROM",
|
||||||
|
] {
|
||||||
|
assert!(!production.contains(forbidden), "forbidden facade ownership/runtime material detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_facade_raw_transaction_capability_inventory_is_exact_and_raw_account_scope_stays_closed() {
|
||||||
|
let store = include_str!("../src/store.rs");
|
||||||
|
let capability_impls = [
|
||||||
|
"impl ksp_store_api::RawTransactionRead for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionWrite for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationRead for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationWrite for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionRead for Store",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionWrite for Store",
|
||||||
|
];
|
||||||
|
for implementation in capability_impls {
|
||||||
|
assert_eq!(store.matches(implementation).count(), 1, "unexpected Store capability implementation inventory: {implementation}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"impl ksp_store_api::RawAccountStateRead for Store",
|
||||||
|
"impl ksp_store_api::RawAccountStateWrite for Store",
|
||||||
|
"impl ksp_store_api::RawAccountObservationRead for Store",
|
||||||
|
"impl ksp_store_api::RawAccountObservationWrite for Store",
|
||||||
|
] {
|
||||||
|
assert!(!store.contains(forbidden), "RawAccountState scope opened in Store during RawTransaction hardening: {forbidden}");
|
||||||
|
}
|
||||||
|
assert_eq!(store.matches("validate_operation_network(").count(), 9);
|
||||||
|
return;
|
||||||
|
}
|
||||||
94
crates/ksp-store-lib/tests/public_api.rs
Normal file
94
crates/ksp-store-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/public_api.rs
|
||||||
|
// version: 7
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Public API canaries for Store settings, lifecycle and Store API reexports.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_settings_and_lifecycle_contract_are_available_from_crate_root() {
|
||||||
|
let postgres = ksp_store_lib::PostgresStoreSettings::new(
|
||||||
|
"postgresql://operator-supplied-sensitive-value",
|
||||||
|
ksp_store_lib::PostgresPoolSettings::default(),
|
||||||
|
ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||||
|
ksp_store_lib::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
let network = match ksp_store_lib::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid public API network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
let settings = ksp_store_lib::StoreSettings::with_default_shutdown(network, ksp_store_lib::StoreBackendSettings::Postgres(postgres));
|
||||||
|
assert_eq!(settings.backend_kind(), ksp_store_lib::StoreBackendKind::Postgres);
|
||||||
|
assert_eq!(settings.network().as_str(), "devnet");
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
let _open = ksp_store_lib::Store::open;
|
||||||
|
let _close = ksp_store_lib::Store::close;
|
||||||
|
let _runtime_snapshot = ksp_store_lib::Store::runtime_snapshot;
|
||||||
|
let _health = ksp_store_lib::Store::health;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_common_and_postgres_error_codes_are_stable_and_store_owned() {
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_SETTINGS_INVALID.domain(), "store");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_SETTINGS_INVALID.code(), "settings_invalid");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_NOT_COMPILED.code(), "backend_not_compiled");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_OPEN_FAILED.code(), "backend_open_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID.code(), "postgres_config_invalid");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONNECT_FAILED.code(), "postgres_connect_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_DATA_INVALID.code(), "postgres_data_invalid");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_HEALTH_FAILED.code(), "postgres_health_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_POOL_TIMEOUT.code(), "postgres_pool_timeout");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_READ_FAILED.code(), "postgres_read_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED.code(), "postgres_retention_compaction_unsupported");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_FAILED.code(), "postgres_migration_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH.code(), "postgres_migration_mismatch");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED.code(), "postgres_page_limit_unsupported");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_SCHEMA_NEWER.code(), "postgres_schema_newer");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_TLS_FAILED.code(), "postgres_tls_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_WRITE_FAILED.code(), "postgres_write_failed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_RAW_REFERENCE_NOT_FOUND.code(), "raw_reference_not_found");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_CLOSED.code(), "backend_closed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_SHUTDOWN_TIMEOUT.code(), "shutdown_timeout");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_WRONG_NETWORK.code(), "wrong_network");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_facade_reexports_backend_agnostic_store_api_types() {
|
||||||
|
let _raw_transaction = std::mem::size_of::<std::option::Option<ksp_store_lib::RawTransaction>>();
|
||||||
|
let _raw_account_state = std::mem::size_of::<std::option::Option<ksp_store_lib::RawAccountState>>();
|
||||||
|
let _query = std::mem::size_of::<std::option::Option<ksp_store_lib::RawTransactionQuery>>();
|
||||||
|
let _capability = std::mem::size_of::<std::option::Option<&dyn ksp_store_lib::RawTransactionRead>>();
|
||||||
|
let _result: ksp_store_lib::Result<()> = std::result::Result::Ok(());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_health_and_runtime_snapshot_types_are_portable_crate_root_contracts() {
|
||||||
|
let _state = std::mem::size_of::<std::option::Option<ksp_store_lib::StoreHealthState>>();
|
||||||
|
let _health = std::mem::size_of::<std::option::Option<ksp_store_lib::StoreHealthSnapshot>>();
|
||||||
|
let _runtime = std::mem::size_of::<std::option::Option<ksp_store_lib::StoreRuntimeSnapshot>>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_raw_transaction_capabilities<T>()
|
||||||
|
where
|
||||||
|
T: ksp_store_lib::RawTransactionRead
|
||||||
|
+ ksp_store_lib::RawTransactionWrite
|
||||||
|
+ ksp_store_lib::RawTransactionObservationRead
|
||||||
|
+ ksp_store_lib::RawTransactionObservationWrite
|
||||||
|
+ ksp_store_lib::RawTransactionRetentionRead
|
||||||
|
+ ksp_store_lib::RawTransactionRetentionWrite,
|
||||||
|
{
|
||||||
|
let _marker = std::marker::PhantomData::<T>;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_store_facade_implements_all_six_raw_transaction_capabilities() {
|
||||||
|
assert_raw_transaction_capabilities::<ksp_store_lib::Store>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
45
crates/ksp-store-lib/unit_tests/health.rs
Normal file
45
crates/ksp-store-lib/unit_tests/health.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// file: crates/ksp-store-lib/unit_tests/health.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn network() -> ksp_store_api::RawNetworkId {
|
||||||
|
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid Store health test network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_snapshot_is_backend_neutral_and_contains_only_safe_counts() {
|
||||||
|
let runtime = crate::StoreRuntimeSnapshot::new(crate::StoreBackendKind::Postgres, network(), 8, 3, 2, 1);
|
||||||
|
assert_eq!(runtime.backend_kind(), crate::StoreBackendKind::Postgres);
|
||||||
|
assert_eq!(runtime.network().as_str(), "devnet");
|
||||||
|
assert_eq!(runtime.pool_capacity(), 8);
|
||||||
|
assert_eq!(runtime.pool_size(), 3);
|
||||||
|
assert_eq!(runtime.pool_available(), 2);
|
||||||
|
assert_eq!(runtime.pool_waiting(), 1);
|
||||||
|
let rendered = format!("{runtime:?}");
|
||||||
|
for forbidden in ["postgresql://", "password", "username", "database", "SELECT ", "ksp_store_schema_migrations"] {
|
||||||
|
assert!(!rendered.contains(forbidden), "unsafe runtime snapshot material detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_snapshot_carries_only_safe_state_migration_and_error_code() {
|
||||||
|
let runtime = crate::StoreRuntimeSnapshot::new(crate::StoreBackendKind::Postgres, network(), 8, 1, 1, 0);
|
||||||
|
let ready = crate::StoreHealthSnapshot::new(crate::StoreHealthState::Ready, runtime.clone(), std::option::Option::Some(0), 0, std::option::Option::None);
|
||||||
|
assert_eq!(ready.state(), crate::StoreHealthState::Ready);
|
||||||
|
assert_eq!(ready.migration_version(), std::option::Option::Some(0));
|
||||||
|
assert_eq!(ready.pending_migration_count(), 0);
|
||||||
|
assert_eq!(ready.last_error_code(), std::option::Option::None);
|
||||||
|
let not_ready = crate::StoreHealthSnapshot::new(
|
||||||
|
crate::StoreHealthState::NotReady,
|
||||||
|
runtime,
|
||||||
|
std::option::Option::None,
|
||||||
|
0,
|
||||||
|
std::option::Option::Some(crate::ERROR_CODE_POSTGRES_HEALTH_FAILED),
|
||||||
|
);
|
||||||
|
assert_eq!(not_ready.state(), crate::StoreHealthState::NotReady);
|
||||||
|
assert_eq!(not_ready.last_error_code(), std::option::Option::Some(crate::ERROR_CODE_POSTGRES_HEALTH_FAILED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
155
crates/ksp-store-lib/unit_tests/settings.rs
Normal file
155
crates/ksp-store-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// file: crates/ksp-store-lib/unit_tests/settings.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
fn valid_network() -> crate::RawNetworkId {
|
||||||
|
return match crate::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid test network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_postgres_settings() -> crate::PostgresStoreSettings {
|
||||||
|
return crate::PostgresStoreSettings::new(
|
||||||
|
"postgresql://secret-user:secret-password@db.internal/ksp",
|
||||||
|
crate::PostgresPoolSettings::default(),
|
||||||
|
crate::PostgresTlsMode::VerifyFull,
|
||||||
|
crate::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_the_pre_001_runtime_bounds() {
|
||||||
|
let pool = crate::PostgresPoolSettings::default();
|
||||||
|
assert_eq!(pool.max_connections(), 8);
|
||||||
|
assert_eq!(pool.connect_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
assert_eq!(pool.wait_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
assert_eq!(pool.create_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
assert_eq!(pool.recycle_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
let bootstrap = crate::PostgresBootstrapSettings::default();
|
||||||
|
assert!(bootstrap.auto_migrate());
|
||||||
|
assert!(bootstrap.schema_autocreate());
|
||||||
|
assert!(bootstrap.schema_autoupdate());
|
||||||
|
let split_policy =
|
||||||
|
crate::PostgresBootstrapSettings::with_schema_policy(false, true, std::time::Duration::from_millis(30_000), std::time::Duration::from_millis(10_000));
|
||||||
|
assert!(!split_policy.schema_autocreate());
|
||||||
|
assert!(split_policy.schema_autoupdate());
|
||||||
|
assert_eq!(bootstrap.migration_timeout(), std::time::Duration::from_millis(30_000));
|
||||||
|
assert_eq!(bootstrap.migration_lock_timeout(), std::time::Duration::from_millis(10_000));
|
||||||
|
let store = crate::StoreSettings::with_default_shutdown(valid_network(), crate::StoreBackendSettings::Postgres(valid_postgres_settings()));
|
||||||
|
assert_eq!(store.shutdown_timeout(), std::time::Duration::from_millis(5_000));
|
||||||
|
assert_eq!(store.backend_kind(), crate::StoreBackendKind::Postgres);
|
||||||
|
assert_eq!(store.network().as_str(), "devnet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_runtime_boundaries_validate_and_adjacent_values_are_rejected() {
|
||||||
|
let minimum_pool = crate::PostgresPoolSettings::new(
|
||||||
|
1,
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
assert!(minimum_pool.validate().is_ok());
|
||||||
|
let maximum_pool = crate::PostgresPoolSettings::new(
|
||||||
|
64,
|
||||||
|
std::time::Duration::from_millis(60_000),
|
||||||
|
std::time::Duration::from_millis(60_000),
|
||||||
|
std::time::Duration::from_millis(60_000),
|
||||||
|
std::time::Duration::from_millis(60_000),
|
||||||
|
);
|
||||||
|
assert!(maximum_pool.validate().is_ok());
|
||||||
|
assert!(
|
||||||
|
crate::PostgresPoolSettings::new(
|
||||||
|
0,
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crate::PostgresPoolSettings::new(
|
||||||
|
65,
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crate::PostgresPoolSettings::new(
|
||||||
|
1,
|
||||||
|
std::time::Duration::from_millis(99),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crate::PostgresPoolSettings::new(
|
||||||
|
1,
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(60_001),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let minimum_bootstrap = crate::PostgresBootstrapSettings::new(false, std::time::Duration::from_millis(1_000), std::time::Duration::from_millis(100));
|
||||||
|
assert!(minimum_bootstrap.validate().is_ok());
|
||||||
|
let maximum_bootstrap = crate::PostgresBootstrapSettings::new(true, std::time::Duration::from_millis(300_000), std::time::Duration::from_millis(120_000));
|
||||||
|
assert!(maximum_bootstrap.validate().is_ok());
|
||||||
|
assert!(
|
||||||
|
crate::PostgresBootstrapSettings::new(true, std::time::Duration::from_millis(999), std::time::Duration::from_millis(100),)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crate::PostgresBootstrapSettings::new(true, std::time::Duration::from_millis(1_000), std::time::Duration::from_millis(120_001),)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_shutdown_bound_is_independent_from_backend_and_rejects_outside_values() {
|
||||||
|
let valid =
|
||||||
|
crate::StoreSettings::new(valid_network(), crate::StoreBackendSettings::Postgres(valid_postgres_settings()), std::time::Duration::from_millis(100));
|
||||||
|
assert!(valid.validate().is_ok());
|
||||||
|
let invalid =
|
||||||
|
crate::StoreSettings::new(valid_network(), crate::StoreBackendSettings::Postgres(valid_postgres_settings()), std::time::Duration::from_millis(30_001));
|
||||||
|
let error = invalid.validate().err();
|
||||||
|
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_SETTINGS_INVALID));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_uri_is_required_but_never_rendered_by_debug_or_validation_error() {
|
||||||
|
let secret = "postgresql://secret-user:secret-password@db.internal/ksp";
|
||||||
|
let settings = valid_postgres_settings();
|
||||||
|
let debug = format!("{settings:?}");
|
||||||
|
assert!(!debug.contains(secret));
|
||||||
|
assert!(!debug.contains("secret-user"));
|
||||||
|
assert!(!debug.contains("secret-password"));
|
||||||
|
assert!(debug.contains("<redacted>"));
|
||||||
|
let empty = crate::PostgresStoreSettings::new(
|
||||||
|
std::string::String::new(),
|
||||||
|
crate::PostgresPoolSettings::default(),
|
||||||
|
crate::PostgresTlsMode::Disabled,
|
||||||
|
crate::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
let error = empty.validate().err();
|
||||||
|
assert_eq!(error.as_ref().map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_SETTINGS_INVALID));
|
||||||
|
assert!(!format!("{:?}", error).contains(secret));
|
||||||
|
return;
|
||||||
|
}
|
||||||
107
crates/ksp-store-lib/unit_tests/store.rs
Normal file
107
crates/ksp-store-lib/unit_tests/store.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// file: crates/ksp-store-lib/unit_tests/store.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||||
|
let mut future = std::boxed::Box::pin(future);
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
return match std::future::Future::poll(future.as_mut(), &mut context) {
|
||||||
|
std::task::Poll::Ready(value) => value,
|
||||||
|
std::task::Poll::Pending => panic!("Store pre-I/O rejection unexpectedly became pending"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_network() -> crate::RawNetworkId {
|
||||||
|
return match crate::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid test network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_settings(connection_uri: &str) -> crate::StoreSettings {
|
||||||
|
let postgres = crate::PostgresStoreSettings::new(
|
||||||
|
connection_uri,
|
||||||
|
crate::PostgresPoolSettings::default(),
|
||||||
|
crate::PostgresTlsMode::Disabled,
|
||||||
|
crate::PostgresBootstrapSettings::default(),
|
||||||
|
);
|
||||||
|
return crate::StoreSettings::with_default_shutdown(valid_network(), crate::StoreBackendSettings::Postgres(postgres));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_settings_are_rejected_before_backend_dispatch() {
|
||||||
|
let result = poll_ready(crate::Store::open(store_settings("")));
|
||||||
|
let error = result.err();
|
||||||
|
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_SETTINGS_INVALID));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
#[test]
|
||||||
|
fn compiled_postgres_rejects_malformed_physical_configuration_before_io() {
|
||||||
|
let secret_canary = "not-a-postgresql-uri-secret-canary";
|
||||||
|
let result = poll_ready(crate::Store::open(store_settings(secret_canary)));
|
||||||
|
let error = result.err();
|
||||||
|
assert_eq!(error.as_ref().map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_POSTGRES_CONFIG_INVALID));
|
||||||
|
assert!(!format!("{error:?}").contains(secret_canary));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "postgres"))]
|
||||||
|
#[test]
|
||||||
|
fn known_postgres_without_feature_is_rejected_before_io() {
|
||||||
|
let result = poll_ready(crate::Store::open(store_settings("postgresql://operator-supplied-sensitive-value@localhost/ksp")));
|
||||||
|
let error = result.err();
|
||||||
|
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_BACKEND_NOT_COMPILED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_operation_network_guard_rejects_mismatch_without_echoing_requested_network() {
|
||||||
|
let store_network = valid_network();
|
||||||
|
let hostile = match crate::RawNetworkId::new("other-network") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid alternate network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
let result = super::validate_operation_network(&store_network, &hostile, crate::StoreBackendKind::Postgres);
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(()) => panic!("wrong operation network unexpectedly accepted"),
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_WRONG_NETWORK);
|
||||||
|
assert!(!std::format!("{error:?}").contains("other-network"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
#[test]
|
||||||
|
fn pre_010_postgres_error_code_mapping_covers_every_current_backend_kind() {
|
||||||
|
let cases = [
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid, crate::ERROR_CODE_POSTGRES_CONFIG_INVALID),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed, crate::ERROR_CODE_POSTGRES_CONNECT_FAILED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout, crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed, crate::ERROR_CODE_POSTGRES_HEALTH_FAILED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict, ksp_store_api::ERROR_CODE_RAW_CONFLICT),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid, crate::ERROR_CODE_POSTGRES_DATA_INVALID),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed, crate::ERROR_CODE_POSTGRES_MIGRATION_FAILED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported, crate::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch, crate::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid, ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed, crate::ERROR_CODE_POSTGRES_READ_FAILED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound, crate::ERROR_CODE_RAW_REFERENCE_NOT_FOUND),
|
||||||
|
(
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported,
|
||||||
|
crate::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED,
|
||||||
|
),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer, crate::ERROR_CODE_POSTGRES_SCHEMA_NEWER),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout, crate::ERROR_CODE_SHUTDOWN_TIMEOUT),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed, crate::ERROR_CODE_POSTGRES_TLS_FAILED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed, crate::ERROR_CODE_POSTGRES_WRITE_FAILED),
|
||||||
|
(ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork, crate::ERROR_CODE_WRONG_NETWORK),
|
||||||
|
];
|
||||||
|
assert_eq!(cases.len(), 18);
|
||||||
|
for (kind, expected) in cases {
|
||||||
|
assert_eq!(super::postgres_error_code(kind), expected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
22
crates/ksp-store-postgres-lib/Cargo.toml
Normal file
22
crates/ksp-store-postgres-lib/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# file: crates/ksp-store-postgres-lib/Cargo.toml
|
||||||
|
# version: 3
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-store-postgres-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
deadpool-postgres = { workspace = true, features = ["rt_tokio_1"] }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
ksp-store-api = { path = "../ksp-store-api" }
|
||||||
|
rustls = { workspace = true, features = ["aws_lc_rs", "std", "tls12"] }
|
||||||
|
rustls-native-certs.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["rt", "time"] }
|
||||||
|
tokio-postgres = { workspace = true, features = ["runtime"] }
|
||||||
|
tokio-postgres-rustls = { workspace = true, features = ["aws-lc-rs"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
150
crates/ksp-store-postgres-lib/README.md
Normal file
150
crates/ksp-store-postgres-lib/README.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<!-- file: crates/ksp-store-postgres-lib/README.md -->
|
||||||
|
<!-- version: 10 -->
|
||||||
|
|
||||||
|
# ksp-store-postgres-lib
|
||||||
|
|
||||||
|
`ksp-store-postgres-lib` est le backend PostgreSQL physique officiel du Store KSP.
|
||||||
|
|
||||||
|
La crate implémente connexion, pool, TLS, migrations, health et persistence RAW derrière `ksp-store-lib`. Elle dépend directement de `ksp-store-api` mais ne dépend jamais de la façade `ksp-store-lib`.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
La crate possède seule pour PostgreSQL :
|
||||||
|
|
||||||
|
- le parsing et la normalisation de la configuration physique `tokio-postgres` ;
|
||||||
|
- le pool borné `deadpool-postgres` ;
|
||||||
|
- la policy TLS physique avec Rustls ;
|
||||||
|
- les roots système et le provider cryptographique AWS-LC ;
|
||||||
|
- le bootstrap et le moteur de migrations privé KSP ;
|
||||||
|
- la table metadata `ksp_store_schema_migrations` ;
|
||||||
|
- l'advisory transaction lock borné des migrations ;
|
||||||
|
- le schéma physique et les statements privés `RawTransaction` ;
|
||||||
|
- les snapshots runtime/health sûrs destinés au bridge de façade ;
|
||||||
|
- la fermeture explicite du pool et son fallback `Drop` best-effort ;
|
||||||
|
- la classification d'erreurs backend sans conserver le texte d'erreur PostgreSQL.
|
||||||
|
|
||||||
|
## Frontière d'utilisation
|
||||||
|
|
||||||
|
Les applications, jobs et workers KSP ne dépendent normalement pas de cette crate :
|
||||||
|
|
||||||
|
```text
|
||||||
|
consumer -> ksp-store-lib -> [feature postgres] ksp-store-postgres-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
La surface publique de cette crate existe pour le bridge inter-crates et les tests/intégrations backend. Elle ne constitue pas une seconde façade Store.
|
||||||
|
|
||||||
|
`ksp-store-postgres-lib` ne réexporte pas `tokio-postgres`, Deadpool ou Rustls.
|
||||||
|
|
||||||
|
## Connexion et pool
|
||||||
|
|
||||||
|
`PostgresBackend::open` :
|
||||||
|
|
||||||
|
1. valide et normalise l'URI fournie explicitement ;
|
||||||
|
2. impose la policy TLS typée ;
|
||||||
|
3. construit un pool borné ;
|
||||||
|
4. prouve une connexion physique ;
|
||||||
|
5. vérifie/applique le bootstrap selon les settings ;
|
||||||
|
6. ne retourne qu'après succès de cette fondation.
|
||||||
|
|
||||||
|
Le backend ne lit aucun environnement, `.env`, `PG*`, `.pgpass` ou fichier TLS implicite libpq.
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Les modes sont exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Disabled
|
||||||
|
VerifyFull
|
||||||
|
```
|
||||||
|
|
||||||
|
`VerifyFull` exige TLS, roots système, certificat valide et vérification de l'identité serveur. Une configuration ne permettant pas de vérifier cette identité, comme `hostaddr` seul, est rejetée.
|
||||||
|
|
||||||
|
## Migrations et schéma
|
||||||
|
|
||||||
|
Le moteur de migrations embarqué vérifie version logique, nom et checksum SHA-256, sérialise les runners par advisory transaction lock et refuse une history divergente ou plus récente que le runtime.
|
||||||
|
|
||||||
|
Le bootstrap metadata est conservé comme migration V000. La migration logique V001 matérialise le schéma `RawTransaction` en ressources séparées `tables/`, `constraints/` et `indexes/` afin que le backend puisse vérifier leur compatibilité effective sans transformer les fichiers SQL en parser généraliste.
|
||||||
|
|
||||||
|
La base est liée à un seul `RawNetworkId` via `ksp_store_identity`. Une migration enregistrée mais physiquement divergente est un mismatch ; les réparations additives sûres dépendent de `schema_autoupdate`.
|
||||||
|
|
||||||
|
## Health et erreurs
|
||||||
|
|
||||||
|
`PostgresBackendRuntimeSnapshot` et `PostgresBackendHealthSnapshot` ne contiennent que des compteurs et états sûrs destinés à la façade.
|
||||||
|
|
||||||
|
`PostgresBackendError` ne conserve que :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PostgresBackendErrorKind
|
||||||
|
phase statique
|
||||||
|
```
|
||||||
|
|
||||||
|
Le texte d'erreur PostgreSQL, l'URI, SQL et les valeurs bind ne traversent pas cette frontière.
|
||||||
|
|
||||||
|
## Support PostgreSQL
|
||||||
|
|
||||||
|
Le major minimal supporté est PostgreSQL 15. Le backend ne fixe aucun plafond arbitraire de major ; la compatibilité opérationnelle reste fondée sur le contrat de schéma KSP et l'introspection du catalogue.
|
||||||
|
|
||||||
|
## Lectures RAW transaction
|
||||||
|
|
||||||
|
Le backend expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
get_raw_transaction
|
||||||
|
get_raw_transaction_observation
|
||||||
|
get_raw_transaction_retention_state
|
||||||
|
get_raw_transaction_tombstone
|
||||||
|
```
|
||||||
|
|
||||||
|
Le SQL et les rows restent privés. Le mapping PostgreSQL est fallible et couvre notamment `NUMERIC(20,0) -> u64`, `BIGINT -> u32/u64`, timestamps bornés, bytes de taille fixe et codes de provenance.
|
||||||
|
|
||||||
|
`Full` lit le payload chaud, `Archived` le reconstruit depuis la relation archive et `Purged` retourne `None`; le tombstone reste accessible séparément.
|
||||||
|
|
||||||
|
## Écritures RAW transaction
|
||||||
|
|
||||||
|
Le backend expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
persist_raw_transaction_acquisition
|
||||||
|
record_raw_transaction_observation
|
||||||
|
```
|
||||||
|
|
||||||
|
L'acquisition canonique et son observation initiale sont commises dans une seule transaction PostgreSQL. Les clés uniques physiques fournissent l'admission idempotente ; après un conflit unique, le backend verrouille la ligne gagnante et compare le contenu réel avant de conclure `AlreadyPresent` ou `Conflict`.
|
||||||
|
|
||||||
|
Un tombstone `Purged` compatible produit `SkippedPurged/NotRecorded` en mode normal. `ForceRehydrate` restaure explicitement le payload `Full` et l'observation dans la même transaction.
|
||||||
|
|
||||||
|
## Pagination RAW transaction
|
||||||
|
|
||||||
|
`list_raw_transactions` parcourt les références canoniques récupérables avec un ordre total `(slot, signature)`. Les tombstones `Purged` sont exclus.
|
||||||
|
|
||||||
|
La continuation est une keyset stricte, jamais un `OFFSET`. Le cursor backend V1 est opaque et lié au réseau, à la direction et aux bornes de slots de la query. Store n'impose aucun plafond métier arbitraire à la taille de page ; seule la limitation physique du `LIMIT + 1` PostgreSQL est exposée.
|
||||||
|
|
||||||
|
## Rétention RAW transaction
|
||||||
|
|
||||||
|
`transition_raw_transaction_retention` applique les transitions physiques :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Full -> Archived -> Purged
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend verrouille la ligne canonique avec `FOR UPDATE`, compare l'état courant à l'état attendu et applique la mutation atomiquement. L'archivage conserve le payload exact dans la relation archive ; la purge conserve seulement le tombstone minimal.
|
||||||
|
|
||||||
|
Toute transition impliquant `Compacted` est rejetée avec `RetentionCompactionUnsupported` tant qu'aucune représentation compactée réelle n'est implémentée.
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
La crate ne contient :
|
||||||
|
|
||||||
|
- aucune implémentation PostgreSQL des capabilities `RawAccount*` ;
|
||||||
|
- aucune orchestration worker/job ;
|
||||||
|
- aucun transport d'acquisition ou decoder Program ;
|
||||||
|
- aucune policy autonome de batch, priorité ou rétention.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — guide pratique du bridge physique et de ses capabilities ;
|
||||||
|
- [`../ksp-store-lib/README.md`](../ksp-store-lib/README.md) — façade runtime destinée aux consumers ;
|
||||||
|
- [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) — architecture Store ;
|
||||||
|
- [`../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md`](../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md) — décisions pool/TLS/migrations ;
|
||||||
|
- [`../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md`](../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md) — validation de fondation ;
|
||||||
|
- [`../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md`](../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md) — design `RawTransaction` ;
|
||||||
|
- [`../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md`](../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md) — validation `RawTransaction`.
|
||||||
241
crates/ksp-store-postgres-lib/USAGE.md
Normal file
241
crates/ksp-store-postgres-lib/USAGE.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!-- file: crates/ksp-store-postgres-lib/USAGE.md -->
|
||||||
|
<!-- version: 10 -->
|
||||||
|
|
||||||
|
# Utilisation de ksp-store-postgres-lib
|
||||||
|
|
||||||
|
## 1. Quand dépendre directement du backend
|
||||||
|
|
||||||
|
Le consumer applicatif normal utilise `ksp-store-lib`.
|
||||||
|
|
||||||
|
Une dépendance directe à `ksp-store-postgres-lib` est réservée aux composants qui implémentent, intègrent ou testent le bridge physique PostgreSQL. Cette crate ne doit pas devenir une façade Store parallèle.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
ksp-store-api = { path = "../ksp-store-api" }
|
||||||
|
ksp-store-postgres-lib = { path = "../ksp-store-postgres-lib" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend reçoit les modèles et traits backend-neutral de `ksp-store-api`; il ne réexporte pas `tokio-postgres`, Deadpool ou Rustls.
|
||||||
|
|
||||||
|
## 2. Construire les settings physiques
|
||||||
|
|
||||||
|
Pour distinguer création initiale et mise à jour du schéma, utiliser `PostgresBackendSettings::with_schema_policy` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn backend_settings(
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
connection_uri: std::string::String,
|
||||||
|
) -> ksp_store_postgres_lib::PostgresBackendSettings {
|
||||||
|
return ksp_store_postgres_lib::PostgresBackendSettings::with_schema_policy(
|
||||||
|
network,
|
||||||
|
connection_uri,
|
||||||
|
8,
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`schema_autocreate` autorise l'initialisation d'un Store vierge. `schema_autoupdate` autorise les migrations pending et les réparations additives sûres d'une migration déjà enregistrée. Le constructeur `new(..., auto_migrate, ...)` existe pour les callers utilisant encore un switch unique et applique cette valeur aux deux politiques.
|
||||||
|
|
||||||
|
L'URI est sensible : elle n'est jamais rendue par `Debug`.
|
||||||
|
|
||||||
|
## 3. Ouvrir, sonder et fermer le backend
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn use_backend(
|
||||||
|
settings: ksp_store_postgres_lib::PostgresBackendSettings,
|
||||||
|
) -> std::result::Result<(), ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
let backend = ksp_store_postgres_lib::PostgresBackend::open(settings).await;
|
||||||
|
let backend = match backend {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let runtime = backend.runtime_snapshot();
|
||||||
|
let _capacity = runtime.pool_capacity();
|
||||||
|
let _size = runtime.pool_size();
|
||||||
|
let _available = runtime.pool_available();
|
||||||
|
let _waiting = runtime.pool_waiting();
|
||||||
|
|
||||||
|
let health = backend.health().await;
|
||||||
|
let _ready = health.is_ready();
|
||||||
|
let _migration_version = health.migration_version();
|
||||||
|
let _pending = health.pending_migration_count();
|
||||||
|
let _safe_error_kind = health.error_kind();
|
||||||
|
|
||||||
|
return backend.close(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`open` valide la configuration, construit le pool, prouve une connexion et vérifie/applique le bootstrap avant de retourner. `close` ferme le pool et attend son drain dans la deadline fournie.
|
||||||
|
|
||||||
|
Une instance physique est liée à un seul `RawNetworkId`.
|
||||||
|
|
||||||
|
## 4. Choisir le mode TLS
|
||||||
|
|
||||||
|
Pour une connexion PostgreSQL protégée :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull
|
||||||
|
```
|
||||||
|
|
||||||
|
`VerifyFull` impose TLS, les roots système et la vérification de l'identité serveur. Une configuration ne fournissant pas d'identité vérifiable est rejetée.
|
||||||
|
|
||||||
|
Pour une topologie explicitement non chiffrée :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
La policy typée choisie par KSP prime sur les paramètres SSL de l'URI.
|
||||||
|
|
||||||
|
## 5. Lire une transaction et ses métadonnées
|
||||||
|
|
||||||
|
Les méthodes backend retournent uniquement des modèles `ksp-store-api`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn read_transaction_state(
|
||||||
|
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||||
|
reference: &ksp_store_api::RawTransactionReference,
|
||||||
|
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransaction>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
let retention = backend.get_raw_transaction_retention_state(reference).await;
|
||||||
|
if let std::result::Result::Err(error) = retention {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tombstone = backend.get_raw_transaction_tombstone(reference).await;
|
||||||
|
if let std::result::Result::Err(error) = tombstone {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return backend.get_raw_transaction(reference).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Full` lit le payload chaud, `Archived` reconstruit le payload depuis l'archive et `Purged` retourne `None`. Un tombstone purgé reste lisible séparément.
|
||||||
|
|
||||||
|
Un réseau différent de celui du backend est rejeté avant acquisition d'un client du pool.
|
||||||
|
|
||||||
|
## 6. Lire une observation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn read_observation(
|
||||||
|
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||||
|
key: &ksp_store_api::RawObservationKey,
|
||||||
|
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransactionObservation>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
return backend.get_raw_transaction_observation(key).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les rows PostgreSQL, SQLSTATE, statements et valeurs de bind ne traversent jamais cette API.
|
||||||
|
|
||||||
|
## 7. Persister une acquisition canonique
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn persist_acquisition(
|
||||||
|
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||||
|
transaction: ksp_store_api::RawTransaction,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawAcquisitionWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
return backend
|
||||||
|
.persist_raw_transaction_acquisition(
|
||||||
|
transaction,
|
||||||
|
observation,
|
||||||
|
ksp_store_api::RawTransactionAcquisitionMode::Normal,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
L'opération est atomique : le canonique et son observation initiale sont tous deux durables ou aucun ne l'est. Une identité déjà présente avec un contenu identique est idempotente ; un contenu divergent retourne `PostgresBackendErrorKind::Conflict` sans overwrite silencieux.
|
||||||
|
|
||||||
|
Pour un tombstone purgé compatible, le mode `Normal` ne restaure pas le payload. `ForceRehydrate` doit être demandé explicitement pour rétablir un payload `Full`.
|
||||||
|
|
||||||
|
## 8. Ajouter une observation à un canonique existant
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn record_observation(
|
||||||
|
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawObservationWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
return backend.record_raw_transaction_observation(observation).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette opération ne crée jamais la transaction canonique. Une référence absente retourne `ReferenceNotFound`; une transaction purgée produit l'outcome `NotRecorded` prévu par l'API.
|
||||||
|
|
||||||
|
## 9. Paginer les transactions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn list_transactions(
|
||||||
|
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||||
|
query: &ksp_store_api::RawTransactionQuery,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
return backend.list_raw_transactions(query).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La navigation est keyset sur `(slot, signature)` et exclut les tombstones `Purged`. Le cursor retourné est opaque et lié au réseau, à la direction et aux bornes de slots de la query qui l'a produit.
|
||||||
|
|
||||||
|
Le backend n'utilise pas `OFFSET` et n'impose pas de plafond métier arbitraire. La seule borne exposée ici provient de la représentation physique de `LIMIT + 1` dans PostgreSQL.
|
||||||
|
|
||||||
|
## 10. Appliquer une transition de rétention
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn apply_retention(
|
||||||
|
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||||
|
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawRetentionWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||||
|
return backend.transition_raw_transaction_retention(transition).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend applique la transition choisie par le caller ; il ne décide pas de la policy d'éligibilité. Les transitions physiques prises en charge sont `Full -> Archived` puis `Archived -> Purged`.
|
||||||
|
|
||||||
|
Une transition impliquant `Compacted` est refusée avec `PostgresBackendErrorKind::RetentionCompactionUnsupported` tant qu'aucune représentation compactée réelle n'est disponible.
|
||||||
|
|
||||||
|
## 11. Classifier les erreurs sans fuite
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn classify(error: &ksp_store_postgres_lib::PostgresBackendError) {
|
||||||
|
match error.kind() {
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _safe_phase = error.phase();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PostgresBackendError` conserve uniquement une classification KSP et une phase statique. Ne pas reconstruire de diagnostic utilisateur à partir d'une erreur brute PostgreSQL.
|
||||||
|
|
||||||
|
## 12. Limites du backend direct
|
||||||
|
|
||||||
|
Le backend ne lit aucune variable d'environnement et ne possède aucune sélection de target Config. Les applications, jobs et workers doivent normalement passer par `ksp-store-lib`.
|
||||||
|
|
||||||
|
Les capabilities `RawAccount*` ne sont pas implémentées par ce backend. Les décisions de batch, priorité, backlog, scheduling et policy de rétention restent hors de sa responsabilité.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE ksp_store_schema_migrations (
|
||||||
|
version BIGINT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'pk_ksp_store_identity'
|
||||||
|
AND conrelid = to_regclass('ksp_store_identity')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_store_identity ADD CONSTRAINT pk_ksp_store_identity PRIMARY KEY (singleton);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_store_identity_singleton'
|
||||||
|
AND conrelid = to_regclass('ksp_store_identity')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_store_identity ADD CONSTRAINT ck_ksp_store_identity_singleton CHECK (singleton = 1);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_store_identity_network'
|
||||||
|
AND conrelid = to_regclass('ksp_store_identity')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_store_identity ADD CONSTRAINT ck_ksp_store_identity_network CHECK (
|
||||||
|
octet_length(network) >= 1 AND octet_length(network) <= 128
|
||||||
|
AND network ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'pk_ksp_raw_transactions'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT pk_ksp_raw_transactions PRIMARY KEY (signature);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_signature'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_signature CHECK (octet_length(signature) = 64);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_slot'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_slot CHECK (slot >= 0 AND slot <= 18446744073709551615);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_block_time'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_block_time CHECK (
|
||||||
|
block_time_unix_millis IS NULL
|
||||||
|
OR block_time_unix_millis >= 0 AND block_time_unix_millis <= 253402300799999
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_format_id'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_format_id CHECK (
|
||||||
|
octet_length(format_id) >= 1 AND octet_length(format_id) <= 128
|
||||||
|
AND format_id ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_format_version'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_format_version CHECK (format_version >= 1 AND format_version <= 4294967295);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_content_hash'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_content_hash CHECK (octet_length(content_hash) = 32);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_payload'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_payload CHECK (
|
||||||
|
payload IS NULL
|
||||||
|
OR octet_length(payload) >= 1 AND octet_length(payload) <= 16777216
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_retention_state'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_retention_state CHECK ((retention_state = 'full' OR retention_state = 'archived' OR retention_state = 'purged'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_payload_state'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_payload_state CHECK (
|
||||||
|
(retention_state = 'full' AND payload IS NOT NULL)
|
||||||
|
OR ((retention_state = 'archived' OR retention_state = 'purged') AND payload IS NULL)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transactions_purged_block_time'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_purged_block_time CHECK (
|
||||||
|
retention_state <> 'purged'
|
||||||
|
OR block_time_unix_millis IS NULL
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'pk_ksp_raw_transaction_observations'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT pk_ksp_raw_transaction_observations PRIMARY KEY (observation_key);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'fk_ksp_raw_transaction_observations_transaction'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT fk_ksp_raw_transaction_observations_transaction FOREIGN KEY (transaction_signature) REFERENCES ksp_raw_transactions(signature) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_key'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_key CHECK (octet_length(observation_key) = 32);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_signature'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_signature CHECK (octet_length(transaction_signature) = 64);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_provider'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_provider CHECK (
|
||||||
|
octet_length(provider) >= 1 AND octet_length(provider) <= 128
|
||||||
|
AND provider ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_protocol'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_protocol CHECK (
|
||||||
|
octet_length(protocol) >= 1 AND octet_length(protocol) <= 128
|
||||||
|
AND protocol ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_method'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_method CHECK (
|
||||||
|
octet_length(acquisition_method) >= 1 AND octet_length(acquisition_method) <= 128
|
||||||
|
AND acquisition_method ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_origin'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_origin CHECK ((origin = 'backfill' OR origin = 'import' OR origin = 'live' OR origin = 'repair' OR origin = 'replay'));
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_received_at'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_received_at CHECK (received_at_unix_millis >= 0 AND received_at_unix_millis <= 253402300799999);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_capture_session'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_capture_session CHECK (
|
||||||
|
capture_session_id IS NULL
|
||||||
|
OR (
|
||||||
|
octet_length(capture_session_id) >= 1 AND octet_length(capture_session_id) <= 128
|
||||||
|
AND capture_session_id ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_commitment'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_commitment CHECK (
|
||||||
|
commitment IS NULL
|
||||||
|
OR (
|
||||||
|
octet_length(commitment) >= 1 AND octet_length(commitment) <= 128
|
||||||
|
AND commitment ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_endpoint'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_endpoint CHECK (
|
||||||
|
endpoint_id IS NULL
|
||||||
|
OR (
|
||||||
|
octet_length(endpoint_id) >= 1 AND octet_length(endpoint_id) <= 128
|
||||||
|
AND endpoint_id ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_filter'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_filter CHECK (
|
||||||
|
filter_id IS NULL
|
||||||
|
OR (
|
||||||
|
octet_length(filter_id) >= 1 AND octet_length(filter_id) <= 128
|
||||||
|
AND filter_id ~ '^[A-Za-z0-9_.:-]+$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_observed_at'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_observed_at CHECK (
|
||||||
|
observed_at_unix_millis IS NULL
|
||||||
|
OR observed_at_unix_millis >= 0 AND observed_at_unix_millis <= 253402300799999
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_time_order'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_time_order CHECK (
|
||||||
|
observed_at_unix_millis IS NULL
|
||||||
|
OR observed_at_unix_millis <= received_at_unix_millis
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_source_hash'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_source_hash CHECK (
|
||||||
|
source_payload_hash IS NULL
|
||||||
|
OR octet_length(source_payload_hash) = 32
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_observations_source_size'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_observations')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD CONSTRAINT ck_ksp_raw_transaction_observations_source_size CHECK (
|
||||||
|
source_payload_size_bytes IS NULL
|
||||||
|
OR source_payload_size_bytes >= 0 AND source_payload_size_bytes <= 67108864
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'pk_ksp_raw_transaction_archive_payloads'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_archive_payloads')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_archive_payloads ADD CONSTRAINT pk_ksp_raw_transaction_archive_payloads PRIMARY KEY (signature);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'fk_ksp_raw_transaction_archive_payloads_transaction'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_archive_payloads')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_archive_payloads ADD CONSTRAINT fk_ksp_raw_transaction_archive_payloads_transaction FOREIGN KEY (signature) REFERENCES ksp_raw_transactions(signature) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_archive_payloads_signature'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_archive_payloads')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_archive_payloads ADD CONSTRAINT ck_ksp_raw_transaction_archive_payloads_signature CHECK (octet_length(signature) = 64);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DO $ksp$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_ksp_raw_transaction_archive_payloads_payload'
|
||||||
|
AND conrelid = to_regclass('ksp_raw_transaction_archive_payloads')
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ksp_raw_transaction_archive_payloads ADD CONSTRAINT ck_ksp_raw_transaction_archive_payloads_payload CHECK (octet_length(payload) >= 1 AND octet_length(payload) <= 16777216);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$ksp$;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS ix_ksp_raw_transactions_slot_signature
|
||||||
|
ON ksp_raw_transactions (slot, signature)
|
||||||
|
WHERE retention_state <> 'purged';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ksp_store_identity (
|
||||||
|
singleton SMALLINT NOT NULL,
|
||||||
|
network TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ksp_store_identity ADD COLUMN IF NOT EXISTS singleton SMALLINT NOT NULL;
|
||||||
|
ALTER TABLE ksp_store_identity ADD COLUMN IF NOT EXISTS network TEXT NOT NULL;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ksp_raw_transactions (
|
||||||
|
signature BYTEA NOT NULL,
|
||||||
|
slot NUMERIC(20, 0) NOT NULL,
|
||||||
|
block_time_unix_millis BIGINT NULL,
|
||||||
|
format_id TEXT NOT NULL,
|
||||||
|
format_version BIGINT NOT NULL,
|
||||||
|
content_hash BYTEA NOT NULL,
|
||||||
|
payload BYTEA NULL,
|
||||||
|
retention_state TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS signature BYTEA NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS slot NUMERIC(20, 0) NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS block_time_unix_millis BIGINT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS format_id TEXT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS format_version BIGINT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS content_hash BYTEA NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS payload BYTEA NULL;
|
||||||
|
ALTER TABLE ksp_raw_transactions ADD COLUMN IF NOT EXISTS retention_state TEXT NOT NULL;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ksp_raw_transaction_observations (
|
||||||
|
observation_key BYTEA NOT NULL,
|
||||||
|
transaction_signature BYTEA NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
acquisition_method TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
received_at_unix_millis BIGINT NOT NULL,
|
||||||
|
capture_session_id TEXT NULL,
|
||||||
|
commitment TEXT NULL,
|
||||||
|
endpoint_id TEXT NULL,
|
||||||
|
filter_id TEXT NULL,
|
||||||
|
observed_at_unix_millis BIGINT NULL,
|
||||||
|
source_payload_hash BYTEA NULL,
|
||||||
|
source_payload_size_bytes BIGINT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS observation_key BYTEA NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS transaction_signature BYTEA NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS protocol TEXT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS acquisition_method TEXT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS received_at_unix_millis BIGINT NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS capture_session_id TEXT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS commitment TEXT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS endpoint_id TEXT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS filter_id TEXT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS observed_at_unix_millis BIGINT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS source_payload_hash BYTEA NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_observations ADD COLUMN IF NOT EXISTS source_payload_size_bytes BIGINT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ksp_raw_transaction_archive_payloads (
|
||||||
|
signature BYTEA NOT NULL,
|
||||||
|
payload BYTEA NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ksp_raw_transaction_archive_payloads ADD COLUMN IF NOT EXISTS signature BYTEA NOT NULL;
|
||||||
|
ALTER TABLE ksp_raw_transaction_archive_payloads ADD COLUMN IF NOT EXISTS payload BYTEA NOT NULL;
|
||||||
7
crates/ksp-store-postgres-lib/src/constants.rs
Normal file
7
crates/ksp-store-postgres-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/constants.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! PostgreSQL Store backend-owned constants.
|
||||||
|
|
||||||
|
/// Owning tracing target reserved for events emitted by the PostgreSQL Store backend.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "ksp-store-postgres-lib";
|
||||||
75
crates/ksp-store-postgres-lib/src/error.rs
Normal file
75
crates/ksp-store-postgres-lib/src/error.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/error.rs
|
||||||
|
// version: 8
|
||||||
|
|
||||||
|
/// Stable KSP error code reserved for PostgreSQL retention transitions that require unsupported physical compaction.
|
||||||
|
pub const ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED: ksp_store_api::ErrorCode =
|
||||||
|
ksp_store_api::ErrorCode::new("store", "postgres_retention_compaction_unsupported");
|
||||||
|
|
||||||
|
/// Safe backend-local classification used by the Store facade for stable error mapping.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum PostgresBackendErrorKind {
|
||||||
|
/// The supplied physical PostgreSQL configuration is unsupported or malformed.
|
||||||
|
ConfigInvalid,
|
||||||
|
/// A physical PostgreSQL connection could not be established.
|
||||||
|
ConnectFailed,
|
||||||
|
/// A bounded pool wait, create or recycle operation reached its deadline.
|
||||||
|
PoolTimeout,
|
||||||
|
/// A lightweight PostgreSQL health/readiness probe failed without exposing server text or SQL.
|
||||||
|
HealthFailed,
|
||||||
|
/// A canonical RAW identity or observation key already exists with divergent durable content.
|
||||||
|
Conflict,
|
||||||
|
/// PostgreSQL returned stored RAW data that cannot be represented by the stable Store API contract.
|
||||||
|
DataInvalid,
|
||||||
|
/// PostgreSQL migration/bootstrap execution failed without exposing server text or SQL.
|
||||||
|
MigrationFailed,
|
||||||
|
/// The requested RAW page size cannot be represented by PostgreSQL LIMIT plus the continuation probe row.
|
||||||
|
PageLimitUnsupported,
|
||||||
|
/// Applied PostgreSQL migration history diverges from the embedded immutable KSP history.
|
||||||
|
MigrationMismatch,
|
||||||
|
/// A bounded backend-private RAW query or opaque cursor is invalid for the requested navigation context.
|
||||||
|
QueryInvalid,
|
||||||
|
/// A PostgreSQL RAW read statement failed without exposing server text, SQL or bind values.
|
||||||
|
ReadFailed,
|
||||||
|
/// A RAW write requires an existing canonical reference that is not durable.
|
||||||
|
ReferenceNotFound,
|
||||||
|
/// The requested RAW retention transition requires a compacted representation unsupported by PostgreSQL.
|
||||||
|
RetentionCompactionUnsupported,
|
||||||
|
/// The database schema history contains a migration newer than this runtime understands.
|
||||||
|
SchemaNewer,
|
||||||
|
/// Explicit backend shutdown did not drain inside the supplied deadline.
|
||||||
|
ShutdownTimeout,
|
||||||
|
/// Verified TLS configuration or negotiation could not be established.
|
||||||
|
TlsFailed,
|
||||||
|
/// A PostgreSQL RAW write statement or transaction failed without exposing server text, SQL or bind values.
|
||||||
|
WriteFailed,
|
||||||
|
/// A network-scoped RAW operation targeted a network different from the backend binding.
|
||||||
|
WrongNetwork,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redacted PostgreSQL backend error carrying only a safe classification and static lifecycle phase.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresBackendError {
|
||||||
|
kind: PostgresBackendErrorKind,
|
||||||
|
phase: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBackendError {
|
||||||
|
/// Creates one backend error without retaining external error text or sensitive connection material.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn new(kind: PostgresBackendErrorKind, phase: &'static str) -> Self {
|
||||||
|
return Self { kind, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe backend-local error classification.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> PostgresBackendErrorKind {
|
||||||
|
return self.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the static safe lifecycle phase associated with the failure.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn phase(&self) -> &'static str {
|
||||||
|
return self.phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/ksp-store-postgres-lib/src/health.rs
Normal file
101
crates/ksp-store-postgres-lib/src/health.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/health.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
const DEFAULT_HEALTH_TIMEOUT_MS: u64 = 5_000;
|
||||||
|
const MIGRATION_VERSION_SQL: &str = "SELECT COALESCE(MAX(version), -1)::BIGINT FROM ksp_store_schema_migrations";
|
||||||
|
const READINESS_SQL: &str = "SELECT 1::BIGINT";
|
||||||
|
|
||||||
|
/// Runs one bounded lightweight readiness probe and returns only safe classified diagnostics.
|
||||||
|
pub(crate) async fn probe_health(pool: &deadpool_postgres::Pool) -> crate::PostgresBackendHealthSnapshot {
|
||||||
|
let runtime = crate::runtime_snapshot_from_status(pool.status());
|
||||||
|
let timeouts = pool.timeouts();
|
||||||
|
let timeout = match timeouts.wait {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => std::time::Duration::from_millis(DEFAULT_HEALTH_TIMEOUT_MS),
|
||||||
|
};
|
||||||
|
let bounded = tokio::time::timeout(timeout, probe_health_inner(pool, runtime.clone())).await;
|
||||||
|
return match bounded {
|
||||||
|
std::result::Result::Ok(snapshot) => snapshot,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn probe_health_inner(pool: &deadpool_postgres::Pool, runtime: crate::PostgresBackendRuntimeSnapshot) -> crate::PostgresBackendHealthSnapshot {
|
||||||
|
let client_result = pool.get().await;
|
||||||
|
let client = match client_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let classified = crate::map_pool_error(error);
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, classified.kind());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let readiness_result = client.query_one(READINESS_SQL, &[]).await;
|
||||||
|
let readiness_row = match readiness_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let readiness_value = match readiness_row.try_get::<usize, i64>(0) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if readiness_value != 1 {
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed);
|
||||||
|
}
|
||||||
|
let migration_result = client.query_one(MIGRATION_VERSION_SQL, &[]).await;
|
||||||
|
let migration_row = match migration_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let migration_value = migration_row.try_get::<usize, i64>(0);
|
||||||
|
let migration_version = match migration_value {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let expected = crate::current_migration_version();
|
||||||
|
if migration_version < 0 || migration_version < expected {
|
||||||
|
let observed = nonnegative_version(migration_version);
|
||||||
|
let pending = pending_migration_count(migration_version, expected);
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(runtime, observed, pending, crate::PostgresBackendErrorKind::MigrationMismatch);
|
||||||
|
}
|
||||||
|
if migration_version > expected {
|
||||||
|
return crate::PostgresBackendHealthSnapshot::not_ready(
|
||||||
|
runtime,
|
||||||
|
nonnegative_version(migration_version),
|
||||||
|
0,
|
||||||
|
crate::PostgresBackendErrorKind::SchemaNewer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return crate::PostgresBackendHealthSnapshot::ready(runtime, migration_version as u64, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonnegative_version(value: i64) -> std::option::Option<u64> {
|
||||||
|
if value < 0 {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(value as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_migration_count(observed: i64, expected: i64) -> u32 {
|
||||||
|
if observed >= expected {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let delta = expected.saturating_sub(observed);
|
||||||
|
if delta > i64::from(u32::MAX) {
|
||||||
|
return u32::MAX;
|
||||||
|
}
|
||||||
|
return delta as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/health.rs"]
|
||||||
|
mod tests;
|
||||||
103
crates/ksp-store-postgres-lib/src/lib.rs
Normal file
103
crates/ksp-store-postgres-lib/src/lib.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/lib.rs
|
||||||
|
// version: 14
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Official PostgreSQL backend implementation for KSP Store.
|
||||||
|
//!
|
||||||
|
//! The backend owns the physical `tokio-postgres` connection, bounded Deadpool
|
||||||
|
//! pool, explicit Rustls TLS policy, private KSP migration/bootstrap engine and
|
||||||
|
//! safe lightweight health/readiness probe. `0.3.3-pre.003-fix.001` splits
|
||||||
|
//! migrations into versioned physical resources and verifies the effective
|
||||||
|
//! PostgreSQL schema contract before readiness. `0.3.3-pre.004` adds exact
|
||||||
|
//! backend-private RAW transaction/observation/retention read mapping.
|
||||||
|
//! `0.3.3-pre.005` adds atomic canonical/observation writes, real idempotence
|
||||||
|
//! checks and safe conflict classification without exposing PostgreSQL rows or
|
||||||
|
//! SQL through the public bridge. `0.3.3-pre.006` adds deterministic keyset
|
||||||
|
//! pagination with a fixed opaque cursor bound to network, range and direction.
|
||||||
|
//! `0.3.3-pre.007` adds atomic `Full -> Archived -> Purged` retention transitions
|
||||||
|
//! with compare-and-transition outcomes and explicit rejection of `Compacted`.
|
||||||
|
//! `0.3.3-pre.008` implements all six `RawTransaction*` capabilities directly on
|
||||||
|
//! `PostgresBackend` while preserving the existing narrow backend bridge.
|
||||||
|
//!
|
||||||
|
//! This crate depends on `ksp-store-api` and never on `ksp-store-lib`. The
|
||||||
|
//! common facade consumes only this crate's narrow backend bridge and never
|
||||||
|
//! exposes PostgreSQL pool, client, row or statement types.
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod error;
|
||||||
|
mod health;
|
||||||
|
mod migration;
|
||||||
|
mod raw_transaction;
|
||||||
|
mod runtime;
|
||||||
|
mod schema;
|
||||||
|
|
||||||
|
/// Stable KSP error code for unsupported PostgreSQL retention compaction.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED;
|
||||||
|
/// Safe backend-local error returned to the common Store facade.
|
||||||
|
pub use self::error::PostgresBackendError;
|
||||||
|
/// Safe backend-local error classification used by the common Store facade.
|
||||||
|
pub use self::error::PostgresBackendErrorKind;
|
||||||
|
/// Opaque physical PostgreSQL backend owning its connection pool.
|
||||||
|
pub use self::runtime::PostgresBackend;
|
||||||
|
/// Safe PostgreSQL readiness projection returned through the backend bridge.
|
||||||
|
pub use self::runtime::PostgresBackendHealthSnapshot;
|
||||||
|
/// Safe PostgreSQL pool counter projection returned through the backend bridge.
|
||||||
|
pub use self::runtime::PostgresBackendRuntimeSnapshot;
|
||||||
|
/// Physical PostgreSQL settings bridge consumed only by the backend crate.
|
||||||
|
pub use self::runtime::PostgresBackendSettings;
|
||||||
|
/// TLS mode accepted by the physical PostgreSQL settings bridge.
|
||||||
|
pub use self::runtime::PostgresBackendTlsMode;
|
||||||
|
|
||||||
|
/// Crate-owned tracing target for PostgreSQL backend behavior.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET;
|
||||||
|
/// Private bounded health probe consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::health::probe_health;
|
||||||
|
/// Private migration/bootstrap runner consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::migration::bootstrap;
|
||||||
|
/// Current embedded migration version consumed by the private health probe.
|
||||||
|
pub(crate) use self::migration::current_migration_version;
|
||||||
|
/// Private RAW transaction cursor decoder consumed by the physical RAW module.
|
||||||
|
pub(crate) use self::raw_transaction::cursor::decode_raw_transaction_cursor;
|
||||||
|
/// Private RAW transaction cursor encoder consumed by the physical RAW module.
|
||||||
|
pub(crate) use self::raw_transaction::cursor::encode_raw_transaction_cursor;
|
||||||
|
/// Private physical page-limit converter consumed by the physical RAW module.
|
||||||
|
pub(crate) use self::raw_transaction::cursor::raw_transaction_physical_page_limit;
|
||||||
|
/// Private RAW transaction reader consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::get_raw_transaction;
|
||||||
|
/// Private RAW transaction observation reader consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::get_raw_transaction_observation;
|
||||||
|
/// Private RAW transaction retention-state reader consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::get_raw_transaction_retention_state;
|
||||||
|
/// Private RAW transaction tombstone reader consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::get_raw_transaction_tombstone;
|
||||||
|
/// Private RAW transaction list reader consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::list_raw_transactions;
|
||||||
|
/// Private atomic RAW transaction acquisition writer consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::persist_raw_transaction_acquisition;
|
||||||
|
/// Private additional RAW transaction observation writer consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::record_raw_transaction_observation;
|
||||||
|
/// Private RAW transaction retention transition writer consumed by the physical backend runtime.
|
||||||
|
pub(crate) use self::raw_transaction::transition_raw_transaction_retention;
|
||||||
|
/// Private Deadpool error mapper shared with the health probe.
|
||||||
|
pub(crate) use self::runtime::map_pool_error;
|
||||||
|
/// Private Deadpool status projector shared with the health probe.
|
||||||
|
pub(crate) use self::runtime::runtime_snapshot_from_status;
|
||||||
|
/// Private physical schema resource descriptor consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::SchemaResource;
|
||||||
|
/// Private physical schema resource compatibility state consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::SchemaResourceState;
|
||||||
|
/// Private V000 schema resource inventory consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::V000_RESOURCES;
|
||||||
|
/// Private V001 schema resource inventory consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::V001_RESOURCES;
|
||||||
|
/// Private physical schema resource inspector consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::inspect_resource;
|
||||||
|
/// Private V001 adoption probe consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::managed_v001_objects_exist;
|
||||||
|
/// Private external-schema compatibility gate consumed by the migration engine.
|
||||||
|
pub(crate) use self::schema::verify_v001_external_compatibility;
|
||||||
|
|
||||||
|
const _: &str = crate::TRACING_TARGET;
|
||||||
649
crates/ksp-store-postgres-lib/src/migration.rs
Normal file
649
crates/ksp-store-postgres-lib/src/migration.rs
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/migration.rs
|
||||||
|
// version: 7
|
||||||
|
|
||||||
|
use sha2::Digest; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const ADVISORY_LOCK_KEY: i64 = 0x4b53_5053_544f_5245;
|
||||||
|
const EMBEDDED_MIGRATIONS: &[EmbeddedMigration] = &[
|
||||||
|
EmbeddedMigration {
|
||||||
|
checksum: MigrationChecksum::LegacySql(include_str!("../migrations/v000_bootstrap/tables/001_ksp_store_schema_migrations.sql")),
|
||||||
|
hook: MigrationHook::None,
|
||||||
|
name: "bootstrap",
|
||||||
|
resources: crate::V000_RESOURCES,
|
||||||
|
version: 0,
|
||||||
|
},
|
||||||
|
EmbeddedMigration {
|
||||||
|
checksum: MigrationChecksum::Resources,
|
||||||
|
hook: MigrationHook::StoreIdentity,
|
||||||
|
name: "raw_transaction",
|
||||||
|
resources: crate::V001_RESOURCES,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
const HISTORY_INSERT_SQL: &str = "INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)";
|
||||||
|
const HISTORY_LOAD_SQL: &str = "SELECT version, name, checksum FROM ksp_store_schema_migrations ORDER BY version";
|
||||||
|
const IDENTITY_INSERT_SQL: &str = "INSERT INTO ksp_store_identity (singleton, network) VALUES (1, $1)";
|
||||||
|
const IDENTITY_LOAD_SQL: &str = "SELECT singleton, network FROM ksp_store_identity ORDER BY singleton LIMIT 2";
|
||||||
|
const LOCK_POLL_INTERVAL_MS: u64 = 25;
|
||||||
|
const METADATA_EXISTS_SQL: &str = r#"SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'ksp_store_schema_migrations'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
)"#;
|
||||||
|
const SET_STATEMENT_TIMEOUT_SQL: &str = "SELECT set_config('statement_timeout', $1, true)";
|
||||||
|
|
||||||
|
struct AppliedMigration {
|
||||||
|
checksum: std::string::String,
|
||||||
|
name: std::string::String,
|
||||||
|
version: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum MigrationChecksum {
|
||||||
|
LegacySql(&'static str),
|
||||||
|
Resources,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct EmbeddedMigration {
|
||||||
|
checksum: MigrationChecksum,
|
||||||
|
hook: MigrationHook,
|
||||||
|
name: &'static str,
|
||||||
|
resources: &'static [crate::SchemaResource],
|
||||||
|
version: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum MigrationHook {
|
||||||
|
None,
|
||||||
|
StoreIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum MigrationHookContext {
|
||||||
|
AppliedNow,
|
||||||
|
Existing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum SchemaMutationMode {
|
||||||
|
Create,
|
||||||
|
Update,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest migration version embedded by this backend runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn current_migration_version() -> i64 {
|
||||||
|
return EMBEDDED_MIGRATIONS[EMBEDDED_MIGRATIONS.len() - 1].version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the private bounded PostgreSQL schema bootstrap on one dedicated pooled client.
|
||||||
|
pub(crate) async fn bootstrap(
|
||||||
|
client: &mut deadpool_postgres::Client,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
schema_autocreate: bool,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let registry_result = validate_embedded_registry(EMBEDDED_MIGRATIONS);
|
||||||
|
if let std::result::Result::Err(error) = registry_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let bounded = tokio::time::timeout(
|
||||||
|
migration_timeout,
|
||||||
|
bootstrap_inner(client, network, schema_autocreate, schema_autoupdate, migration_timeout, migration_lock_timeout),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return match bounded {
|
||||||
|
std::result::Result::Ok(result) => result,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_timeout"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bootstrap_inner(
|
||||||
|
client: &mut deadpool_postgres::Client,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
schema_autocreate: bool,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let transaction_result = client.transaction().await;
|
||||||
|
let transaction = match transaction_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_begin"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let lock_result = acquire_advisory_lock(&transaction, migration_lock_timeout).await;
|
||||||
|
if let std::result::Result::Err(error) = lock_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let timeout_result = set_statement_timeout(&transaction, migration_timeout).await;
|
||||||
|
if let std::result::Result::Err(error) = timeout_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let exists_result = metadata_exists(&transaction).await;
|
||||||
|
let metadata_exists = match exists_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let (next_index, mutation_mode) = if metadata_exists {
|
||||||
|
let metadata_result = crate::inspect_resource(&transaction, &crate::V000_RESOURCES[0]).await;
|
||||||
|
match metadata_result {
|
||||||
|
std::result::Result::Ok(crate::SchemaResourceState::Compatible) => {},
|
||||||
|
std::result::Result::Ok(crate::SchemaResourceState::Missing | crate::SchemaResourceState::Incompatible) => {
|
||||||
|
log_schema_block("metadata_incompatible");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_incompatible"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let history_result = load_history(&transaction).await;
|
||||||
|
let history = match history_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let validation_result = validate_history(history.as_slice(), EMBEDDED_MIGRATIONS);
|
||||||
|
let index = match validation_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
(index, SchemaMutationMode::Update)
|
||||||
|
} else {
|
||||||
|
if !schema_autocreate {
|
||||||
|
log_schema_block("schema_autocreate_disabled");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "schema_autocreate_disabled"));
|
||||||
|
}
|
||||||
|
let managed_result = crate::managed_v001_objects_exist(&transaction).await;
|
||||||
|
let managed_objects_exist = match managed_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if managed_objects_exist && !schema_autoupdate {
|
||||||
|
log_schema_block("schema_adoption_disabled");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "schema_adoption_disabled"));
|
||||||
|
}
|
||||||
|
(0, SchemaMutationMode::Create)
|
||||||
|
};
|
||||||
|
let existing_schema_result = verify_or_repair_applied_migrations(&transaction, next_index, schema_autoupdate).await;
|
||||||
|
if let std::result::Result::Err(error) = existing_schema_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let existing_hook_result = run_applied_migration_hooks(&transaction, network, next_index).await;
|
||||||
|
if let std::result::Result::Err(error) = existing_hook_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if next_index < EMBEDDED_MIGRATIONS.len() && mutation_mode == SchemaMutationMode::Update && !schema_autoupdate {
|
||||||
|
log_schema_block("migration_pending");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_pending"));
|
||||||
|
}
|
||||||
|
let apply_result = apply_pending_migrations(&transaction, network, next_index, mutation_mode).await;
|
||||||
|
if let std::result::Result::Err(error) = apply_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let commit_result = transaction.commit().await;
|
||||||
|
return match commit_result {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_commit"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquire_advisory_lock(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let deadline = match started.checked_add(timeout) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_timeout"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let row_result = transaction.query_one("SELECT pg_try_advisory_xact_lock($1)", &[&ADVISORY_LOCK_KEY]).await;
|
||||||
|
let row = match row_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let acquired_result = row.try_get::<usize, bool>(0);
|
||||||
|
let acquired = match acquired_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_decode"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if acquired {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_timeout"));
|
||||||
|
}
|
||||||
|
let candidate = now + std::time::Duration::from_millis(LOCK_POLL_INTERVAL_MS);
|
||||||
|
let wake = if candidate < deadline { candidate } else { deadline };
|
||||||
|
tokio::time::sleep_until(wake).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_migration(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
migration: &EmbeddedMigration,
|
||||||
|
mutation_mode: SchemaMutationMode,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
for resource in migration.resources {
|
||||||
|
let result = ensure_resource(transaction, resource, mutation_mode, false).await;
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let contract_result = verify_migration_contract(transaction, migration.version).await;
|
||||||
|
if let std::result::Result::Err(error) = contract_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let hook_result = run_migration_hook(transaction, network, migration.hook, MigrationHookContext::AppliedNow).await;
|
||||||
|
if let std::result::Result::Err(error) = hook_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let checksum = migration_checksum(migration);
|
||||||
|
let insert_result = transaction.execute(HISTORY_INSERT_SQL, &[&migration.version, &migration.name, &checksum]).await;
|
||||||
|
return match insert_result {
|
||||||
|
std::result::Result::Ok(1) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Ok(_) | std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_insert"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_pending_migrations(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
next_index: usize,
|
||||||
|
mutation_mode: SchemaMutationMode,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let mut index = next_index;
|
||||||
|
while index < EMBEDDED_MIGRATIONS.len() {
|
||||||
|
let migration = &EMBEDDED_MIGRATIONS[index];
|
||||||
|
let result = apply_migration(transaction, network, migration, mutation_mode).await;
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_resource(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
resource: &crate::SchemaResource,
|
||||||
|
mutation_mode: SchemaMutationMode,
|
||||||
|
applied_history: bool,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let state_result = crate::inspect_resource(transaction, resource).await;
|
||||||
|
let state = match state_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
match state {
|
||||||
|
crate::SchemaResourceState::Compatible => return std::result::Result::Ok(()),
|
||||||
|
crate::SchemaResourceState::Incompatible => {
|
||||||
|
log_schema_resource_block(resource.id, "incompatible");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(
|
||||||
|
crate::PostgresBackendErrorKind::MigrationMismatch,
|
||||||
|
"schema_resource_incompatible",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
crate::SchemaResourceState::Missing => {},
|
||||||
|
}
|
||||||
|
if applied_history && !resource.repair_existing {
|
||||||
|
log_schema_resource_block(resource.id, "repair_forbidden");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "schema_resource_missing"));
|
||||||
|
}
|
||||||
|
if applied_history && mutation_mode != SchemaMutationMode::Update {
|
||||||
|
log_schema_resource_block(resource.id, "repair_mode_invalid");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "schema_resource_missing"));
|
||||||
|
}
|
||||||
|
if applied_history {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
resource_id = resource.id,
|
||||||
|
"repairing missing PostgreSQL Store schema resource under schema_autoupdate policy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let execute_result = transaction.batch_execute(resource.sql).await;
|
||||||
|
if execute_result.is_err() {
|
||||||
|
log_schema_resource_block(resource.id, "apply_failed");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "schema_resource_apply"));
|
||||||
|
}
|
||||||
|
let verified_result = crate::inspect_resource(transaction, resource).await;
|
||||||
|
return match verified_result {
|
||||||
|
std::result::Result::Ok(crate::SchemaResourceState::Compatible) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Ok(crate::SchemaResourceState::Missing | crate::SchemaResourceState::Incompatible) => {
|
||||||
|
log_schema_resource_block(resource.id, "post_apply_incompatible");
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, resource.id))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_history(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<std::vec::Vec<AppliedMigration>, crate::PostgresBackendError> {
|
||||||
|
let result = transaction.query(HISTORY_LOAD_SQL, &[]).await;
|
||||||
|
let rows = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_load"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut history = std::vec::Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let version_result = row.try_get::<usize, i64>(0);
|
||||||
|
let name_result = row.try_get::<usize, std::string::String>(1);
|
||||||
|
let checksum_result = row.try_get::<usize, std::string::String>(2);
|
||||||
|
match (version_result, name_result, checksum_result) {
|
||||||
|
(std::result::Result::Ok(version), std::result::Result::Ok(name), std::result::Result::Ok(checksum)) => {
|
||||||
|
history.push(AppliedMigration { checksum, name, version });
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_decode"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata_exists(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<bool, crate::PostgresBackendError> {
|
||||||
|
let result = transaction.query_one(METADATA_EXISTS_SQL, &[]).await;
|
||||||
|
let row = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_probe"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match row.try_get::<usize, bool>(0) {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_probe_decode"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migration_checksum(migration: &EmbeddedMigration) -> std::string::String {
|
||||||
|
return match migration.checksum {
|
||||||
|
MigrationChecksum::LegacySql(sql) => checksum_bytes(sql.as_bytes()),
|
||||||
|
MigrationChecksum::Resources => {
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(b"ksp-migration-resources-v1\0");
|
||||||
|
for resource in migration.resources {
|
||||||
|
hasher.update(resource.id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(resource.sql.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
}
|
||||||
|
return encode_digest(hasher.finalize().as_slice());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checksum_bytes(bytes: &[u8]) -> std::string::String {
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(bytes);
|
||||||
|
return encode_digest(hasher.finalize().as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_digest(bytes: &[u8]) -> std::string::String {
|
||||||
|
let mut encoded = std::string::String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
let value = *byte;
|
||||||
|
encoded.push(char::from(HEX_LOWER[(value >> 4) as usize]));
|
||||||
|
encoded.push(char::from(HEX_LOWER[(value & 0x0f) as usize]));
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_applied_migration_hooks(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
applied_count: usize,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let mut index = 0_usize;
|
||||||
|
while index < applied_count {
|
||||||
|
let migration = &EMBEDDED_MIGRATIONS[index];
|
||||||
|
let result = run_migration_hook(transaction, network, migration.hook, MigrationHookContext::Existing).await;
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_migration_hook(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
hook: MigrationHook,
|
||||||
|
context: MigrationHookContext,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
return match hook {
|
||||||
|
MigrationHook::None => std::result::Result::Ok(()),
|
||||||
|
MigrationHook::StoreIdentity => bind_store_identity(transaction, network, context).await,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bind_store_identity(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
network: &ksp_store_api::RawNetworkId,
|
||||||
|
context: MigrationHookContext,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let first_read = load_store_identity(transaction).await;
|
||||||
|
let first = match first_read {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if first.is_empty() && context == MigrationHookContext::AppliedNow {
|
||||||
|
let insert_result = transaction.execute(IDENTITY_INSERT_SQL, &[&network.as_str()]).await;
|
||||||
|
match insert_result {
|
||||||
|
std::result::Result::Ok(1) => {},
|
||||||
|
std::result::Result::Ok(_) | std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "store_identity_insert"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let second_read = load_store_identity(transaction).await;
|
||||||
|
let second = match second_read {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return validate_store_identity(second.as_slice(), network);
|
||||||
|
}
|
||||||
|
return validate_store_identity(first.as_slice(), network);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_store_identity(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
) -> std::result::Result<std::vec::Vec<tokio_postgres::Row>, crate::PostgresBackendError> {
|
||||||
|
let rows_result = transaction.query(IDENTITY_LOAD_SQL, &[]).await;
|
||||||
|
return match rows_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "store_identity_read"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_store_identity(rows: &[tokio_postgres::Row], network: &ksp_store_api::RawNetworkId) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
if rows.len() != 1 {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "store_identity_count"));
|
||||||
|
}
|
||||||
|
let row = &rows[0];
|
||||||
|
let singleton_result = row.try_get::<usize, i16>(0);
|
||||||
|
let network_result = row.try_get::<usize, std::string::String>(1);
|
||||||
|
let (singleton, stored_network) = match (singleton_result, network_result) {
|
||||||
|
(std::result::Result::Ok(singleton), std::result::Result::Ok(stored_network)) => (singleton, stored_network),
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "store_identity_decode"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if singleton != 1 {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "store_identity_singleton"));
|
||||||
|
}
|
||||||
|
let stored_network = match ksp_store_api::RawNetworkId::new(stored_network) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "store_identity_network"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if stored_network.as_str() != network.as_str() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "store_identity_network"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_statement_timeout(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let timeout_value = format!("{}ms", timeout.as_millis());
|
||||||
|
let result = transaction.query_one(SET_STATEMENT_TIMEOUT_SQL, &[&timeout_value]).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(_) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "statement_timeout"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_migration_contract(transaction: &deadpool_postgres::Transaction<'_>, version: i64) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
if version == 1 {
|
||||||
|
let result = crate::verify_v001_external_compatibility(transaction).await;
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
log_schema_block(error.phase());
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_or_repair_applied_migrations(
|
||||||
|
transaction: &deadpool_postgres::Transaction<'_>,
|
||||||
|
applied_count: usize,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let mut index = 0_usize;
|
||||||
|
while index < applied_count {
|
||||||
|
let migration = &EMBEDDED_MIGRATIONS[index];
|
||||||
|
for resource in migration.resources {
|
||||||
|
let state_result = crate::inspect_resource(transaction, resource).await;
|
||||||
|
let state = match state_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
match state {
|
||||||
|
crate::SchemaResourceState::Compatible => {},
|
||||||
|
crate::SchemaResourceState::Incompatible => {
|
||||||
|
log_schema_resource_block(resource.id, "incompatible");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(
|
||||||
|
crate::PostgresBackendErrorKind::MigrationMismatch,
|
||||||
|
"schema_resource_incompatible",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
crate::SchemaResourceState::Missing => {
|
||||||
|
if !schema_autoupdate {
|
||||||
|
log_schema_resource_block(resource.id, "schema_autoupdate_disabled");
|
||||||
|
return std::result::Result::Err(schema_autoupdate_disabled_error());
|
||||||
|
}
|
||||||
|
let repair_result = ensure_resource(transaction, resource, SchemaMutationMode::Update, true).await;
|
||||||
|
if let std::result::Result::Err(error) = repair_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let contract_result = verify_migration_contract(transaction, migration.version).await;
|
||||||
|
if let std::result::Result::Err(error) = contract_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema_autoupdate_disabled_error() -> crate::PostgresBackendError {
|
||||||
|
return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "schema_autoupdate_disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_embedded_registry(migrations: &[EmbeddedMigration]) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
if migrations.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_empty"));
|
||||||
|
}
|
||||||
|
let mut expected_version = 0_i64;
|
||||||
|
for migration in migrations {
|
||||||
|
if migration.version != expected_version || migration.name.is_empty() || migration.resources.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_invalid"));
|
||||||
|
}
|
||||||
|
for (resource_index, resource) in migration.resources.iter().enumerate() {
|
||||||
|
if resource.id.is_empty() || resource.sql.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_invalid"));
|
||||||
|
}
|
||||||
|
for previous in &migration.resources[..resource_index] {
|
||||||
|
if previous.id == resource.id {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_invalid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected_version = match expected_version.checked_add(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "registry_invalid"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_history(history: &[AppliedMigration], migrations: &[EmbeddedMigration]) -> std::result::Result<usize, crate::PostgresBackendError> {
|
||||||
|
if history.is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_missing"));
|
||||||
|
}
|
||||||
|
let latest_version = migrations[migrations.len() - 1].version;
|
||||||
|
let mut index = 0_usize;
|
||||||
|
for applied in history {
|
||||||
|
if applied.version > latest_version {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
|
||||||
|
}
|
||||||
|
if index >= migrations.len() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
|
||||||
|
}
|
||||||
|
let expected = &migrations[index];
|
||||||
|
let expected_checksum = migration_checksum(expected);
|
||||||
|
if applied.version != expected.version || applied.name != expected.name || applied.checksum != expected_checksum {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_diverged"));
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_schema_block(phase: &'static str) {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, phase, "PostgreSQL Store schema compatibility gate blocked automatic opening");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_schema_resource_block(resource_id: &'static str, reason: &'static str) {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
resource_id,
|
||||||
|
reason,
|
||||||
|
"PostgreSQL Store schema resource requires manual reconciliation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/migration.rs"]
|
||||||
|
mod tests;
|
||||||
1470
crates/ksp-store-postgres-lib/src/raw_transaction.rs
Normal file
1470
crates/ksp-store-postgres-lib/src/raw_transaction.rs
Normal file
File diff suppressed because it is too large
Load Diff
184
crates/ksp-store-postgres-lib/src/raw_transaction/cursor.rs
Normal file
184
crates/ksp-store-postgres-lib/src/raw_transaction/cursor.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/raw_transaction/cursor.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
use sha2::Digest; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const CURSOR_BYTES: usize = 109;
|
||||||
|
const CURSOR_DIGEST_OFFSET: usize = 77;
|
||||||
|
const CURSOR_DOMAIN: &[u8] = b"KSP/raw-transaction-cursor/v1";
|
||||||
|
const CURSOR_MAGIC: &[u8; 4] = b"KSPT";
|
||||||
|
const CURSOR_VERSION: u8 = 1;
|
||||||
|
const MAX_POSTGRES_PAGE_LIMIT: u64 = 9_223_372_036_854_775_806;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
/// Decoded backend-private continuation key extracted from one validated RAW cursor.
|
||||||
|
pub(crate) struct RawTransactionDecodedCursor {
|
||||||
|
/// Last canonical transaction signature returned by the previous page.
|
||||||
|
pub(crate) last_signature: [u8; 64],
|
||||||
|
/// Last canonical transaction slot returned by the previous page.
|
||||||
|
pub(crate) last_slot: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes and validates one backend-private RAW transaction cursor against its query binding.
|
||||||
|
pub(crate) fn decode_raw_transaction_cursor(
|
||||||
|
query: &ksp_store_api::RawTransactionQuery,
|
||||||
|
cursor: &ksp_store_api::RawPageCursor,
|
||||||
|
) -> std::result::Result<RawTransactionDecodedCursor, crate::PostgresBackendError> {
|
||||||
|
let bytes = cursor.as_bytes();
|
||||||
|
if bytes.len() != CURSOR_BYTES {
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_size"));
|
||||||
|
}
|
||||||
|
if bytes.get(0..4) != std::option::Option::Some(CURSOR_MAGIC.as_ref()) {
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_magic"));
|
||||||
|
}
|
||||||
|
if bytes.get(4).copied() != std::option::Option::Some(CURSOR_VERSION) {
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_version"));
|
||||||
|
}
|
||||||
|
let slot_bytes = match bytes.get(5..13) {
|
||||||
|
std::option::Option::Some(value) => match <[u8; 8]>::try_from(value) {
|
||||||
|
std::result::Result::Ok(decoded) => decoded,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_cursor_slot")),
|
||||||
|
},
|
||||||
|
std::option::Option::None => return std::result::Result::Err(query_invalid("raw_cursor_slot")),
|
||||||
|
};
|
||||||
|
let last_slot = u64::from_be_bytes(slot_bytes);
|
||||||
|
let last_signature = match bytes.get(13..CURSOR_DIGEST_OFFSET) {
|
||||||
|
std::option::Option::Some(value) => match <[u8; 64]>::try_from(value) {
|
||||||
|
std::result::Result::Ok(decoded) => decoded,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_cursor_signature")),
|
||||||
|
},
|
||||||
|
std::option::Option::None => return std::result::Result::Err(query_invalid("raw_cursor_signature")),
|
||||||
|
};
|
||||||
|
let range_result = validate_slot_in_range(query.slots(), last_slot);
|
||||||
|
if let std::result::Result::Err(error) = range_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let expected_digest = match binding_digest(query, last_slot, &last_signature) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let stored_digest = match bytes.get(CURSOR_DIGEST_OFFSET..CURSOR_BYTES) {
|
||||||
|
std::option::Option::Some(value) => match <[u8; 32]>::try_from(value) {
|
||||||
|
std::result::Result::Ok(decoded) => decoded,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_cursor_digest")),
|
||||||
|
},
|
||||||
|
std::option::Option::None => return std::result::Result::Err(query_invalid("raw_cursor_digest")),
|
||||||
|
};
|
||||||
|
if stored_digest != expected_digest {
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_binding"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(RawTransactionDecodedCursor { last_signature, last_slot });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one backend-private RAW transaction cursor bound to the supplied query context.
|
||||||
|
pub(crate) fn encode_raw_transaction_cursor(
|
||||||
|
query: &ksp_store_api::RawTransactionQuery,
|
||||||
|
last_slot: u64,
|
||||||
|
last_signature: &ksp_store_api::RawTransactionSignature,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawPageCursor, crate::PostgresBackendError> {
|
||||||
|
let range_result = validate_slot_in_range(query.slots(), last_slot);
|
||||||
|
if let std::result::Result::Err(error) = range_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let digest = match binding_digest(query, last_slot, last_signature.as_bytes()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut bytes = std::vec::Vec::with_capacity(CURSOR_BYTES);
|
||||||
|
bytes.extend_from_slice(CURSOR_MAGIC);
|
||||||
|
bytes.push(CURSOR_VERSION);
|
||||||
|
bytes.extend_from_slice(&last_slot.to_be_bytes());
|
||||||
|
bytes.extend_from_slice(last_signature.as_bytes());
|
||||||
|
bytes.extend_from_slice(&digest);
|
||||||
|
if bytes.len() != CURSOR_BYTES {
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_encode_size"));
|
||||||
|
}
|
||||||
|
return match ksp_store_api::RawPageCursor::try_new(bytes.into_boxed_slice()) {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(query_invalid("raw_cursor_encode")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts one caller page size into the exact PostgreSQL LIMIT+1 representation.
|
||||||
|
pub(crate) fn raw_transaction_physical_page_limit(requested: u64) -> std::result::Result<(usize, i64), crate::PostgresBackendError> {
|
||||||
|
if requested > MAX_POSTGRES_PAGE_LIMIT {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_page_limit"));
|
||||||
|
}
|
||||||
|
let requested_usize = match usize::try_from(requested) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_page_limit"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let requested_plus_one = match requested.checked_add(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_page_limit"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let sql_limit = match i64::try_from(requested_plus_one) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::PageLimitUnsupported, "raw_page_limit"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok((requested_usize, sql_limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binding_digest(
|
||||||
|
query: &ksp_store_api::RawTransactionQuery,
|
||||||
|
last_slot: u64,
|
||||||
|
last_signature: &[u8; 64],
|
||||||
|
) -> std::result::Result<[u8; 32], crate::PostgresBackendError> {
|
||||||
|
let direction = match query.direction() {
|
||||||
|
ksp_store_api::RawSortDirection::Ascending => 0_u8,
|
||||||
|
ksp_store_api::RawSortDirection::Descending => 1_u8,
|
||||||
|
_ => return std::result::Result::Err(query_invalid("raw_cursor_direction")),
|
||||||
|
};
|
||||||
|
let network_len = match u64::try_from(query.network().as_str().len()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(query_invalid("raw_cursor_network")),
|
||||||
|
};
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(CURSOR_DOMAIN);
|
||||||
|
hasher.update(network_len.to_be_bytes());
|
||||||
|
hasher.update(query.network().as_str().as_bytes());
|
||||||
|
hasher.update([direction]);
|
||||||
|
hash_optional_slot(&mut hasher, query.slots().start_inclusive());
|
||||||
|
hash_optional_slot(&mut hasher, query.slots().end_inclusive());
|
||||||
|
hasher.update(last_slot.to_be_bytes());
|
||||||
|
hasher.update(last_signature);
|
||||||
|
let finalized = hasher.finalize();
|
||||||
|
let mut digest = [0_u8; 32];
|
||||||
|
digest.copy_from_slice(finalized.as_ref());
|
||||||
|
return std::result::Result::Ok(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_optional_slot(hasher: &mut sha2::Sha256, value: std::option::Option<u64>) {
|
||||||
|
match value {
|
||||||
|
std::option::Option::Some(slot) => {
|
||||||
|
hasher.update([1_u8]);
|
||||||
|
hasher.update(slot.to_be_bytes());
|
||||||
|
},
|
||||||
|
std::option::Option::None => hasher.update([0_u8]),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slot_in_range(range: ksp_store_api::RawSlotRange, slot: u64) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
if let std::option::Option::Some(start) = range.start_inclusive()
|
||||||
|
&& slot < start
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_slot_range"));
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(end) = range.end_inclusive()
|
||||||
|
&& slot > end
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(query_invalid("raw_cursor_slot_range"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_invalid(phase: &'static str) -> crate::PostgresBackendError {
|
||||||
|
return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::QueryInvalid, phase);
|
||||||
|
}
|
||||||
697
crates/ksp-store-postgres-lib/src/runtime.rs
Normal file
697
crates/ksp-store-postgres-lib/src/runtime.rs
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/runtime.rs
|
||||||
|
// version: 10
|
||||||
|
|
||||||
|
const APPLICATION_NAME: &str = "ksp-store";
|
||||||
|
const MAX_CONNECTION_URI_BYTES: usize = 4_096;
|
||||||
|
const SHUTDOWN_POLL_INTERVAL_MS: u64 = 10;
|
||||||
|
|
||||||
|
/// Safe PostgreSQL runtime counters exported only through the narrow backend bridge.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresBackendRuntimeSnapshot {
|
||||||
|
pool_available: u32,
|
||||||
|
pool_capacity: u32,
|
||||||
|
pool_size: u32,
|
||||||
|
pool_waiting: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBackendRuntimeSnapshot {
|
||||||
|
/// Creates a safe pool-counter projection from already bounded values.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn new(pool_capacity: u32, pool_size: u32, pool_available: u32, pool_waiting: u32) -> Self {
|
||||||
|
return Self { pool_available, pool_capacity, pool_size, pool_waiting };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of currently available pooled PostgreSQL clients.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_available(&self) -> u32 {
|
||||||
|
return self.pool_available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum pooled PostgreSQL client count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_capacity(&self) -> u32 {
|
||||||
|
return self.pool_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current pooled PostgreSQL client count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_size(&self) -> u32 {
|
||||||
|
return self.pool_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tasks currently waiting for a pooled PostgreSQL client.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pool_waiting(&self) -> u32 {
|
||||||
|
return self.pool_waiting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe PostgreSQL readiness projection returned to the common Store facade.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresBackendHealthSnapshot {
|
||||||
|
error_kind: std::option::Option<crate::PostgresBackendErrorKind>,
|
||||||
|
migration_version: std::option::Option<u64>,
|
||||||
|
pending_migration_count: u32,
|
||||||
|
ready: bool,
|
||||||
|
runtime: PostgresBackendRuntimeSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBackendHealthSnapshot {
|
||||||
|
/// Creates one successful safe readiness projection.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn ready(runtime: PostgresBackendRuntimeSnapshot, migration_version: u64, pending_migration_count: u32) -> Self {
|
||||||
|
return Self {
|
||||||
|
error_kind: std::option::Option::None,
|
||||||
|
migration_version: std::option::Option::Some(migration_version),
|
||||||
|
pending_migration_count,
|
||||||
|
ready: true,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates one failed safe readiness projection from a classified backend error.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn not_ready(
|
||||||
|
runtime: PostgresBackendRuntimeSnapshot,
|
||||||
|
migration_version: std::option::Option<u64>,
|
||||||
|
pending_migration_count: u32,
|
||||||
|
error_kind: crate::PostgresBackendErrorKind,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
error_kind: std::option::Option::Some(error_kind),
|
||||||
|
migration_version,
|
||||||
|
pending_migration_count,
|
||||||
|
ready: false,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe backend error classification when readiness could not be proven.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn error_kind(&self) -> std::option::Option<crate::PostgresBackendErrorKind> {
|
||||||
|
return self.error_kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the latest bounded PostgreSQL probe proved readiness.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_ready(&self) -> bool {
|
||||||
|
return self.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the migration version observed by the readiness probe when available.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn migration_version(&self) -> std::option::Option<u64> {
|
||||||
|
return self.migration_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of embedded migrations newer than the observed applied version.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pending_migration_count(&self) -> u32 {
|
||||||
|
return self.pending_migration_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe PostgreSQL pool counters captured for this probe.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn runtime(&self) -> &PostgresBackendRuntimeSnapshot {
|
||||||
|
return &self.runtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLS mode accepted by the physical PostgreSQL backend bridge.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum PostgresBackendTlsMode {
|
||||||
|
/// Disable TLS for the selected PostgreSQL target.
|
||||||
|
Disabled,
|
||||||
|
/// Require TLS with system-root trust and server-identity verification.
|
||||||
|
VerifyFull,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBackendTlsMode {
|
||||||
|
/// Returns the stable safe TLS mode code used only in diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> &'static str {
|
||||||
|
return match self {
|
||||||
|
Self::Disabled => "disabled",
|
||||||
|
Self::VerifyFull => "verify_full",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical settings consumed only by the PostgreSQL backend crate.
|
||||||
|
pub struct PostgresBackendSettings {
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
connection_uri: std::string::String,
|
||||||
|
create_timeout: std::time::Duration,
|
||||||
|
max_connections: u32,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
recycle_timeout: std::time::Duration,
|
||||||
|
schema_autocreate: bool,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
tls_mode: PostgresBackendTlsMode,
|
||||||
|
wait_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBackendSettings {
|
||||||
|
/// Creates the physical PostgreSQL settings bridge using the legacy single migration switch.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
connection_uri: impl std::convert::Into<std::string::String>,
|
||||||
|
max_connections: u32,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
wait_timeout: std::time::Duration,
|
||||||
|
create_timeout: std::time::Duration,
|
||||||
|
recycle_timeout: std::time::Duration,
|
||||||
|
tls_mode: PostgresBackendTlsMode,
|
||||||
|
auto_migrate: bool,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
return Self::with_schema_policy(
|
||||||
|
network,
|
||||||
|
connection_uri,
|
||||||
|
max_connections,
|
||||||
|
connect_timeout,
|
||||||
|
wait_timeout,
|
||||||
|
create_timeout,
|
||||||
|
recycle_timeout,
|
||||||
|
tls_mode,
|
||||||
|
auto_migrate,
|
||||||
|
auto_migrate,
|
||||||
|
migration_timeout,
|
||||||
|
migration_lock_timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the physical PostgreSQL settings bridge with independent schema creation and update policies.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_schema_policy(
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
connection_uri: impl std::convert::Into<std::string::String>,
|
||||||
|
max_connections: u32,
|
||||||
|
connect_timeout: std::time::Duration,
|
||||||
|
wait_timeout: std::time::Duration,
|
||||||
|
create_timeout: std::time::Duration,
|
||||||
|
recycle_timeout: std::time::Duration,
|
||||||
|
tls_mode: PostgresBackendTlsMode,
|
||||||
|
schema_autocreate: bool,
|
||||||
|
schema_autoupdate: bool,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
connect_timeout,
|
||||||
|
connection_uri: connection_uri.into(),
|
||||||
|
create_timeout,
|
||||||
|
max_connections,
|
||||||
|
migration_lock_timeout,
|
||||||
|
migration_timeout,
|
||||||
|
network,
|
||||||
|
recycle_timeout,
|
||||||
|
schema_autocreate,
|
||||||
|
schema_autoupdate,
|
||||||
|
tls_mode,
|
||||||
|
wait_timeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical network bound to this one backend instance.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn network(&self) -> &ksp_store_api::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected safe TLS mode.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn tls_mode(&self) -> PostgresBackendTlsMode {
|
||||||
|
return self.tls_mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for PostgresBackendSettings {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("PostgresBackendSettings")
|
||||||
|
.field("network", &self.network)
|
||||||
|
.field("connection_uri", &"<redacted>")
|
||||||
|
.field("schema_autocreate", &self.schema_autocreate)
|
||||||
|
.field("schema_autoupdate", &self.schema_autoupdate)
|
||||||
|
.field("max_connections", &self.max_connections)
|
||||||
|
.field("migration_timeout", &self.migration_timeout)
|
||||||
|
.field("migration_lock_timeout", &self.migration_lock_timeout)
|
||||||
|
.field("connect_timeout", &self.connect_timeout)
|
||||||
|
.field("wait_timeout", &self.wait_timeout)
|
||||||
|
.field("create_timeout", &self.create_timeout)
|
||||||
|
.field("recycle_timeout", &self.recycle_timeout)
|
||||||
|
.field("tls_mode", &self.tls_mode)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque physical PostgreSQL backend owning the bounded Deadpool connection pool.
|
||||||
|
pub struct PostgresBackend {
|
||||||
|
network: ksp_store_api::RawNetworkId,
|
||||||
|
pool: deadpool_postgres::Pool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBackend {
|
||||||
|
/// Parses and normalizes one supplied URI, builds a bounded pool and proves one physical connection before returning readiness.
|
||||||
|
pub async fn open(settings: PostgresBackendSettings) -> std::result::Result<Self, crate::PostgresBackendError> {
|
||||||
|
let normalized = normalized_config(&settings);
|
||||||
|
let pg_config = match normalized {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
network = settings.network().as_str(),
|
||||||
|
tls_mode = settings.tls_mode().code(),
|
||||||
|
max_connections = settings.max_connections,
|
||||||
|
"opening PostgreSQL Store backend pool"
|
||||||
|
);
|
||||||
|
let pool_result = match settings.tls_mode {
|
||||||
|
PostgresBackendTlsMode::Disabled => build_pool(pg_config, tokio_postgres::NoTls, &settings),
|
||||||
|
PostgresBackendTlsMode::VerifyFull => {
|
||||||
|
let tls_result = build_verified_tls();
|
||||||
|
let tls = match tls_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
build_pool(pg_config, tls, &settings)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let pool = match pool_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let probe = pool.get().await;
|
||||||
|
let mut client = match probe {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(map_pool_error(error)),
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
network = settings.network().as_str(),
|
||||||
|
tls_mode = settings.tls_mode().code(),
|
||||||
|
"PostgreSQL Store backend established initial physical connection"
|
||||||
|
);
|
||||||
|
let bootstrap_result = crate::bootstrap(
|
||||||
|
&mut client,
|
||||||
|
settings.network(),
|
||||||
|
settings.schema_autocreate,
|
||||||
|
settings.schema_autoupdate,
|
||||||
|
settings.migration_timeout,
|
||||||
|
settings.migration_lock_timeout,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let std::result::Result::Err(error) = bootstrap_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
drop(client);
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
network = settings.network().as_str(),
|
||||||
|
schema_autocreate = settings.schema_autocreate,
|
||||||
|
schema_autoupdate = settings.schema_autoupdate,
|
||||||
|
"PostgreSQL Store migration/bootstrap foundation verified"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(Self { network: settings.network, pool });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a safe synchronous snapshot of bounded pool counters without performing PostgreSQL I/O.
|
||||||
|
#[must_use]
|
||||||
|
pub fn runtime_snapshot(&self) -> PostgresBackendRuntimeSnapshot {
|
||||||
|
return runtime_snapshot_from_status(self.pool.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a bounded lightweight PostgreSQL readiness probe and returns only safe classified diagnostics.
|
||||||
|
pub async fn health(&self) -> PostgresBackendHealthSnapshot {
|
||||||
|
return crate::probe_health(&self.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one canonical RAW transaction without exposing physical PostgreSQL row types.
|
||||||
|
pub async fn get_raw_transaction(
|
||||||
|
&self,
|
||||||
|
reference: &ksp_store_api::RawTransactionReference,
|
||||||
|
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransaction>, crate::PostgresBackendError> {
|
||||||
|
return crate::get_raw_transaction(&self.pool, &self.network, reference).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists deterministic canonical RAW transaction references with a backend-owned opaque continuation cursor.
|
||||||
|
pub async fn list_raw_transactions(
|
||||||
|
&self,
|
||||||
|
query: &ksp_store_api::RawTransactionQuery,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>, crate::PostgresBackendError> {
|
||||||
|
return crate::list_raw_transactions(&self.pool, &self.network, query).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one persisted RAW transaction observation by producer-owned idempotence key.
|
||||||
|
pub async fn get_raw_transaction_observation(
|
||||||
|
&self,
|
||||||
|
observation_key: &ksp_store_api::RawObservationKey,
|
||||||
|
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransactionObservation>, crate::PostgresBackendError> {
|
||||||
|
return crate::get_raw_transaction_observation(&self.pool, &self.network, observation_key).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the retention state of one canonical RAW transaction identity.
|
||||||
|
pub async fn get_raw_transaction_retention_state(
|
||||||
|
&self,
|
||||||
|
reference: &ksp_store_api::RawTransactionReference,
|
||||||
|
) -> std::result::Result<std::option::Option<ksp_store_api::RawRetentionState>, crate::PostgresBackendError> {
|
||||||
|
return crate::get_raw_transaction_retention_state(&self.pool, &self.network, reference).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the minimal durable tombstone only when one RAW transaction is purged.
|
||||||
|
pub async fn get_raw_transaction_tombstone(
|
||||||
|
&self,
|
||||||
|
reference: &ksp_store_api::RawTransactionReference,
|
||||||
|
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransactionTombstone>, crate::PostgresBackendError> {
|
||||||
|
return crate::get_raw_transaction_tombstone(&self.pool, &self.network, reference).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists one canonical RAW transaction and its acquisition observation atomically.
|
||||||
|
pub async fn persist_raw_transaction_acquisition(
|
||||||
|
&self,
|
||||||
|
raw_transaction: ksp_store_api::RawTransaction,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
mode: ksp_store_api::RawTransactionAcquisitionMode,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawAcquisitionWriteOutcome, crate::PostgresBackendError> {
|
||||||
|
return crate::persist_raw_transaction_acquisition(&self.pool, &self.network, raw_transaction, observation, mode).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists one additional acquisition observation for an existing RAW transaction.
|
||||||
|
pub async fn record_raw_transaction_observation(
|
||||||
|
&self,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawObservationWriteOutcome, crate::PostgresBackendError> {
|
||||||
|
return crate::record_raw_transaction_observation(&self.pool, &self.network, observation).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one policy-authorized atomic RAW transaction retention transition.
|
||||||
|
pub async fn transition_raw_transaction_retention(
|
||||||
|
&self,
|
||||||
|
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||||
|
) -> std::result::Result<ksp_store_api::RawRetentionWriteOutcome, crate::PostgresBackendError> {
|
||||||
|
return crate::transition_raw_transaction_retention(&self.pool, &self.network, transition).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly closes the pool and waits for all owned pooled objects to drain inside the supplied bound.
|
||||||
|
pub async fn close(self, timeout: std::time::Duration) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
self.pool.close();
|
||||||
|
let drain = async {
|
||||||
|
loop {
|
||||||
|
if self.pool.status().size == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(SHUTDOWN_POLL_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = tokio::time::timeout(timeout, drain).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(()) => {
|
||||||
|
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, network = self.network.as_str(), "PostgreSQL Store backend pool closed");
|
||||||
|
std::result::Result::Ok(())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ShutdownTimeout, "pool_drain"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for PostgresBackend {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("PostgresBackend").field("network", &self.network).field("state", &"open").finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRead for PostgresBackend {
|
||||||
|
fn get_raw_transaction<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawTransactionReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransaction>>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::get_raw_transaction(self, reference).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_raw_transactions<'a>(
|
||||||
|
&'a self,
|
||||||
|
query: &'a ksp_store_api::RawTransactionQuery,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::list_raw_transactions(self, query).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionWrite for PostgresBackend {
|
||||||
|
fn persist_raw_transaction_acquisition<'a>(
|
||||||
|
&'a self,
|
||||||
|
transaction: ksp_store_api::RawTransaction,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
mode: ksp_store_api::RawTransactionAcquisitionMode,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawAcquisitionWriteOutcome>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::persist_raw_transaction_acquisition(self, transaction, observation, mode).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionObservationRead for PostgresBackend {
|
||||||
|
fn get_raw_transaction_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation_key: &'a ksp_store_api::RawObservationKey,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionObservation>>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::get_raw_transaction_observation(self, observation_key).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionObservationWrite for PostgresBackend {
|
||||||
|
fn record_raw_transaction_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation: ksp_store_api::RawTransactionObservation,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawObservationWriteOutcome>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::record_raw_transaction_observation(self, observation).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRetentionRead for PostgresBackend {
|
||||||
|
fn get_raw_transaction_retention_state<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawTransactionReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawRetentionState>>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::get_raw_transaction_retention_state(self, reference).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_raw_transaction_tombstone<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawTransactionReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionTombstone>>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::get_raw_transaction_tombstone(self, reference).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRetentionWrite for PostgresBackend {
|
||||||
|
fn transition_raw_transaction_retention<'a>(
|
||||||
|
&'a self,
|
||||||
|
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawRetentionWriteOutcome>> {
|
||||||
|
return std::boxed::Box::pin(async move {
|
||||||
|
let result = PostgresBackend::transition_raw_transaction_retention(self, transition).await;
|
||||||
|
return result.map_err(map_capability_error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_capability_error(error: crate::PostgresBackendError) -> ksp_store_api::Error {
|
||||||
|
let code = match error.kind() {
|
||||||
|
crate::PostgresBackendErrorKind::ConfigInvalid => ksp_store_api::ErrorCode::new("store", "postgres_config_invalid"),
|
||||||
|
crate::PostgresBackendErrorKind::ConnectFailed => ksp_store_api::ErrorCode::new("store", "postgres_connect_failed"),
|
||||||
|
crate::PostgresBackendErrorKind::Conflict => ksp_store_api::ERROR_CODE_RAW_CONFLICT,
|
||||||
|
crate::PostgresBackendErrorKind::DataInvalid => ksp_store_api::ErrorCode::new("store", "postgres_data_invalid"),
|
||||||
|
crate::PostgresBackendErrorKind::HealthFailed => ksp_store_api::ErrorCode::new("store", "postgres_health_failed"),
|
||||||
|
crate::PostgresBackendErrorKind::MigrationFailed => ksp_store_api::ErrorCode::new("store", "postgres_migration_failed"),
|
||||||
|
crate::PostgresBackendErrorKind::MigrationMismatch => ksp_store_api::ErrorCode::new("store", "postgres_migration_mismatch"),
|
||||||
|
crate::PostgresBackendErrorKind::PageLimitUnsupported => ksp_store_api::ErrorCode::new("store", "postgres_page_limit_unsupported"),
|
||||||
|
crate::PostgresBackendErrorKind::PoolTimeout => ksp_store_api::ErrorCode::new("store", "postgres_pool_timeout"),
|
||||||
|
crate::PostgresBackendErrorKind::QueryInvalid => ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID,
|
||||||
|
crate::PostgresBackendErrorKind::ReadFailed => ksp_store_api::ErrorCode::new("store", "postgres_read_failed"),
|
||||||
|
crate::PostgresBackendErrorKind::ReferenceNotFound => ksp_store_api::ErrorCode::new("store", "raw_reference_not_found"),
|
||||||
|
crate::PostgresBackendErrorKind::RetentionCompactionUnsupported => crate::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED,
|
||||||
|
crate::PostgresBackendErrorKind::SchemaNewer => ksp_store_api::ErrorCode::new("store", "postgres_schema_newer"),
|
||||||
|
crate::PostgresBackendErrorKind::ShutdownTimeout => ksp_store_api::ErrorCode::new("store", "shutdown_timeout"),
|
||||||
|
crate::PostgresBackendErrorKind::TlsFailed => ksp_store_api::ErrorCode::new("store", "postgres_tls_failed"),
|
||||||
|
crate::PostgresBackendErrorKind::WriteFailed => ksp_store_api::ErrorCode::new("store", "postgres_write_failed"),
|
||||||
|
crate::PostgresBackendErrorKind::WrongNetwork => ksp_store_api::ErrorCode::new("store", "wrong_network"),
|
||||||
|
};
|
||||||
|
return ksp_store_api::Error::new(code, "PostgreSQL Store capability operation failed")
|
||||||
|
.with_context("backend", "postgres")
|
||||||
|
.with_context("phase", error.phase());
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Drop for PostgresBackend {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.pool.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_config(settings: &PostgresBackendSettings) -> std::result::Result<tokio_postgres::Config, crate::PostgresBackendError> {
|
||||||
|
if settings.connection_uri.is_empty() || settings.connection_uri.len() > MAX_CONNECTION_URI_BYTES {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "connection_uri"));
|
||||||
|
}
|
||||||
|
let parsed = settings.connection_uri.parse::<tokio_postgres::Config>();
|
||||||
|
let mut config = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "connection_uri"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if config.get_options().is_some() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "server_options"));
|
||||||
|
}
|
||||||
|
if config.get_hosts().is_empty() && config.get_hostaddrs().is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "host"));
|
||||||
|
}
|
||||||
|
if settings.tls_mode == PostgresBackendTlsMode::VerifyFull {
|
||||||
|
if config.get_hosts().is_empty() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "tls_server_identity"));
|
||||||
|
}
|
||||||
|
for host in config.get_hosts() {
|
||||||
|
if !is_tcp_host(host) {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "tls_server_identity"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.application_name(APPLICATION_NAME);
|
||||||
|
config.connect_timeout(settings.connect_timeout);
|
||||||
|
config.ssl_negotiation(tokio_postgres::config::SslNegotiation::Postgres);
|
||||||
|
match settings.tls_mode {
|
||||||
|
PostgresBackendTlsMode::Disabled => {
|
||||||
|
config.ssl_mode(tokio_postgres::config::SslMode::Disable);
|
||||||
|
},
|
||||||
|
PostgresBackendTlsMode::VerifyFull => {
|
||||||
|
config.ssl_mode(tokio_postgres::config::SslMode::Require);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tcp_host(host: &tokio_postgres::config::Host) -> bool {
|
||||||
|
return match host {
|
||||||
|
tokio_postgres::config::Host::Tcp(_) => true,
|
||||||
|
#[cfg(unix)]
|
||||||
|
tokio_postgres::config::Host::Unix(_) => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pool<T>(
|
||||||
|
pg_config: tokio_postgres::Config,
|
||||||
|
tls: T,
|
||||||
|
settings: &PostgresBackendSettings,
|
||||||
|
) -> std::result::Result<deadpool_postgres::Pool, crate::PostgresBackendError>
|
||||||
|
where
|
||||||
|
T: tokio_postgres::tls::MakeTlsConnect<tokio_postgres::Socket> + std::clone::Clone + std::marker::Send + std::marker::Sync + 'static,
|
||||||
|
T::Stream: std::marker::Send + std::marker::Sync,
|
||||||
|
T::TlsConnect: std::marker::Send + std::marker::Sync,
|
||||||
|
<T::TlsConnect as tokio_postgres::tls::TlsConnect<tokio_postgres::Socket>>::Future: std::marker::Send,
|
||||||
|
{
|
||||||
|
let manager_config = deadpool_postgres::ManagerConfig { recycling_method: deadpool_postgres::RecyclingMethod::Verified };
|
||||||
|
let manager = deadpool_postgres::Manager::from_config(pg_config, tls, manager_config);
|
||||||
|
let built = deadpool_postgres::Pool::builder(manager)
|
||||||
|
.max_size(settings.max_connections as usize)
|
||||||
|
.wait_timeout(std::option::Option::Some(settings.wait_timeout))
|
||||||
|
.create_timeout(std::option::Option::Some(settings.create_timeout))
|
||||||
|
.recycle_timeout(std::option::Option::Some(settings.recycle_timeout))
|
||||||
|
.runtime(deadpool_postgres::Runtime::Tokio1)
|
||||||
|
.build();
|
||||||
|
return match built {
|
||||||
|
std::result::Result::Ok(pool) => std::result::Result::Ok(pool),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "pool_build")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_verified_tls() -> std::result::Result<tokio_postgres_rustls::MakeRustlsConnect, crate::PostgresBackendError> {
|
||||||
|
let native = rustls_native_certs::load_native_certs();
|
||||||
|
let native_error_count = native.errors.len();
|
||||||
|
if native.certs.is_empty() {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, native_error_count, "no system TLS roots available for PostgreSQL verify_full");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::TlsFailed, "native_roots"));
|
||||||
|
}
|
||||||
|
let mut roots = rustls::RootCertStore::empty();
|
||||||
|
let (added, ignored) = roots.add_parsable_certificates(native.certs);
|
||||||
|
if added == 0 {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, native_error_count, ignored, "system TLS roots could not be admitted for PostgreSQL verify_full");
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::TlsFailed, "native_roots"));
|
||||||
|
}
|
||||||
|
if native_error_count > 0 || ignored > 0 {
|
||||||
|
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, added, ignored, native_error_count, "loaded PostgreSQL system TLS roots with partial diagnostics");
|
||||||
|
}
|
||||||
|
let provider = std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||||
|
let builder_result = rustls::ClientConfig::builder_with_provider(provider).with_safe_default_protocol_versions();
|
||||||
|
let builder = match builder_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::TlsFailed, "protocol_versions"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let client_config = builder.with_root_certificates(roots).with_no_client_auth();
|
||||||
|
return std::result::Result::Ok(tokio_postgres_rustls::MakeRustlsConnect::new(client_config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps one Deadpool acquisition error into a redacted backend classification.
|
||||||
|
pub(crate) fn map_pool_error(error: deadpool_postgres::PoolError) -> crate::PostgresBackendError {
|
||||||
|
return match error {
|
||||||
|
deadpool_postgres::PoolError::Timeout(timeout_type) => crate::PostgresBackendError::new(
|
||||||
|
crate::PostgresBackendErrorKind::PoolTimeout,
|
||||||
|
match timeout_type {
|
||||||
|
deadpool_postgres::TimeoutType::Wait => "pool_wait",
|
||||||
|
deadpool_postgres::TimeoutType::Create => "pool_create",
|
||||||
|
deadpool_postgres::TimeoutType::Recycle => "pool_recycle",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
deadpool_postgres::PoolError::Backend(_) => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConnectFailed, "physical_connect"),
|
||||||
|
deadpool_postgres::PoolError::Closed => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConnectFailed, "pool_closed"),
|
||||||
|
deadpool_postgres::PoolError::NoRuntimeSpecified => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "pool_runtime"),
|
||||||
|
deadpool_postgres::PoolError::PostCreateHook(_) => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConnectFailed, "pool_post_create"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts Deadpool status into a bounded safe backend runtime projection.
|
||||||
|
pub(crate) fn runtime_snapshot_from_status(status: deadpool_postgres::Status) -> PostgresBackendRuntimeSnapshot {
|
||||||
|
return PostgresBackendRuntimeSnapshot::new(
|
||||||
|
bounded_count(status.max_size),
|
||||||
|
bounded_count(status.size),
|
||||||
|
bounded_count(status.available),
|
||||||
|
bounded_count(status.waiting),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded_count(value: usize) -> u32 {
|
||||||
|
if value > u32::MAX as usize {
|
||||||
|
return u32::MAX;
|
||||||
|
}
|
||||||
|
return value as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/runtime.rs"]
|
||||||
|
mod tests;
|
||||||
1217
crates/ksp-store-postgres-lib/src/schema.rs
Normal file
1217
crates/ksp-store-postgres-lib/src/schema.rs
Normal file
File diff suppressed because it is too large
Load Diff
274
crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
Normal file
274
crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
|
||||||
|
// version: 14
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Dependency and ownership canaries for the physical PostgreSQL Store backend.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_facade_edge() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
for required in
|
||||||
|
["deadpool-postgres", "ksp-logging-lib", "ksp-store-api", "rustls", "rustls-native-certs", "sha2", "tokio-postgres", "tokio-postgres-rustls"]
|
||||||
|
{
|
||||||
|
assert!(manifest.contains(required), "missing PostgreSQL backend dependency: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["ksp-store-lib", "ksp-config-lib", "ksp-materializer", "ksp-program", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib", "sqlx"] {
|
||||||
|
assert!(!manifest.contains(forbidden), "forbidden PostgreSQL backend dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
let migration = include_str!("../src/migration.rs");
|
||||||
|
let schema = include_str!("../src/schema.rs");
|
||||||
|
let bootstrap_sql = include_str!("../migrations/v000_bootstrap/tables/001_ksp_store_schema_migrations.sql");
|
||||||
|
assert!(migration.contains("crate::V000_RESOURCES"));
|
||||||
|
assert!(migration.contains("crate::V001_RESOURCES"));
|
||||||
|
assert!(schema.contains("../migrations/v000_bootstrap/tables/001_ksp_store_schema_migrations.sql"));
|
||||||
|
assert!(schema.contains("../migrations/v001_raw_transaction/tables/001_ksp_store_identity.sql"));
|
||||||
|
assert!(bootstrap_sql.contains("ksp_store_schema_migrations"));
|
||||||
|
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
||||||
|
assert!(!bootstrap_sql.contains(forbidden), "business schema leaked into immutable V000 SQL: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("mod error;"));
|
||||||
|
assert!(crate_root.contains("mod health;"));
|
||||||
|
assert!(crate_root.contains("mod migration;"));
|
||||||
|
assert!(crate_root.contains("mod raw_transaction;"));
|
||||||
|
assert!(crate_root.contains("mod runtime;"));
|
||||||
|
assert!(crate_root.contains("mod schema;"));
|
||||||
|
assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;"));
|
||||||
|
for forbidden in [
|
||||||
|
"pub mod ",
|
||||||
|
"ksp_store_lib",
|
||||||
|
"ksp_config_lib",
|
||||||
|
"tokio_postgres::Client",
|
||||||
|
"tokio_postgres::Row",
|
||||||
|
"tokio_postgres::Statement",
|
||||||
|
"deadpool_postgres::Pool;",
|
||||||
|
] {
|
||||||
|
assert!(!crate_root.contains(forbidden), "forbidden PostgreSQL crate-root surface detected: {forbidden}");
|
||||||
|
}
|
||||||
|
let runtime = include_str!("../src/runtime.rs");
|
||||||
|
for forbidden in [
|
||||||
|
"std::env",
|
||||||
|
"dotenv",
|
||||||
|
"KSP_",
|
||||||
|
"KSPB_",
|
||||||
|
"PGHOST",
|
||||||
|
"PGPORT",
|
||||||
|
"PGUSER",
|
||||||
|
"PGPASSWORD",
|
||||||
|
".pgpass",
|
||||||
|
"CREATE TABLE",
|
||||||
|
"INSERT INTO",
|
||||||
|
"UPDATE ",
|
||||||
|
"DELETE FROM",
|
||||||
|
"SELECT ",
|
||||||
|
] {
|
||||||
|
assert!(!runtime.contains(forbidden), "forbidden PostgreSQL backend ownership/scope content detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_health_probe_remains_foundation_only_and_private_sql() {
|
||||||
|
let health = include_str!("../src/health.rs");
|
||||||
|
assert!(health.contains("SELECT 1::BIGINT"));
|
||||||
|
assert!(health.contains("ksp_store_schema_migrations"));
|
||||||
|
for forbidden in
|
||||||
|
["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED", "std::env", "dotenv", "KSP_SECRET_"]
|
||||||
|
{
|
||||||
|
assert!(!health.contains(forbidden), "forbidden health ownership/scope content detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_fix_001_migration_engine_uses_split_schema_contract_and_binds_network_without_repository_scope() {
|
||||||
|
let migration = include_str!("../src/migration.rs");
|
||||||
|
let schema = include_str!("../src/schema.rs");
|
||||||
|
let identity_table = include_str!("../migrations/v001_raw_transaction/tables/001_ksp_store_identity.sql");
|
||||||
|
let raw_table = include_str!("../migrations/v001_raw_transaction/tables/002_ksp_raw_transactions.sql");
|
||||||
|
let observation_table = include_str!("../migrations/v001_raw_transaction/tables/003_ksp_raw_transaction_observations.sql");
|
||||||
|
let archive_table = include_str!("../migrations/v001_raw_transaction/tables/004_ksp_raw_transaction_archive_payloads.sql");
|
||||||
|
let index = include_str!("../migrations/v001_raw_transaction/indexes/001_ix_ksp_raw_transactions_slot_signature.sql");
|
||||||
|
assert!(migration.contains("const EMBEDDED_MIGRATIONS: &[EmbeddedMigration]"));
|
||||||
|
assert!(migration.contains("MigrationHook::StoreIdentity"));
|
||||||
|
assert!(migration.contains("MigrationHookContext::AppliedNow"));
|
||||||
|
assert!(migration.contains("MigrationHookContext::Existing"));
|
||||||
|
assert!(migration.contains("schema_autocreate"));
|
||||||
|
assert!(migration.contains("schema_autoupdate"));
|
||||||
|
assert!(migration.contains("INSERT INTO ksp_store_identity (singleton, network) VALUES (1, $1)"));
|
||||||
|
assert!(migration.contains("SELECT singleton, network FROM ksp_store_identity ORDER BY singleton LIMIT 2"));
|
||||||
|
assert!(migration.contains("ksp_store_api::RawNetworkId::new(stored_network)"));
|
||||||
|
assert!(schema.contains("SchemaResourceState"));
|
||||||
|
assert!(schema.contains("verify_v001_external_compatibility"));
|
||||||
|
assert!(identity_table.contains("CREATE TABLE IF NOT EXISTS ksp_store_identity"));
|
||||||
|
assert!(raw_table.contains("CREATE TABLE IF NOT EXISTS ksp_raw_transactions"));
|
||||||
|
assert!(observation_table.contains("CREATE TABLE IF NOT EXISTS ksp_raw_transaction_observations"));
|
||||||
|
assert!(archive_table.contains("CREATE TABLE IF NOT EXISTS ksp_raw_transaction_archive_payloads"));
|
||||||
|
assert!(index.contains("CREATE INDEX IF NOT EXISTS ix_ksp_raw_transactions_slot_signature"));
|
||||||
|
for forbidden in ["impl ksp_store_api::RawTransaction", "repository", "sqlx", "RawAccountState"] {
|
||||||
|
assert!(!migration.contains(forbidden), "repository/cross-scope implementation leaked into migration engine: {forbidden}");
|
||||||
|
assert!(!schema.contains(forbidden), "repository/cross-scope implementation leaked into schema contract: {forbidden}");
|
||||||
|
}
|
||||||
|
for removed in ["migrations/V000__bootstrap.sql", "migrations/V001__raw_transaction.sql"] {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(removed);
|
||||||
|
assert!(!path.exists(), "obsolete monolithic migration must be deleted by pre.003-fix.001: {removed}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_raw_read_sql_and_mapping_remain_backend_private() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
let raw = include_str!("../src/raw_transaction.rs");
|
||||||
|
assert!(crate_root.contains("mod raw_transaction;"));
|
||||||
|
assert!(!crate_root.contains("pub mod raw_transaction"));
|
||||||
|
for required in [
|
||||||
|
"SELECT transaction_row.signature",
|
||||||
|
"ksp_raw_transaction_observations",
|
||||||
|
"slot::text AS slot_text",
|
||||||
|
"RawPayload::try_new",
|
||||||
|
"RawTransactionTombstone::try_new",
|
||||||
|
"PostgresBackendErrorKind::DataInvalid",
|
||||||
|
"PostgresBackendErrorKind::WrongNetwork",
|
||||||
|
] {
|
||||||
|
assert!(raw.contains(required), "missing private RAW read mapping contract: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["std::env", "dotenv", "ksp_store_lib", "ksp_config_lib"] {
|
||||||
|
assert!(!raw.contains(forbidden), "RAW module contains forbidden ownership material: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_raw_write_sql_is_atomic_idempotent_and_keeps_direct_trait_scope_closed() {
|
||||||
|
let raw = include_str!("../src/raw_transaction.rs");
|
||||||
|
for required in [
|
||||||
|
"INSERT INTO ksp_raw_transactions",
|
||||||
|
"ON CONFLICT (signature) DO NOTHING RETURNING signature",
|
||||||
|
"FOR UPDATE",
|
||||||
|
"INSERT INTO ksp_raw_transaction_observations",
|
||||||
|
"ON CONFLICT (observation_key) DO NOTHING RETURNING observation_key",
|
||||||
|
"REHYDRATE_TRANSACTION_SQL",
|
||||||
|
"RawEntityWriteOutcome::SkippedPurged",
|
||||||
|
"RawEntityWriteOutcome::Rehydrated",
|
||||||
|
"RawObservationWriteOutcome::NotRecorded",
|
||||||
|
"PostgresBackendErrorKind::Conflict",
|
||||||
|
"PostgresBackendErrorKind::ReferenceNotFound",
|
||||||
|
"PostgresBackendErrorKind::WriteFailed",
|
||||||
|
] {
|
||||||
|
assert!(raw.contains(required), "missing pre.005 RAW write contract: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["impl ksp_store_api::RawTransactionWrite", "impl ksp_store_api::RawTransactionObservationWrite"] {
|
||||||
|
assert!(!raw.contains(forbidden), "pre.005 opened direct Store trait scope prematurely: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_raw_pagination_is_keyset_cursor_bound_and_policy_free() {
|
||||||
|
let raw = include_str!("../src/raw_transaction.rs");
|
||||||
|
let cursor = include_str!("../src/raw_transaction/cursor.rs");
|
||||||
|
let index = include_str!("../migrations/v001_raw_transaction/indexes/001_ix_ksp_raw_transactions_slot_signature.sql");
|
||||||
|
for required in [
|
||||||
|
"LIST_TRANSACTIONS_ASC_SQL",
|
||||||
|
"LIST_TRANSACTIONS_DESC_SQL",
|
||||||
|
"retention_state <> 'purged'",
|
||||||
|
"(slot, signature) >",
|
||||||
|
"(slot, signature) <",
|
||||||
|
"ORDER BY slot ASC, signature ASC",
|
||||||
|
"ORDER BY slot DESC, signature DESC",
|
||||||
|
"LIMIT $5",
|
||||||
|
"list_raw_transactions",
|
||||||
|
] {
|
||||||
|
assert!(raw.contains(required), "missing pre.006 keyset pagination contract: {required}");
|
||||||
|
}
|
||||||
|
for required in [
|
||||||
|
"CURSOR_BYTES: usize = 109",
|
||||||
|
"CURSOR_MAGIC",
|
||||||
|
"b\"KSPT\"",
|
||||||
|
"CURSOR_VERSION: u8 = 1",
|
||||||
|
"KSP/raw-transaction-cursor/v1",
|
||||||
|
"sha2::Sha256",
|
||||||
|
"query.network().as_str()",
|
||||||
|
"query.direction()",
|
||||||
|
"query.slots().start_inclusive()",
|
||||||
|
"query.slots().end_inclusive()",
|
||||||
|
"last_slot",
|
||||||
|
"last_signature",
|
||||||
|
"PageLimitUnsupported",
|
||||||
|
"9_223_372_036_854_775_806",
|
||||||
|
] {
|
||||||
|
assert!(cursor.contains(required), "missing pre.006 cursor/binding contract: {required}");
|
||||||
|
}
|
||||||
|
assert!(index.contains("ON ksp_raw_transactions (slot, signature)"));
|
||||||
|
assert!(index.contains("WHERE retention_state <> 'purged'"));
|
||||||
|
for forbidden in [" OFFSET ", "limit.min(", "clamp(", "500", "1000"] {
|
||||||
|
assert!(!raw.contains(forbidden), "pre.006 contains forbidden pagination/policy/later-scope material: {forbidden}");
|
||||||
|
assert!(!cursor.contains(forbidden), "pre.006 cursor contains forbidden pagination/policy/later-scope material: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_raw_retention_is_atomic_compare_and_transition_without_fake_compaction() {
|
||||||
|
let raw = include_str!("../src/raw_transaction.rs");
|
||||||
|
let runtime = include_str!("../src/runtime.rs");
|
||||||
|
for required in [
|
||||||
|
"LOCK_RETENTION_TRANSACTION_SQL",
|
||||||
|
"FOR UPDATE",
|
||||||
|
"INSERT_ARCHIVE_PAYLOAD_SQL",
|
||||||
|
"INSERT INTO ksp_raw_transaction_archive_payloads (signature, payload)",
|
||||||
|
"UPDATE_ARCHIVED_TRANSACTION_SQL",
|
||||||
|
"SET payload = NULL, retention_state = 'archived'",
|
||||||
|
"DELETE_ARCHIVE_PAYLOAD_SQL",
|
||||||
|
"DELETE FROM ksp_raw_transaction_archive_payloads",
|
||||||
|
"UPDATE_PURGED_TRANSACTION_SQL",
|
||||||
|
"SET block_time_unix_millis = NULL, payload = NULL, retention_state = 'purged'",
|
||||||
|
"if current == target",
|
||||||
|
"if current != expected",
|
||||||
|
"RawRetentionWriteOutcome::AlreadyAtTarget",
|
||||||
|
"RawRetentionWriteOutcome::ExpectedStateMismatch",
|
||||||
|
"RawRetentionWriteOutcome::Applied",
|
||||||
|
"RetentionCompactionUnsupported",
|
||||||
|
"transition_raw_transaction_retention",
|
||||||
|
] {
|
||||||
|
assert!(raw.contains(required), "missing pre.007 retention contract: {required}");
|
||||||
|
}
|
||||||
|
assert!(runtime.contains("pub async fn transition_raw_transaction_retention"));
|
||||||
|
for forbidden in ["retention_state = 'compacted'", "impl ksp_store_api::RawTransactionRetentionWrite", "flate", "zstd", "lz4", "snappy"] {
|
||||||
|
assert!(!raw.contains(forbidden), "pre.007 contains fake compaction/direct-trait scope: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_backend_trait_implementations_stay_in_runtime_bridge_and_raw_account_scope_stays_closed() {
|
||||||
|
let runtime = include_str!("../src/runtime.rs");
|
||||||
|
for required in [
|
||||||
|
"impl ksp_store_api::RawTransactionRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionWrite for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationWrite for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionWrite for PostgresBackend",
|
||||||
|
] {
|
||||||
|
assert!(runtime.contains(required), "missing pre.008 PostgreSQL capability implementation: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"impl ksp_store_api::RawAccountStateRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawAccountStateWrite for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawAccountObservationRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawAccountObservationWrite for PostgresBackend",
|
||||||
|
] {
|
||||||
|
assert!(!runtime.contains(forbidden), "pre.008 opened RawAccount capability scope prematurely: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
304
crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
Normal file
304
crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
|
||||||
|
// version: 11
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Hardening, exact-surface and scope canaries for the physical PostgreSQL Store backend.
|
||||||
|
|
||||||
|
const SECRET_CANARY: &str = "KSP-POSTGRES-SECRET-CANARY-PRE009";
|
||||||
|
|
||||||
|
fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||||
|
let mut future = std::boxed::Box::pin(future);
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
return match std::future::Future::poll(future.as_mut(), &mut context) {
|
||||||
|
std::task::Poll::Ready(value) => value,
|
||||||
|
std::task::Poll::Pending => panic!("PostgreSQL hardening pre-I/O rejection unexpectedly became pending"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn network() -> ksp_store_api::RawNetworkId {
|
||||||
|
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid backend hardening network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(connection_uri: &str, tls_mode: ksp_store_postgres_lib::PostgresBackendTlsMode) -> ksp_store_postgres_lib::PostgresBackendSettings {
|
||||||
|
return ksp_store_postgres_lib::PostgresBackendSettings::new(
|
||||||
|
network(),
|
||||||
|
connection_uri,
|
||||||
|
8,
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
tls_mode,
|
||||||
|
true,
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_reexport_names(source: &str) -> std::vec::Vec<&str> {
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.starts_with("pub use ") || !trimmed.ends_with(';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let without_semicolon = trimmed.trim_end_matches(';');
|
||||||
|
let name = match without_semicolon.rsplit("::").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_dependency_names(source: &str) -> std::vec::Vec<&str> {
|
||||||
|
let dependencies_tail = match source.split("[dependencies]").nth(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in dependencies.lines() {
|
||||||
|
let content = match line.split('#').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim(),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if content.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = match content.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !key.is_empty() {
|
||||||
|
names.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_pre_io_rejection(connection_uri: &str, tls_mode: ksp_store_postgres_lib::PostgresBackendTlsMode, expected_phase: &str) {
|
||||||
|
let settings = settings(connection_uri, tls_mode);
|
||||||
|
let rendered_settings = std::format!("{settings:?}");
|
||||||
|
assert!(!rendered_settings.contains(SECRET_CANARY));
|
||||||
|
assert!(rendered_settings.contains("<redacted>"));
|
||||||
|
let result = poll_ready(ksp_store_postgres_lib::PostgresBackend::open(settings));
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => panic!("hostile PostgreSQL settings unexpectedly opened"),
|
||||||
|
};
|
||||||
|
assert_eq!(error.kind(), ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid);
|
||||||
|
assert_eq!(error.phase(), expected_phase);
|
||||||
|
assert!(!std::format!("{error:?}").contains(SECRET_CANARY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_backend_modules_exports_and_manifest_dependencies_are_exact() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
for required in ["mod constants;", "mod error;", "mod health;", "mod migration;", "mod raw_transaction;", "mod runtime;", "mod schema;"] {
|
||||||
|
assert!(crate_root.contains(required), "missing PostgreSQL backend module: {required}");
|
||||||
|
}
|
||||||
|
assert!(!crate_root.contains("pub mod "));
|
||||||
|
let actual_exports = public_reexport_names(crate_root);
|
||||||
|
let mut expected_exports = [
|
||||||
|
"ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED",
|
||||||
|
"PostgresBackend",
|
||||||
|
"PostgresBackendError",
|
||||||
|
"PostgresBackendErrorKind",
|
||||||
|
"PostgresBackendHealthSnapshot",
|
||||||
|
"PostgresBackendRuntimeSnapshot",
|
||||||
|
"PostgresBackendSettings",
|
||||||
|
"PostgresBackendTlsMode",
|
||||||
|
];
|
||||||
|
expected_exports.sort_unstable();
|
||||||
|
assert_eq!(actual_exports.as_slice(), expected_exports.as_slice());
|
||||||
|
assert_eq!(actual_exports.len(), 8);
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
let actual_dependencies = manifest_dependency_names(manifest);
|
||||||
|
let expected_dependencies = [
|
||||||
|
"deadpool-postgres",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"ksp-store-api",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"sha2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-postgres",
|
||||||
|
"tokio-postgres-rustls",
|
||||||
|
];
|
||||||
|
assert_eq!(actual_dependencies.as_slice(), expected_dependencies.as_slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_hostile_uri_matrix_is_rejected_before_io_without_secret_echo() {
|
||||||
|
let malformed = std::format!("not-a-postgresql-uri-{SECRET_CANARY}");
|
||||||
|
assert_pre_io_rejection(malformed.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, "connection_uri");
|
||||||
|
let oversized = std::format!("{}{SECRET_CANARY}", "x".repeat(4_097));
|
||||||
|
assert_pre_io_rejection(oversized.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, "connection_uri");
|
||||||
|
let hostaddr_only = std::format!("hostaddr=127.0.0.1 user=operator password={SECRET_CANARY} dbname=ksp");
|
||||||
|
assert_pre_io_rejection(hostaddr_only.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull, "tls_server_identity");
|
||||||
|
let server_options = std::format!("host=localhost user=operator password={SECRET_CANARY} dbname=ksp options='-c application_name={SECRET_CANARY}'");
|
||||||
|
assert_pre_io_rejection(server_options.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, "server_options");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_backend_error_bridge_cannot_retain_external_error_or_secret_text() {
|
||||||
|
let error_source = include_str!("../src/error.rs");
|
||||||
|
assert!(error_source.contains("kind: PostgresBackendErrorKind"));
|
||||||
|
assert!(error_source.contains("phase: &'static str"));
|
||||||
|
for forbidden in ["String", "source:", "message:", "tokio_postgres::Error", "deadpool_postgres::PoolError"] {
|
||||||
|
assert!(!error_source.contains(forbidden), "backend error type can retain forbidden external material: {forbidden}");
|
||||||
|
}
|
||||||
|
let runtime = include_str!("../src/runtime.rs");
|
||||||
|
assert!(runtime.contains("deadpool_postgres::PoolError::Backend(_)"));
|
||||||
|
assert!(!runtime.contains("deadpool_postgres::PoolError::Backend(error)"));
|
||||||
|
for source in [
|
||||||
|
runtime,
|
||||||
|
include_str!("../src/migration.rs"),
|
||||||
|
include_str!("../src/health.rs"),
|
||||||
|
include_str!("../src/raw_transaction.rs"),
|
||||||
|
include_str!("../src/raw_transaction/cursor.rs"),
|
||||||
|
] {
|
||||||
|
for forbidden in ["format!(\"{error", "format!(\"{error:?", "error = ?", "error = %"] {
|
||||||
|
assert!(!source.contains(forbidden), "backend source renders external error material: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_backend_has_no_env_bypass_reverse_facade_edge_or_raw_account_trait_implementation() {
|
||||||
|
let production = std::format!(
|
||||||
|
"{}
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
{}",
|
||||||
|
include_str!("../src/error.rs"),
|
||||||
|
include_str!("../src/health.rs"),
|
||||||
|
include_str!("../src/lib.rs"),
|
||||||
|
include_str!("../src/migration.rs"),
|
||||||
|
include_str!("../src/raw_transaction.rs"),
|
||||||
|
include_str!("../src/raw_transaction/cursor.rs"),
|
||||||
|
include_str!("../src/runtime.rs"),
|
||||||
|
include_str!("../src/schema.rs")
|
||||||
|
);
|
||||||
|
for forbidden in [
|
||||||
|
"std::env",
|
||||||
|
"dotenv",
|
||||||
|
"KSP_SECRET_",
|
||||||
|
"KSPB_",
|
||||||
|
"PGHOST",
|
||||||
|
"PGPORT",
|
||||||
|
"PGUSER",
|
||||||
|
"PGPASSWORD",
|
||||||
|
".pgpass",
|
||||||
|
".postgresql/",
|
||||||
|
"sslrootcert",
|
||||||
|
"sslcert",
|
||||||
|
"sslkey",
|
||||||
|
"ksp_store_lib",
|
||||||
|
"ksp_config_lib",
|
||||||
|
"sqlx::",
|
||||||
|
"impl ksp_store_api::RawAccount",
|
||||||
|
] {
|
||||||
|
assert!(!production.contains(forbidden), "forbidden backend ownership/reverse-edge/RawAccount material detected: {forbidden}");
|
||||||
|
}
|
||||||
|
let bootstrap_sql = include_str!("../migrations/v000_bootstrap/tables/001_ksp_store_schema_migrations.sql");
|
||||||
|
assert!(bootstrap_sql.contains("ksp_store_schema_migrations"));
|
||||||
|
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
||||||
|
assert!(!bootstrap_sql.contains(forbidden), "business schema leaked into foundation migration: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_009_live_raw_transaction_proof_is_opt_in_isolated_and_secret_safe() {
|
||||||
|
let live = include_str!("postgres_raw_transaction_live.rs");
|
||||||
|
for required in [
|
||||||
|
"#[ignore = \"opt-in real PostgreSQL RawTransaction proof; reads one dedicated URI from stdin\"]",
|
||||||
|
"std::io::stdin().read_line",
|
||||||
|
"managed_schema_preexisting_refusal",
|
||||||
|
"prove_schema_update_policy",
|
||||||
|
"prove_concurrent_identical_insert",
|
||||||
|
"prove_concurrent_divergent_insert",
|
||||||
|
"prove_pagination",
|
||||||
|
"prove_retention_and_rehydrate",
|
||||||
|
"prove_retention_races",
|
||||||
|
"prove_cancellation_rollback",
|
||||||
|
"task.abort()",
|
||||||
|
"cleanup_verification",
|
||||||
|
] {
|
||||||
|
assert!(live.contains(required), "missing pre.009 live proof guard/scenario: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["std::env", "KSP_SECRET_", "PGPASSWORD", "connection_uri = %", "connection_uri = ?", "println!(uri", "eprintln!(uri"] {
|
||||||
|
assert!(!live.contains(forbidden), "pre.009 live proof contains forbidden secret/environment material: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_raw_transaction_capability_implementation_inventory_is_exact_and_raw_account_scope_stays_closed() {
|
||||||
|
let runtime = include_str!("../src/runtime.rs");
|
||||||
|
let capability_impls = [
|
||||||
|
"impl ksp_store_api::RawTransactionRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionWrite for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionObservationWrite for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawTransactionRetentionWrite for PostgresBackend",
|
||||||
|
];
|
||||||
|
for implementation in capability_impls {
|
||||||
|
assert_eq!(runtime.matches(implementation).count(), 1, "unexpected PostgreSQL capability implementation inventory: {implementation}");
|
||||||
|
}
|
||||||
|
for forbidden in [
|
||||||
|
"impl ksp_store_api::RawAccountStateRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawAccountStateWrite for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawAccountObservationRead for PostgresBackend",
|
||||||
|
"impl ksp_store_api::RawAccountObservationWrite for PostgresBackend",
|
||||||
|
] {
|
||||||
|
assert!(!runtime.contains(forbidden), "RawAccountState scope opened during RawTransaction hardening: {forbidden}");
|
||||||
|
}
|
||||||
|
let migration = include_str!("../src/migration.rs");
|
||||||
|
assert!(!migration.contains("ksp_raw_account"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_010_raw_transaction_private_sql_keeps_keyset_navigation_and_bounded_statement_surface() {
|
||||||
|
let source = include_str!("../src/raw_transaction.rs");
|
||||||
|
for required in [
|
||||||
|
"ORDER BY slot ASC, signature ASC",
|
||||||
|
"ORDER BY slot DESC, signature DESC",
|
||||||
|
"LIMIT $5",
|
||||||
|
"FOR UPDATE",
|
||||||
|
"ON CONFLICT (signature) DO NOTHING",
|
||||||
|
"ON CONFLICT (observation_key) DO NOTHING",
|
||||||
|
"ksp_raw_transaction_archive_payloads",
|
||||||
|
] {
|
||||||
|
assert!(source.contains(required), "required hardened RawTransaction SQL contract missing: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in [" OFFSET ", "SELECT *", "ON CONFLICT DO UPDATE", "processing_state", "batch_size", "priority"] {
|
||||||
|
assert!(!source.contains(forbidden), "forbidden RawTransaction scope/policy SQL detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
445
crates/ksp-store-postgres-lib/tests/postgres_foundation_live.rs
Normal file
445
crates/ksp-store-postgres-lib/tests/postgres_foundation_live.rs
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/tests/postgres_foundation_live.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Opt-in real PostgreSQL proof for the Store foundation runtime.
|
||||||
|
//!
|
||||||
|
//! The test reads one dedicated PostgreSQL URI from stdin, refuses to start
|
||||||
|
//! when any KSP Store table managed by V000/V001 already exists, never prints
|
||||||
|
//! the URI, and cleans up only the isolated schema it proved absent first. It
|
||||||
|
//! validates migration/bootstrap behavior, not RawTransaction capabilities.
|
||||||
|
|
||||||
|
const LIVE_BOOTSTRAP_SQL: &str = include_str!("../migrations/v000_bootstrap/tables/001_ksp_store_schema_migrations.sql");
|
||||||
|
const LIVE_BROKEN_CHECKSUM_A: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
const LIVE_BROKEN_CHECKSUM_B: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||||
|
const LIVE_MANAGED_SCHEMA_DROP_SQL: &str = r#"DROP TABLE IF EXISTS ksp_raw_transaction_observations;
|
||||||
|
DROP TABLE IF EXISTS ksp_raw_transaction_archive_payloads;
|
||||||
|
DROP TABLE IF EXISTS ksp_raw_transactions;
|
||||||
|
DROP TABLE IF EXISTS ksp_store_identity;
|
||||||
|
DROP TABLE IF EXISTS ksp_store_schema_migrations;"#;
|
||||||
|
const LIVE_MANAGED_SCHEMA_EXISTS_SQL: &str = r#"SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name IN (
|
||||||
|
'ksp_store_schema_migrations',
|
||||||
|
'ksp_store_identity',
|
||||||
|
'ksp_raw_transactions',
|
||||||
|
'ksp_raw_transaction_observations',
|
||||||
|
'ksp_raw_transaction_archive_payloads'
|
||||||
|
)
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
)"#;
|
||||||
|
const LIVE_MAX_URI_BYTES: usize = 4_096;
|
||||||
|
const LIVE_METADATA_EXISTS_SQL: &str = r#"SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'ksp_store_schema_migrations'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
)"#;
|
||||||
|
const LIVE_SENTINEL_CHECKSUM_SQL: &str = "SELECT checksum FROM ksp_store_schema_migrations WHERE version = 0";
|
||||||
|
const LIVE_SENTINEL_INSERT_SQL: &str =
|
||||||
|
"INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES (0, 'bootstrap', 'pre008_rollback_injected', CURRENT_TIMESTAMP)";
|
||||||
|
const LIVE_SENTINEL_UPDATE_SQL: &str = "UPDATE ksp_store_schema_migrations SET checksum = $1 WHERE version = 0";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct LiveFailure {
|
||||||
|
phase: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiveFailure {
|
||||||
|
const fn new(phase: &'static str) -> Self {
|
||||||
|
return Self { phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn phase(&self) -> &'static str {
|
||||||
|
return self.phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "opt-in real PostgreSQL foundation proof; reads one dedicated URI from stdin"]
|
||||||
|
fn pre_008_real_postgres_foundation_is_safe_idempotent_concurrent_and_recoverable() {
|
||||||
|
eprintln!("KSP Store PostgreSQL live proof: reading one dedicated URI from stdin without echoing it from the test.");
|
||||||
|
let uri_result = read_uri_from_stdin();
|
||||||
|
let uri = match uri_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("PostgreSQL live input rejected at phase {}", error.phase()),
|
||||||
|
};
|
||||||
|
let runtime_result = tokio::runtime::Builder::new_current_thread().enable_all().build();
|
||||||
|
let runtime = match runtime_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => panic!("PostgreSQL live runtime could not be constructed"),
|
||||||
|
};
|
||||||
|
let outcome = runtime.block_on(run_live_test(uri.as_str()));
|
||||||
|
if let std::result::Result::Err(error) = outcome {
|
||||||
|
panic!("PostgreSQL live foundation proof failed at safe phase {}", error.phase());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_uri_from_stdin() -> std::result::Result<std::string::String, LiveFailure> {
|
||||||
|
let mut input = std::string::String::new();
|
||||||
|
let read_result = std::io::stdin().read_line(&mut input);
|
||||||
|
match read_result {
|
||||||
|
std::result::Result::Ok(0) | std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("stdin_read")),
|
||||||
|
std::result::Result::Ok(_) => {},
|
||||||
|
}
|
||||||
|
let uri = input.trim().to_owned();
|
||||||
|
if uri.is_empty() || uri.len() > LIVE_MAX_URI_BYTES {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("stdin_uri"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_live_test(uri: &str) -> std::result::Result<(), LiveFailure> {
|
||||||
|
let admin_result = connect_admin(uri).await;
|
||||||
|
let mut admin = match admin_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let preexisting_result = managed_schema_exists(&admin).await;
|
||||||
|
let preexisting = match preexisting_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if preexisting {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("managed_schema_preexisting_refusal"));
|
||||||
|
}
|
||||||
|
let major_result = postgres_major(&admin).await;
|
||||||
|
let major = match major_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if major < 15 {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("postgres_major_unsupported"));
|
||||||
|
}
|
||||||
|
eprintln!("KSP Store PostgreSQL live proof: server major {major}");
|
||||||
|
let mut owns_schema = false;
|
||||||
|
let scenario = run_foundation_scenario(&mut admin, uri, &mut owns_schema).await;
|
||||||
|
let cleanup = if owns_schema { drop_managed_schema(&admin).await } else { std::result::Result::Ok(()) };
|
||||||
|
if let std::result::Result::Err(error) = cleanup {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = scenario {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let remains_result = managed_schema_exists(&admin).await;
|
||||||
|
let remains = match remains_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if remains {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("cleanup_verification"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_foundation_scenario(admin: &mut tokio_postgres::Client, uri: &str, owns_schema: &mut bool) -> std::result::Result<(), LiveFailure> {
|
||||||
|
let initial_result = open_backend(uri).await;
|
||||||
|
let initial = match initial_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let created_result = metadata_exists(admin).await;
|
||||||
|
let created = match created_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !created {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("initial_bootstrap_metadata"));
|
||||||
|
}
|
||||||
|
*owns_schema = true;
|
||||||
|
let initial_health = initial.health().await;
|
||||||
|
if !initial_health.is_ready() || initial_health.migration_version() != std::option::Option::Some(1) || initial_health.pending_migration_count() != 0 {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("initial_health"));
|
||||||
|
}
|
||||||
|
let initial_close = close_backend(initial).await;
|
||||||
|
if let std::result::Result::Err(error) = initial_close {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let idempotent_result = open_backend(uri).await;
|
||||||
|
let idempotent = match idempotent_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let idempotent_health = idempotent.health().await;
|
||||||
|
if !idempotent_health.is_ready()
|
||||||
|
|| idempotent_health.migration_version() != std::option::Option::Some(1)
|
||||||
|
|| idempotent_health.pending_migration_count() != 0
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(LiveFailure::new("idempotent_health"));
|
||||||
|
}
|
||||||
|
let idempotent_close = close_backend(idempotent).await;
|
||||||
|
if let std::result::Result::Err(error) = idempotent_close {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let reset_result = drop_managed_schema(admin).await;
|
||||||
|
if let std::result::Result::Err(error) = reset_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let concurrent_result = concurrent_bootstrap(uri).await;
|
||||||
|
if let std::result::Result::Err(error) = concurrent_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let after_concurrent_result = metadata_exists(admin).await;
|
||||||
|
match after_concurrent_result {
|
||||||
|
std::result::Result::Ok(true) => {},
|
||||||
|
std::result::Result::Ok(false) => return std::result::Result::Err(LiveFailure::new("concurrent_bootstrap_metadata")),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let checksum_result = sentinel_checksum(admin).await;
|
||||||
|
let checksum = match checksum_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let broken_checksum = if checksum == LIVE_BROKEN_CHECKSUM_A { LIVE_BROKEN_CHECKSUM_B } else { LIVE_BROKEN_CHECKSUM_A };
|
||||||
|
let corrupt_result = set_sentinel_checksum(admin, broken_checksum).await;
|
||||||
|
if let std::result::Result::Err(error) = corrupt_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let mismatch_settings = match settings(uri) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mismatch = ksp_store_postgres_lib::PostgresBackend::open(mismatch_settings).await;
|
||||||
|
match mismatch {
|
||||||
|
std::result::Result::Err(error) if error.kind() == ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => {},
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("checksum_mismatch_classification")),
|
||||||
|
std::result::Result::Ok(backend) => {
|
||||||
|
let _ = close_backend(backend).await;
|
||||||
|
return std::result::Result::Err(LiveFailure::new("checksum_mismatch_accepted"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let restore_result = set_sentinel_checksum(admin, checksum.as_str()).await;
|
||||||
|
if let std::result::Result::Err(error) = restore_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let recovered_result = open_backend(uri).await;
|
||||||
|
let recovered = match recovered_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let recovered_health = recovered.health().await;
|
||||||
|
if !recovered_health.is_ready() {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("checksum_recovery_health"));
|
||||||
|
}
|
||||||
|
let recovered_close = close_backend(recovered).await;
|
||||||
|
if let std::result::Result::Err(error) = recovered_close {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let rollback_reset = drop_managed_schema(admin).await;
|
||||||
|
if let std::result::Result::Err(error) = rollback_reset {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let rollback_result = prove_transaction_rollback(admin).await;
|
||||||
|
if let std::result::Result::Err(error) = rollback_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let absent_after_rollback = metadata_exists(admin).await;
|
||||||
|
match absent_after_rollback {
|
||||||
|
std::result::Result::Ok(false) => {},
|
||||||
|
std::result::Result::Ok(true) => return std::result::Result::Err(LiveFailure::new("rollback_left_metadata")),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let final_result = open_backend(uri).await;
|
||||||
|
let final_backend = match final_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let final_health = final_backend.health().await;
|
||||||
|
if !final_health.is_ready() || final_health.migration_version() != std::option::Option::Some(1) || final_health.pending_migration_count() != 0 {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("final_health"));
|
||||||
|
}
|
||||||
|
return close_backend(final_backend).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(uri: &str) -> std::result::Result<ksp_store_postgres_lib::PostgresBackendSettings, LiveFailure> {
|
||||||
|
let network_result = ksp_store_api::RawNetworkId::new("devnet");
|
||||||
|
let network = match network_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("network")),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_store_postgres_lib::PostgresBackendSettings::new(
|
||||||
|
network,
|
||||||
|
uri,
|
||||||
|
4,
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled,
|
||||||
|
true,
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_backend(uri: &str) -> std::result::Result<ksp_store_postgres_lib::PostgresBackend, LiveFailure> {
|
||||||
|
let settings_result = settings(uri);
|
||||||
|
let value = match settings_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match ksp_store_postgres_lib::PostgresBackend::open(value).await {
|
||||||
|
std::result::Result::Ok(backend) => std::result::Result::Ok(backend),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("backend_open")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close_backend(backend: ksp_store_postgres_lib::PostgresBackend) -> std::result::Result<(), LiveFailure> {
|
||||||
|
return match backend.close(std::time::Duration::from_secs(5)).await {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("backend_close")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn concurrent_bootstrap(uri: &str) -> std::result::Result<(), LiveFailure> {
|
||||||
|
let first_settings = match settings(uri) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let second_settings = match settings(uri) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let first = tokio::spawn(async move {
|
||||||
|
return ksp_store_postgres_lib::PostgresBackend::open(first_settings).await;
|
||||||
|
});
|
||||||
|
let second = tokio::spawn(async move {
|
||||||
|
return ksp_store_postgres_lib::PostgresBackend::open(second_settings).await;
|
||||||
|
});
|
||||||
|
let first_joined = first.await;
|
||||||
|
let second_joined = second.await;
|
||||||
|
let first_backend = match first_joined {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
|
||||||
|
_ => return std::result::Result::Err(LiveFailure::new("concurrent_first")),
|
||||||
|
};
|
||||||
|
let second_backend = match second_joined {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
|
||||||
|
_ => {
|
||||||
|
let _ = close_backend(first_backend).await;
|
||||||
|
return std::result::Result::Err(LiveFailure::new("concurrent_second"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let first_close = close_backend(first_backend).await;
|
||||||
|
let second_close = close_backend(second_backend).await;
|
||||||
|
if first_close.is_err() || second_close.is_err() {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("concurrent_close"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_admin(uri: &str) -> std::result::Result<tokio_postgres::Client, LiveFailure> {
|
||||||
|
let parsed = uri.parse::<tokio_postgres::Config>();
|
||||||
|
let mut config = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("admin_config")),
|
||||||
|
};
|
||||||
|
config.ssl_mode(tokio_postgres::config::SslMode::Disable);
|
||||||
|
config.ssl_negotiation(tokio_postgres::config::SslNegotiation::Postgres);
|
||||||
|
let connected = config.connect(tokio_postgres::NoTls).await;
|
||||||
|
let (client, connection) = match connected {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("admin_connect")),
|
||||||
|
};
|
||||||
|
let _connection_task = tokio::spawn(async move {
|
||||||
|
let _result = connection.await;
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
return std::result::Result::Ok(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn postgres_major(client: &tokio_postgres::Client) -> std::result::Result<u32, LiveFailure> {
|
||||||
|
let row_result = client.query_one("SHOW server_version_num", &[]).await;
|
||||||
|
let row = match row_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("server_version")),
|
||||||
|
};
|
||||||
|
let value_result = row.try_get::<usize, std::string::String>(0);
|
||||||
|
let value = match value_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("server_version_decode")),
|
||||||
|
};
|
||||||
|
let parsed = value.parse::<u32>();
|
||||||
|
let version_num = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("server_version_parse")),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(version_num / 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn managed_schema_exists(client: &tokio_postgres::Client) -> std::result::Result<bool, LiveFailure> {
|
||||||
|
let row_result = client.query_one(LIVE_MANAGED_SCHEMA_EXISTS_SQL, &[]).await;
|
||||||
|
let row = match row_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("managed_schema_probe")),
|
||||||
|
};
|
||||||
|
return match row.try_get::<usize, bool>(0) {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("managed_schema_probe_decode")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata_exists(client: &tokio_postgres::Client) -> std::result::Result<bool, LiveFailure> {
|
||||||
|
let row_result = client.query_one(LIVE_METADATA_EXISTS_SQL, &[]).await;
|
||||||
|
let row = match row_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("metadata_probe")),
|
||||||
|
};
|
||||||
|
return match row.try_get::<usize, bool>(0) {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("metadata_probe_decode")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drop_managed_schema(client: &tokio_postgres::Client) -> std::result::Result<(), LiveFailure> {
|
||||||
|
return match client.batch_execute(LIVE_MANAGED_SCHEMA_DROP_SQL).await {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("managed_schema_cleanup")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sentinel_checksum(client: &tokio_postgres::Client) -> std::result::Result<std::string::String, LiveFailure> {
|
||||||
|
let row_result = client.query_one(LIVE_SENTINEL_CHECKSUM_SQL, &[]).await;
|
||||||
|
let row = match row_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("sentinel_read")),
|
||||||
|
};
|
||||||
|
return match row.try_get::<usize, std::string::String>(0) {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("sentinel_decode")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_sentinel_checksum(client: &tokio_postgres::Client, checksum: &str) -> std::result::Result<(), LiveFailure> {
|
||||||
|
let update = client.execute(LIVE_SENTINEL_UPDATE_SQL, &[&checksum]).await;
|
||||||
|
return match update {
|
||||||
|
std::result::Result::Ok(1) => std::result::Result::Ok(()),
|
||||||
|
_ => std::result::Result::Err(LiveFailure::new("sentinel_update")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prove_transaction_rollback(client: &mut tokio_postgres::Client) -> std::result::Result<(), LiveFailure> {
|
||||||
|
let transaction_result = client.transaction().await;
|
||||||
|
let transaction = match transaction_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("rollback_begin")),
|
||||||
|
};
|
||||||
|
let create = transaction.batch_execute(LIVE_BOOTSTRAP_SQL).await;
|
||||||
|
if create.is_err() {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("rollback_create"));
|
||||||
|
}
|
||||||
|
let insert = transaction.batch_execute(LIVE_SENTINEL_INSERT_SQL).await;
|
||||||
|
if insert.is_err() {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("rollback_insert"));
|
||||||
|
}
|
||||||
|
let injected = transaction.batch_execute("SELECT 1 / 0").await;
|
||||||
|
if injected.is_ok() {
|
||||||
|
return std::result::Result::Err(LiveFailure::new("rollback_injection_missing"));
|
||||||
|
}
|
||||||
|
drop(transaction);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
1176
crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs
Normal file
1176
crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs
Normal file
File diff suppressed because it is too large
Load Diff
123
crates/ksp-store-postgres-lib/tests/public_api.rs
Normal file
123
crates/ksp-store-postgres-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/tests/public_api.rs
|
||||||
|
// version: 9
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Narrow physical bridge canaries consumed by `ksp-store-lib` without exposing driver or pool types.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_backend_bridge_is_constructible_without_io() {
|
||||||
|
let network = match ksp_store_api::RawNetworkId::new("devnet") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("valid backend bridge network rejected: {error:?}"),
|
||||||
|
};
|
||||||
|
let settings = ksp_store_postgres_lib::PostgresBackendSettings::new(
|
||||||
|
network,
|
||||||
|
"postgresql://operator:secret@localhost/ksp",
|
||||||
|
8,
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||||
|
true,
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
assert_eq!(settings.network().as_str(), "devnet");
|
||||||
|
assert_eq!(settings.tls_mode().code(), "verify_full");
|
||||||
|
let _open = ksp_store_postgres_lib::PostgresBackend::open;
|
||||||
|
let _close = ksp_store_postgres_lib::PostgresBackend::close;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_backend_error_projection_is_safe_and_static() {
|
||||||
|
let kinds = [
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed,
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork,
|
||||||
|
];
|
||||||
|
assert_eq!(kinds.len(), 18);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_backend_health_bridge_exposes_only_safe_snapshot_types() {
|
||||||
|
let _runtime = std::mem::size_of::<std::option::Option<ksp_store_postgres_lib::PostgresBackendRuntimeSnapshot>>();
|
||||||
|
let _health = std::mem::size_of::<std::option::Option<ksp_store_postgres_lib::PostgresBackendHealthSnapshot>>();
|
||||||
|
let _runtime_snapshot = ksp_store_postgres_lib::PostgresBackend::runtime_snapshot;
|
||||||
|
let _health_probe = ksp_store_postgres_lib::PostgresBackend::health;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_003_retention_compaction_error_code_matches_store_contract_value() {
|
||||||
|
assert_eq!(ksp_store_postgres_lib::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED.domain(), "store");
|
||||||
|
assert_eq!(ksp_store_postgres_lib::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED.code(), "postgres_retention_compaction_unsupported",);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_raw_read_bridge_uses_only_backend_independent_models() {
|
||||||
|
let _get = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction;
|
||||||
|
let _observation = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction_observation;
|
||||||
|
let _retention = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction_retention_state;
|
||||||
|
let _tombstone = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction_tombstone;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_raw_write_bridge_uses_only_backend_independent_models_and_outcomes() {
|
||||||
|
let _acquisition = ksp_store_postgres_lib::PostgresBackend::persist_raw_transaction_acquisition;
|
||||||
|
let _observation = ksp_store_postgres_lib::PostgresBackend::record_raw_transaction_observation;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_raw_list_bridge_uses_backend_independent_query_page_and_reference_models() {
|
||||||
|
let _list = ksp_store_postgres_lib::PostgresBackend::list_raw_transactions;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_raw_retention_write_bridge_uses_backend_independent_transition_and_outcome_models() {
|
||||||
|
let _transition = ksp_store_postgres_lib::PostgresBackend::transition_raw_transaction_retention;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_raw_transaction_capabilities<T>()
|
||||||
|
where
|
||||||
|
T: ksp_store_api::RawTransactionRead
|
||||||
|
+ ksp_store_api::RawTransactionWrite
|
||||||
|
+ ksp_store_api::RawTransactionObservationRead
|
||||||
|
+ ksp_store_api::RawTransactionObservationWrite
|
||||||
|
+ ksp_store_api::RawTransactionRetentionRead
|
||||||
|
+ ksp_store_api::RawTransactionRetentionWrite,
|
||||||
|
{
|
||||||
|
let _marker = std::marker::PhantomData::<T>;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_008_postgres_backend_implements_all_six_raw_transaction_capabilities() {
|
||||||
|
assert_raw_transaction_capabilities::<ksp_store_postgres_lib::PostgresBackend>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user