Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb56574824 | |||
| 8363c8bdc9 | |||
| b375f263dc | |||
| e1fe419028 | |||
| 9ec4f26874 | |||
| 80eea82398 | |||
| e39cf656b2 | |||
| c4f56d9e85 | |||
| c1dbaad88d | |||
| d93184d41d | |||
| de3cec6a23 | |||
| 0a4cddafc4 | |||
| 6d2b1401aa | |||
| 0a8fb5dd1b | |||
| a8c90107b5 | |||
| ced653bfc0 | |||
| 153014be9d | |||
| 8d4b3b67fb | |||
| 6cc94ebb46 | |||
| 8e8f1f0b4a | |||
| 78e015413b | |||
| 2f0eb316f5 | |||
| 55325ffd32 | |||
| ad02b6f13c | |||
| 47b12e14e6 | |||
| c43d4d7c38 | |||
| b76baf6835 | |||
| c83e3261d0 | |||
| 28ca5bdac5 | |||
| afd4c770f9 | |||
| ecc5607fd4 | |||
| 11e4cde77f | |||
| 62ed72f2b5 | |||
| 1a07574bae | |||
| 29a888d1dc | |||
| 3d307be645 | |||
| 7dfe892467 | |||
| 2cf00e9426 | |||
| 4eba9be48c | |||
| bebfc63b75 | |||
| 0319794e59 | |||
| bcf2f16c05 | |||
| 27d4cb1f36 | |||
| c1f61a380f | |||
| 5e5ed6d1ab | |||
| 31461cf9be | |||
| 8c8b7ee986 | |||
| 5bfe6f0820 | |||
| 8c4e835fdd | |||
| 695d1080bd | |||
| e774a065b4 | |||
| 29f27ae109 | |||
| 0ee28eeb95 | |||
| 900444bff5 | |||
| 99477d0d2b | |||
| 9148920f42 |
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
|
||||||
|
|||||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,8 +1,42 @@
|
|||||||
<!-- file: CHANGELOG.md -->
|
<!-- file: CHANGELOG.md -->
|
||||||
<!-- version: 16 -->
|
<!-- version: 20 -->
|
||||||
|
|
||||||
# Changelog KSP
|
# Changelog KSP
|
||||||
|
|
||||||
|
## 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` 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.
|
||||||
|
|
||||||
|
La façade publique conserve un modèle objet sans SQL ni rows backend, des primitives de provenance/hash/timestamps bornées, des queries cursorisées sans plafond métier KSP arbitraire, des outcomes d’idempotence/conflit, dix capabilities fines object-safe et un lifecycle logique de rétention transactionnelle `Full -> Compacted -> Archived -> Purged`. Le tombstone minimal empêche le rebackfill normal après purge tandis que `ForceRehydrate` reste une intention distincte ; une course de compare-and-transition est représentée par `ExpectedStateMismatch` plutôt que par un overwrite silencieux. Le backlog, le batch-size, la priorité, la policy de processing, la compression/archive physique, les notifications runtime et les couches STRUCTURAL/DECODED/DOMAIN restent hors Store API. Le graphe normal final de `ksp-store-api` reste strictement limité à `ksp-core-lib`; aucun PostgreSQL, Tokio, serde, Config, Transport, Program, Logging ou backend concret n’entre dans la crate.
|
||||||
|
|
||||||
|
Les canaris de clôture verrouillent 60 exports crate-root, 10 capabilities, l’inventaire exact des modules RAW, l’implémentabilité par un backend externe, les bornes adversariales, la redaction des `Debug`, la frontière Interface/Store et l’absence de surface N2/N3/N4. Le gate technique de référence a été exécuté après `cargo clean` et passe audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés des crates, `cargo test --workspace`, builds Tauri des trois Desk et graphes Cargo ; les gates documentaires suivants restent également verts. Le redécoupage final prépare trois releases Store/PostgreSQL où `ksp-store-lib` et `ksp-store-postgres-lib` avancent toujours ensemble : `0.3.2` pour la fondation runtime/backend, `0.3.3` pour la vertical slice `RawTransaction`, puis `0.3.4` pour `RawAccountState` et la complétude RAW. `prompts/021-V0_3_2_START_PROMPT.md` ouvre donc uniquement la fondation conjointe Store/PostgreSQL, avec `tokio-postgres` comme driver retenu mais pooling, TLS, migrations et Config à réauditer avant implémentation lourde.
|
||||||
|
|
||||||
|
## 0.2.14 — Program API foundation — 2026-08-28
|
||||||
|
|
||||||
|
`0.2.14` introduit `ksp-program-api` comme première API publique extensible du domaine Program, volontairement limitée au décodage d’instructions et indépendante des runtimes supérieurs. La façade réexporte les contrats Core/Interface nécessaires puis possède `ProgramInstructionRecognition` (`NoMatch`, `ProgramMatch`, `ExactMatch`), `ProgramInstructionDecodeOutcome<Decoded>` (`Decoded`, `Unsupported`) et le trait `ProgramInstructionDecoder: Send + Sync`. L’output `Decoded` reste possédé par l’implémentation et ne reçoit aucun bound implicite `Debug`, `Clone`, `Send` ou `Sync`; les erreurs réelles restent dans le `Result` Core. Les Program IDs sont des `Pubkey` opaques : une implémentation externe peut prendre en charge un programme absent du registry Core sans enum centrale fermée, `Any`, JSON, descriptor global ni registry runtime.
|
||||||
|
|
||||||
|
Le graphe normal final reste strictement `ksp-program-api -> ksp-core-lib + ksp-interface-lib`, Interface dépendant elle-même de Core. Aucun `ksp-program-lib`, codec, serde, logging, réseau, filesystem, environnement, Store, Materializer, Wallet, Config ou Tauri n’entre dans cette foundation. Les canaris public API, implémentation externe, dependency firewall, release completeness et hardening couvrent notamment l’inventaire exact de dix exports crate-root, les trois modules de production, un Program Pubkey non enregistré, l’input Interface maximal de 255 accounts / 10 240 bytes, l’absence d’echo automatique d’un payload hostile et l’absence de claim `dyn` hétérogène. `pre.005-fix.001` corrige uniquement un faux positif cross-crate du scanner Logging provoqué par le motif de test recherché, sans changement fonctionnel. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, les 18 tests Program API, ownership Logging et le workspace complet avant la réconciliation documentaire finale.
|
||||||
|
|
||||||
|
`prompts/020-V0_3_1_START_PROMPT.md` ouvre `0.3.1 — Store RAW foundation` exclusivement depuis le tag stable `v0.2.14`. Le gate `pre.001` exige également l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer l’ancien `ks-store`, ses migrations PostgreSQL et ses contrats N1/N2/N3 sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`. L’archive reste une source historique uniquement : `0.3.1` doit créer `ksp-store-api` et `ksp-store-lib` avec PostgreSQL de référence et persistence **RAW seulement**, sans aspirer les contrats CORE/DECODE/SPECIALIZED, les jobs/workers, l’Interface `0.3.2` ni la configuration produit.
|
||||||
|
|
||||||
|
## 0.2.13 — Interface / wire foundation — 2026-08-28
|
||||||
|
|
||||||
|
`0.2.13` introduit `ksp-interface-lib` comme première façade wire officielle KSP, volontairement passive et Program-facing. La surface stable réexporte le `Pubkey` canonique de Core, ajoute `ProgramAccountMeta` et `ProgramInstruction` à champs privés avec accessors explicites, conserve l’ordre et les doublons des account metas, accepte les Program Pubkeys opaques et borne l’admission à **255 account metas** et **10 240 octets** de data. Les deux bornes sont des limites d’admission Interface et ne prétendent pas garantir à elles seules le fit d’une transaction Solana top-level. Les erreurs réutilisent le contrat Core `Error/Result` avec uniquement `field`, `actual_len` et `maximum_len`, tandis que le `Debug` de l’instruction n’expose que `program_id`, `account_count` et `data_len`.
|
||||||
|
|
||||||
|
La dependency direction finale reste strictement `ksp-interface-lib -> ksp-core-lib -> solana-pubkey`. Aucun `serde`, codec générique (`borsh`, `wincode`, `bincode`), `solana-instruction`, runtime réseau ou logging n’est ajouté à cette foundation. Les canaris public API, consumer externe, dependency firewall et release completeness verrouillent les six exports crate-root, les quatre modules de production, l’absence de narrowing/codec caché, le transfert des `Vec` admis sans réallocation interne et la non-répétition de matériel hostile dans les erreurs. `pre.005-fix.001` corrige uniquement un faux positif du scanner Logging provoqué par le littéral de test `tracing::`, sans changement de production ni de dépendance. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, tests Interface, ownership Logging, workspace complet et graphes Cargo avant la réconciliation documentaire finale.
|
||||||
|
|
||||||
|
`prompts/019-V0_2_14_START_PROMPT.md` ouvre `0.2.14 — Program API foundation` exclusivement depuis le tag stable `v0.2.13`. Le gate `pre.001` de cette prochaine release exige aussi l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer les anciens contrats decoder/executor sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`, sans faire de kbot3 une autorité architecturale ni recréer son monolithe `ks-lib`.
|
||||||
|
|
||||||
## 0.2.12 — SOL Prices Desk + projection prix Wallet — 2026-08-27
|
## 0.2.12 — SOL Prices Desk + projection prix Wallet — 2026-08-27
|
||||||
|
|
||||||
`0.2.12` ajoute `ksp-app-solprices-desk`, troisième Desk Tauri spécialisé de KSP, comme HID strictement provider-neutral au-dessus de `ksp-offchain-transport-lib`. Son composite dédié sélectionne `std.logging` et `std.offchain_transport`; la vue Prices expose l’inventaire des huit providers SOL/USD, disponibilité, sémantique, auth, prix exact sous forme de chaîne, timestamps provider/KSP distincts et retry deadline sans inventer de fraîcheur. Les refresh manuel individuel, sélectionné et global passent exclusivement par `MarketPriceService`; les batchs sont bornés à `1..=64`, les états `in_flight` restent backend-owned et aucun polling, scheduler, fallback, moyenne ou prix canonique n’est introduit dans le Desk. Le frontend conserve l’absence de réseau/persistence navigateur et journalise les interactions sans prix, credential, URL, header ou payload provider.
|
`0.2.12` ajoute `ksp-app-solprices-desk`, troisième Desk Tauri spécialisé de KSP, comme HID strictement provider-neutral au-dessus de `ksp-offchain-transport-lib`. Son composite dédié sélectionne `std.logging` et `std.offchain_transport`; la vue Prices expose l’inventaire des huit providers SOL/USD, disponibilité, sémantique, auth, prix exact sous forme de chaîne, timestamps provider/KSP distincts et retry deadline sans inventer de fraîcheur. Les refresh manuel individuel, sélectionné et global passent exclusivement par `MarketPriceService`; les batchs sont bornés à `1..=64`, les états `in_flight` restent backend-owned et aucun polling, scheduler, fallback, moyenne ou prix canonique n’est introduit dans le Desk. Le frontend conserve l’absence de réseau/persistence navigateur et journalise les interactions sans prix, credential, URL, header ou payload provider.
|
||||||
|
|||||||
16
Cargo.toml
16
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 302
|
# version: 347
|
||||||
|
|
||||||
[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-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "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.2.12"
|
version = "0.3.2"
|
||||||
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 }
|
||||||
|
|||||||
63
ROADMAP.md
63
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 90 -->
|
<!-- version: 95 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -57,8 +57,8 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
||||||
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
||||||
- [X] `0.2.12` — SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi d’une moyenne SOL/USD consumer-owned et d’un équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
|
- [X] `0.2.12` — SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi d’une moyenne SOL/USD consumer-owned et d’un équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
|
||||||
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, façade wire KSP publique et passive utilisable par les implémentations officielles et externes ; `pre.001` doit confronter l’architecture actuelle aux contrats utiles de l’ancien `khadhroony-bot3` sans recréer son monolithe `ks-lib`, et séparer explicitement données wire, comportement Program et implémentations.
|
- [X] `0.2.13` — Interface / wire foundation stable : `ksp-interface-lib` expose `Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` passifs, admission bornée à 255 account metas / 10 240 bytes, erreurs et `Debug` sans payload hostile, façade crate-root et consumer externe canaris ; graphe strict `Interface -> Core`, sans serde/codec générique, `solana-instruction`, réseau ni logging runtime.
|
||||||
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet ; les traits de reconnaissance/decode/build et autres comportements ne doivent pas être aspirés prématurément dans `ksp-interface-lib`.
|
- [X] `0.2.14` — Program API foundation stable : `ksp-program-api` expose une surface instruction-only ouverte avec `ProgramInstructionRecognition`, `ProgramInstructionDecodeOutcome<Decoded>` et `ProgramInstructionDecoder`; implémentation externe avec Program Pubkey opaque validée, 10 exports crate-root / 3 modules de production verrouillés, graphe strict Core + Interface, sans registry/runtime/serde/codec/Store/Materializer/Execution. Le prompt `0.3.1` prépare Store RAW-only.
|
||||||
|
|
||||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||||
|
|
||||||
@@ -80,33 +80,52 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
|
|
||||||
## Architecture de données — progression canonique
|
## Architecture de données — progression canonique
|
||||||
|
|
||||||
La chaîne durable cible est :
|
La progression durable cible est :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||||
```
|
```
|
||||||
|
|
||||||
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
|
- **RAW** et **STRUCTURAL** ne nécessitent aucun décodage Program.
|
||||||
- À la fin de chaque couche horizontale RAW/CORE, ajouter les jobs/workers/apps nécessaires pour la rendre réellement exploitable avant d'ouvrir la couche suivante.
|
- La progression n'est pas une chaîne obligatoire pour chaque famille : une donnée event-only peut s'arrêter en N1, et un état de compte pourra aller directement vers un futur decoder si aucune décomposition STRUCTURAL utile n'existe.
|
||||||
- À partir de **DECODE**, avancer verticalement groupe par groupe : wire -> decode -> matérialisation -> projection spécialisée si utile -> préparation d'exécution -> policy -> execution -> scénarios Devnet.
|
- À la fin des couches horizontales RAW/STRUCTURAL réellement persistées, ajouter les jobs/workers/apps nécessaires avant d'ouvrir la couche suivante.
|
||||||
|
- À partir de **DECODED**, avancer verticalement groupe par groupe ; **DOMAIN** désigne les projections métier et la matérialisation est le processus qui les produit.
|
||||||
|
|
||||||
## 0.3.x — RAW / acquisition persistée
|
## 0.3.x — RAW / acquisition persistée
|
||||||
|
|
||||||
- [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
|
- [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` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
|
- [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` — Introduire `ksp-job-api` et un job de backfill historique concret.
|
- [ ] `0.3.3` — Étendre le même couple avec la vertical slice PostgreSQL `RawTransaction` complète : les six capabilities transaction/observation/rétention de `ksp-store-api`, persistence acquisition atomique, get/list cursorisé, idempotence/conflit, binding réseau, rétention/tombstone/force-rehydrate, concurrence et rollback validés sur PostgreSQL réel.
|
||||||
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
|
- [ ] `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.
|
||||||
- [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d'exploitation réellement nécessaires avant de passer à CORE.
|
- [ ] `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.7` — Introduire une application spécialisée de backfill/inspection RAW.
|
||||||
|
- [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d’exploitation réellement nécessaires avant de passer à la couche de normalisation générique suivante.
|
||||||
|
|
||||||
## Série CORE suivante
|
### TODO/IDEAS — taxonomie N1, processing et rétention
|
||||||
|
|
||||||
- [ ] Définir la persistence CORE canonique Solana générique.
|
- [ ] **TODO** — maintenir la matrice d’admission HTTP/WS/gRPC/provider lors de toute nouvelle famille N1 : plusieurs sources ne convergent vers un même struct que si elles satisfont la même sémantique sans perte.
|
||||||
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
|
- [X] `RawAccountState` + observation — contrat commun stabilisé en `0.3.1` avec bytes complets + slot, provenance séparée et enrichissements source-specific optionnels ; la persistence PostgreSQL physique reste réservée à `0.3.4`.
|
||||||
- [ ] Ajouter replay/backfill RAW -> CORE.
|
- [ ] **TODO** — `TransactionStatusObservation` : réauditer `signatureSubscribe`, `getSignatureStatuses`, Yellowstone TransactionStatus et extensions provider lorsqu’un consumer réel apparaît ; ne pas fusionner snapshot, transition et update dans un modèle Option-soup.
|
||||||
- [ ] Ajouter worker/service CORE.
|
- [ ] **TODO** — logs realtime : conserver `logMessages` dans `RawTransaction` jusqu’à la décomposition STRUCTURAL ; traiter `logsSubscribe` comme event-only candidat et décider son contrat passif dans `ksp-interface-lib`, sans table Store par défaut. Le format canonique d’un wake-up « donnée persistée disponible » reste distinct et appartient à `ksp-store-api` conformément à `KSP-NOTIFY-*`, mais ne sera matérialisé qu’avec un publisher/consumer réel.
|
||||||
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
|
- [ ] **TODO** — slot/root/slotsUpdates et vote : ne créer un modèle passif commun que si un consumer realtime réel et une sémantique cross-ledger/provider justifient le contrat ; aucune persistence Store par défaut.
|
||||||
|
- [ ] **IDEA** — `RawBlock` : ne rouvrir que si une information block-level non reconstructible devient nécessaire ; `getBlock` doit d’abord être traité comme source de `RawTransaction`, pas comme invitation à recopier le ledger en blocs.
|
||||||
|
- [ ] **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.
|
||||||
|
- [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] 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.
|
||||||
|
|
||||||
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
|
## Série STRUCTURAL suivante
|
||||||
|
|
||||||
|
- [ ] Définir la persistence STRUCTURAL canonique Solana générique pour les familles réellement décomposables.
|
||||||
|
- [ ] Implémenter en priorité `RawTransaction -> STRUCTURAL` sans decoder Program : transaction/message, comptes/références, instructions top-level, CPI/inner instructions, logs/meta/balances/return data et relations structurelles.
|
||||||
|
- [ ] Vérifier avant extension si d'autres familles N1 possèdent une vraie décomposition STRUCTURAL utile ; ne pas créer de niveau vide par convention.
|
||||||
|
- [ ] Ajouter replay/backfill RAW -> STRUCTURAL avec processing versionné.
|
||||||
|
- [ ] Ajouter worker/service STRUCTURAL et l'application de contrôle/inspection utile.
|
||||||
|
|
||||||
|
## Séries DECODED/DOMAIN/EXECUTION — progression verticale
|
||||||
|
|
||||||
### Priorité 1 — Solana Core Programs
|
### Priorité 1 — Solana Core Programs
|
||||||
|
|
||||||
@@ -119,7 +138,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
|||||||
- [ ] SPL Token.
|
- [ ] SPL Token.
|
||||||
- [ ] Associated Token Account.
|
- [ ] Associated Token Account.
|
||||||
- [ ] Token-2022 et extensions pertinentes.
|
- [ ] Token-2022 et extensions pertinentes.
|
||||||
- [ ] Pour chaque famille : decode -> materialize -> specialized -> prepare -> policy -> execute -> scenarios.
|
- [ ] Pour chaque famille : decode -> materialize -> domain projection -> prepare -> policy -> execute -> scenarios.
|
||||||
|
|
||||||
### Priorité 3 — Metadata token
|
### Priorité 3 — Metadata token
|
||||||
|
|
||||||
@@ -143,7 +162,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
|||||||
|
|
||||||
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
||||||
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
||||||
- [ ] Lire les projections SPECIALIZED KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
- [ ] Lire les projections DOMAIN KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
|
|||||||
78
config/examples/std.store.example.json
Normal file
78
config/examples/std.store.example.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"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": {
|
||||||
|
"auto_migrate": 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": {
|
||||||
|
"auto_migrate": 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": {
|
||||||
|
"auto_migrate": true,
|
||||||
|
"migration_timeout_ms": 30000,
|
||||||
|
"migration_lock_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"shutdown_timeout_ms": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
168
config/schemas/std.store.schema.json
Normal file
168
config/schemas/std.store.schema.json
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.store:v1",
|
||||||
|
"title": "KSP standard Store configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$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
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"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/postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"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/bootstrap"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
config/std.store.json
Normal file
78
config/std.store.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"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": {
|
||||||
|
"auto_migrate": 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": {
|
||||||
|
"auto_migrate": 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": {
|
||||||
|
"auto_migrate": 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,
|
||||||
|
|||||||
280
crates/ksp-config-lib/src/store.rs
Normal file
280
crates/ksp-config-lib/src/store.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/store.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// 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: bool,
|
||||||
|
migration_lock_timeout_ms: u64,
|
||||||
|
migration_timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 = ksp_store_lib::PostgresBootstrapSettings::new(
|
||||||
|
source.postgres.bootstrap.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),
|
||||||
|
);
|
||||||
|
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("../..");
|
||||||
|
|||||||
78
crates/ksp-config-lib/unit_tests/fixtures/std.store.json
Normal file
78
crates/ksp-config-lib/unit_tests/fixtures/std.store.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"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": {
|
||||||
|
"auto_migrate": 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": {
|
||||||
|
"auto_migrate": 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": {
|
||||||
|
"auto_migrate": 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();
|
||||||
|
|||||||
199
crates/ksp-config-lib/unit_tests/store.rs
Normal file
199
crates/ksp-config-lib/unit_tests/store.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/store.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[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_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("../..");
|
||||||
|
}
|
||||||
14
crates/ksp-interface-lib/Cargo.toml
Normal file
14
crates/ksp-interface-lib/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# file: crates/ksp-interface-lib/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-interface-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
131
crates/ksp-interface-lib/README.md
Normal file
131
crates/ksp-interface-lib/README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!-- file: crates/ksp-interface-lib/README.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# ksp-interface-lib
|
||||||
|
|
||||||
|
`ksp-interface-lib` est la façade wire officielle KSP destinée aux contrats passifs partagés par les implémentations Program Solana officielles ou externes. La crate expose uniquement des structures de représentation/admission ; elle ne possède ni transport, ni exécution, ni persistence, ni comportement métier Program.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
La crate réutilise les primitives fondamentales déjà possédées par `ksp-core-lib` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pubkey
|
||||||
|
Error / ErrorCode / Result
|
||||||
|
Program IDs fondamentaux
|
||||||
|
```
|
||||||
|
|
||||||
|
`Pubkey` est réexporté depuis le crate-root Interface afin qu'un consumer wire n'introduise aucun wrapper d'identité parallèle. Les Program IDs restent possédés et répertoriés par Core.
|
||||||
|
|
||||||
|
La dependency direction candidate `0.2.13` reste strictement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-interface-lib
|
||||||
|
└── ksp-core-lib
|
||||||
|
└── solana-pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
## Surface publique `0.2.13`
|
||||||
|
|
||||||
|
La façade crate-root expose exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pubkey
|
||||||
|
ProgramAccountMeta
|
||||||
|
MAX_PROGRAM_INSTRUCTION_ACCOUNTS
|
||||||
|
ProgramInstruction
|
||||||
|
MAX_PROGRAM_INSTRUCTION_DATA_LEN
|
||||||
|
ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun module interne n'est public.
|
||||||
|
|
||||||
|
### `ProgramAccountMeta`
|
||||||
|
|
||||||
|
`ProgramAccountMeta` représente un compte ordonné d'une instruction avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pubkey
|
||||||
|
is_signer
|
||||||
|
is_writable
|
||||||
|
```
|
||||||
|
|
||||||
|
Les champs restent privés. Les constructeurs publics sont `readonly(pubkey, is_signer)` et `writable(pubkey, is_signer)`, complétés par les accessors `pubkey()`, `is_signer()` et `is_writable()`.
|
||||||
|
|
||||||
|
La structure n'applique aucune sémantique spécifique à un programme et n'exige pas que la `Pubkey` appartienne au registry des Program IDs Core.
|
||||||
|
|
||||||
|
### `ProgramInstruction`
|
||||||
|
|
||||||
|
`ProgramInstruction` représente un contrat passif borné :
|
||||||
|
|
||||||
|
```text
|
||||||
|
program_id
|
||||||
|
accounts: Vec<ProgramAccountMeta>
|
||||||
|
data: Vec<u8>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProgramInstruction::try_new` consomme directement les deux `Vec`, conserve l'ordre et les doublons des accounts et préserve les octets opaques de `data` sans décodage.
|
||||||
|
|
||||||
|
Les cas vides sont valides et une `program_id` inconnue du registry KSP reste admissible.
|
||||||
|
|
||||||
|
## Bornes d'admission
|
||||||
|
|
||||||
|
Interface applique deux limites locales :
|
||||||
|
|
||||||
|
| Limite | Valeur |
|
||||||
|
|------------------------------------|----------|
|
||||||
|
| `MAX_PROGRAM_INSTRUCTION_ACCOUNTS` | `255` |
|
||||||
|
| `MAX_PROGRAM_INSTRUCTION_DATA_LEN` | `10_240` |
|
||||||
|
|
||||||
|
Ces valeurs sont des **bornes d'admission Interface**. Elles ne constituent pas une garantie qu'une instruction donnée tient dans toutes les contraintes d'une transaction Solana top-level. La limite CPI de comptes uniques n'est notamment pas transformée en règle artificielle sur la liste d'account metas.
|
||||||
|
|
||||||
|
Un dépassement utilise le code commun :
|
||||||
|
|
||||||
|
```text
|
||||||
|
interface.program_instruction_limit_exceeded
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contexte d'erreur est limité aux métadonnées sûres `field`, `actual_len` et `maximum_len`. Aucun payload arbitraire ni account meta hostile n'est recopié dans l'erreur.
|
||||||
|
|
||||||
|
Le `Debug` de `ProgramInstruction` est volontairement borné : il affiche `program_id`, `account_count` et `data_len`, jamais les accounts complets ni les octets du payload.
|
||||||
|
|
||||||
|
## Codecs et runtime
|
||||||
|
|
||||||
|
La foundation `0.2.13` n'ajoute aucun codec par réflexe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
serde / serde_json absents
|
||||||
|
borsh absent
|
||||||
|
wincode absent
|
||||||
|
bincode absent
|
||||||
|
solana-instruction absent
|
||||||
|
```
|
||||||
|
|
||||||
|
Des codecs/layouts/discriminants spécifiques pourront être ajoutés ultérieurement uniquement lorsqu'un vertical Program réel en démontre le besoin et que leur ownership wire appartient bien à Interface.
|
||||||
|
|
||||||
|
La crate ne produit aucun événement runtime. Elle ne dépend donc pas de `ksp-logging-lib` et ne possède ni `constants.rs` ni `TRACING_TARGET`. Si un futur comportement Interface exige réellement du logging, le runtime devra passer par la façade Logging KSP plutôt que par une dépendance directe à Tracing.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
`ksp-interface-lib` ne possède pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
RPC / WebSocket / gRPC
|
||||||
|
provider DTOs Transport
|
||||||
|
wallet / signature
|
||||||
|
Config / environnement
|
||||||
|
persistence / Store
|
||||||
|
Program decoding / recognition / proofs
|
||||||
|
execution policy / signers
|
||||||
|
transaction replay / CPI path / runtime logs
|
||||||
|
lifecycle réseau
|
||||||
|
```
|
||||||
|
|
||||||
|
La foundation Program API est reportée à `0.2.14`. Les wires génériques d'acquisition/CORE restent reportés à `0.3.2+`.
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- [Usage public](USAGE.md)
|
||||||
|
- [Plan `0.2.13`](../../docs/plans/020-V0_2_13_INTERFACE_PLAN.md)
|
||||||
|
- [Validation `0.2.13`](../../docs/validation/016-V0_2_13_INTERFACE.md)
|
||||||
|
- [Architecture Wire + Program](../../docs/architecture/006-WIRE_AND_PROGRAM.md)
|
||||||
122
crates/ksp-interface-lib/USAGE.md
Normal file
122
crates/ksp-interface-lib/USAGE.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<!-- file: crates/ksp-interface-lib/USAGE.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# Usage de ksp-interface-lib
|
||||||
|
|
||||||
|
Cette page décrit la façade publique matérialisée par `0.2.13`. Les modules internes ne font pas partie du contrat consommable : utiliser uniquement les exports du crate-root.
|
||||||
|
|
||||||
|
## Construire des account metas
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let readonly_account = ksp_interface_lib::Pubkey::new_from_array([1_u8; 32]);
|
||||||
|
let writable_account = ksp_interface_lib::Pubkey::new_from_array([2_u8; 32]);
|
||||||
|
|
||||||
|
let readonly = ksp_interface_lib::ProgramAccountMeta::readonly(readonly_account, true);
|
||||||
|
let writable = ksp_interface_lib::ProgramAccountMeta::writable(writable_account, false);
|
||||||
|
|
||||||
|
assert_eq!(readonly.pubkey(), &readonly_account);
|
||||||
|
assert!(readonly.is_signer());
|
||||||
|
assert!(!readonly.is_writable());
|
||||||
|
|
||||||
|
assert_eq!(writable.pubkey(), &writable_account);
|
||||||
|
assert!(!writable.is_signer());
|
||||||
|
assert!(writable.is_writable());
|
||||||
|
```
|
||||||
|
|
||||||
|
`readonly`/`writable` décrivent uniquement les flags wire de l'account meta. Interface ne valide pas l'identité du compte contre un registry Program.
|
||||||
|
|
||||||
|
## Construire une instruction passive
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let program_id = ksp_interface_lib::Pubkey::new_from_array([3_u8; 32]);
|
||||||
|
let account_id = ksp_interface_lib::Pubkey::new_from_array([4_u8; 32]);
|
||||||
|
|
||||||
|
let account = ksp_interface_lib::ProgramAccountMeta::writable(account_id, true);
|
||||||
|
let result = ksp_interface_lib::ProgramInstruction::try_new(
|
||||||
|
program_id,
|
||||||
|
std::vec![account, account],
|
||||||
|
std::vec![7_u8, 8, 9],
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
std::result::Result::Ok(instruction) => {
|
||||||
|
assert_eq!(instruction.program_id(), &program_id);
|
||||||
|
assert_eq!(instruction.accounts(), &[account, account]);
|
||||||
|
assert_eq!(instruction.data(), &[7_u8, 8, 9]);
|
||||||
|
}
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
L'ordre et les doublons des accounts sont conservés. Les octets `data` restent opaques : `ProgramInstruction` ne les sérialise, désérialise ni interprète.
|
||||||
|
|
||||||
|
Les `Vec` fournis à `try_new` sont consommés par la structure après validation des bornes ; aucun clone ou reformatage interne n'est requis par le contrat actuel.
|
||||||
|
|
||||||
|
## Bornes
|
||||||
|
|
||||||
|
Les limites publiques sont :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||||
|
```
|
||||||
|
|
||||||
|
`255` account metas et `10_240` bytes de data sont admis. `256` account metas ou `10_241` bytes sont refusés par `try_new` avant création d'un `ProgramInstruction` valide.
|
||||||
|
|
||||||
|
Ces limites sont des bornes locales Interface et ne promettent pas qu'une instruction admise respecte à elle seule toutes les contraintes de taille/account-set d'une transaction Solana complète.
|
||||||
|
|
||||||
|
## Observer une erreur de limite
|
||||||
|
|
||||||
|
Le code public est :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
assert_eq!(
|
||||||
|
ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.domain(),
|
||||||
|
"interface",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.code(),
|
||||||
|
"program_instruction_limit_exceeded",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Une erreur de dépassement expose uniquement un contexte structurel sûr :
|
||||||
|
|
||||||
|
```text
|
||||||
|
field
|
||||||
|
actual_len
|
||||||
|
maximum_len
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contenu du payload et les account metas arbitraires ne sont pas projetés dans le diagnostic.
|
||||||
|
|
||||||
|
## Debug borné
|
||||||
|
|
||||||
|
Le `Debug` de `ProgramInstruction` contient uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
program_id
|
||||||
|
account_count
|
||||||
|
data_len
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne faut donc pas attendre de ce rendu une sérialisation wire ou un dump du payload.
|
||||||
|
|
||||||
|
## Dépendances à ne pas ajouter côté consumer
|
||||||
|
|
||||||
|
Un consumer de la façade Interface n'a pas besoin d'ajouter un SDK Program Solana uniquement pour reconstruire `ProgramInstruction`. La crate utilise le `Pubkey` canonique partagé avec Core et conserve son propre contrat passif.
|
||||||
|
|
||||||
|
La foundation ne fournit volontairement pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
serde générique
|
||||||
|
Borsh / Wincode générique
|
||||||
|
solana-instruction interop automatique
|
||||||
|
transport réseau
|
||||||
|
Program decoder/preparer
|
||||||
|
signing/execution
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces surfaces doivent être introduites dans leur owner respectif lorsqu'un cas réel le justifie, pas comme dépendances implicites d'un consumer Interface.
|
||||||
6
crates/ksp-interface-lib/src/error.rs
Normal file
6
crates/ksp-interface-lib/src/error.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// file: crates/ksp-interface-lib/src/error.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Error code used when a bounded passive Program instruction contract exceeds one of its Interface-owned admission limits.
|
||||||
|
pub const ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("interface", "program_instruction_limit_exceeded");
|
||||||
30
crates/ksp-interface-lib/src/lib.rs
Normal file
30
crates/ksp-interface-lib/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// file: crates/ksp-interface-lib/src/lib.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Passive wire contracts shared by KSP Program implementations.
|
||||||
|
//!
|
||||||
|
//! The foundation reuses the canonical Solana [`Pubkey`] owned by
|
||||||
|
//! `ksp-core-lib` and exposes only bounded, passive Program-facing structures.
|
||||||
|
//! Runtime, transport, persistence and Program behavior remain outside this
|
||||||
|
//! crate.
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod program_account_meta;
|
||||||
|
mod program_instruction;
|
||||||
|
|
||||||
|
/// Error code used when an Interface-owned Program instruction admission limit is exceeded.
|
||||||
|
pub use self::error::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED;
|
||||||
|
/// Maximum number of account metas admitted by one passive Program instruction contract.
|
||||||
|
pub use self::program_account_meta::MAX_PROGRAM_INSTRUCTION_ACCOUNTS;
|
||||||
|
/// Passive account metadata attached to one Program instruction.
|
||||||
|
pub use self::program_account_meta::ProgramAccountMeta;
|
||||||
|
/// Maximum opaque data payload admitted by one passive Program instruction contract.
|
||||||
|
pub use self::program_instruction::MAX_PROGRAM_INSTRUCTION_DATA_LEN;
|
||||||
|
/// Passive, bounded Program instruction wire contract.
|
||||||
|
pub use self::program_instruction::ProgramInstruction;
|
||||||
|
/// Canonical Solana account address primitive owned by `ksp-core-lib`.
|
||||||
|
pub use ksp_core_lib::Pubkey;
|
||||||
52
crates/ksp-interface-lib/src/program_account_meta.rs
Normal file
52
crates/ksp-interface-lib/src/program_account_meta.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// file: crates/ksp-interface-lib/src/program_account_meta.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Maximum number of account metas admitted by one passive Program instruction contract.
|
||||||
|
pub const MAX_PROGRAM_INSTRUCTION_ACCOUNTS: usize = 255;
|
||||||
|
|
||||||
|
/// Passive account metadata attached to one Program instruction.
|
||||||
|
///
|
||||||
|
/// Account metas preserve the caller-provided Solana account identity and the
|
||||||
|
/// signer/writable flags without applying Program-specific semantics.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ProgramAccountMeta {
|
||||||
|
pubkey: crate::Pubkey,
|
||||||
|
is_signer: bool,
|
||||||
|
is_writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgramAccountMeta {
|
||||||
|
/// Creates one read-only account meta.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn readonly(pubkey: crate::Pubkey, is_signer: bool) -> Self {
|
||||||
|
return Self { pubkey, is_signer, is_writable: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates one writable account meta.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn writable(pubkey: crate::Pubkey, is_signer: bool) -> Self {
|
||||||
|
return Self { pubkey, is_signer, is_writable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this account must sign the containing instruction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_signer(&self) -> bool {
|
||||||
|
return self.is_signer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this account may be written by the containing instruction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_writable(&self) -> bool {
|
||||||
|
return self.is_writable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical account address.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pubkey(&self) -> &crate::Pubkey {
|
||||||
|
return &self.pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/program_account_meta.rs"]
|
||||||
|
mod tests;
|
||||||
81
crates/ksp-interface-lib/src/program_instruction.rs
Normal file
81
crates/ksp-interface-lib/src/program_instruction.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// file: crates/ksp-interface-lib/src/program_instruction.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Maximum opaque data payload admitted by one passive Program instruction contract.
|
||||||
|
pub const MAX_PROGRAM_INSTRUCTION_DATA_LEN: usize = 10 * 1024;
|
||||||
|
|
||||||
|
/// Passive, bounded Program instruction wire contract.
|
||||||
|
///
|
||||||
|
/// The instruction preserves the caller-provided Program identity, ordered
|
||||||
|
/// account metas and opaque data bytes without applying Program-specific
|
||||||
|
/// semantics. Construction enforces only the Interface-owned admission bounds.
|
||||||
|
pub struct ProgramInstruction {
|
||||||
|
program_id: crate::Pubkey,
|
||||||
|
accounts: std::vec::Vec<crate::ProgramAccountMeta>,
|
||||||
|
data: std::vec::Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgramInstruction {
|
||||||
|
/// Creates one passive Program instruction after enforcing Interface admission bounds.
|
||||||
|
///
|
||||||
|
/// The provided vectors are consumed directly. Their account order and duplicates
|
||||||
|
/// are preserved exactly when construction succeeds.
|
||||||
|
pub fn try_new(program_id: crate::Pubkey, accounts: std::vec::Vec<crate::ProgramAccountMeta>, data: std::vec::Vec<u8>) -> ksp_core_lib::Result<Self> {
|
||||||
|
if accounts.len() > crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED,
|
||||||
|
"Program instruction account count exceeds the Interface admission limit",
|
||||||
|
)
|
||||||
|
.with_context("field", "accounts")
|
||||||
|
.with_context("actual_len", accounts.len().to_string())
|
||||||
|
.with_context("maximum_len", crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if data.len() > crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED,
|
||||||
|
"Program instruction data length exceeds the Interface admission limit",
|
||||||
|
)
|
||||||
|
.with_context("field", "data")
|
||||||
|
.with_context("actual_len", data.len().to_string())
|
||||||
|
.with_context("maximum_len", crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { program_id, accounts, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the ordered account metas exactly as admitted at construction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn accounts(&self) -> &[crate::ProgramAccountMeta] {
|
||||||
|
return self.accounts.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque Program instruction data bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
return self.data.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the Program identity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn program_id(&self) -> &crate::Pubkey {
|
||||||
|
return &self.program_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ProgramInstruction {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ProgramInstruction")
|
||||||
|
.field("program_id", &self.program_id)
|
||||||
|
.field("account_count", &self.accounts.len())
|
||||||
|
.field("data_len", &self.data.len())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/program_instruction.rs"]
|
||||||
|
mod tests;
|
||||||
131
crates/ksp-interface-lib/tests/dependency_boundary.rs
Normal file
131
crates/ksp-interface-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// file: crates/ksp-interface-lib/tests/dependency_boundary.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
//! Dependency and passive-surface canaries for the Interface foundation.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_manifest_has_exact_core_only_runtime_dependency() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||||
|
assert!(dependencies_tail.is_some(), "Interface dependencies section must exist");
|
||||||
|
let dependencies_tail = match dependencies_tail {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib"]);
|
||||||
|
for forbidden in [
|
||||||
|
"ksp-config-lib",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"ksp-offchain-transport-lib",
|
||||||
|
"ksp-onchain-transport-lib",
|
||||||
|
"ksp-program-api",
|
||||||
|
"ksp-program-lib",
|
||||||
|
"ksp-store-api",
|
||||||
|
"ksp-store-lib",
|
||||||
|
"ksp-wallet-lib",
|
||||||
|
"borsh",
|
||||||
|
"bincode",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"solana-instruction",
|
||||||
|
"tauri",
|
||||||
|
"tokio",
|
||||||
|
"tonic",
|
||||||
|
"tracing",
|
||||||
|
"wincode",
|
||||||
|
] {
|
||||||
|
assert!(!dependencies.contains(forbidden), "forbidden Interface dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_surface_remains_passive_without_codecs_or_runtime_logging() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
assert!(crate_root.contains("ProgramAccountMeta"));
|
||||||
|
assert!(crate_root.contains("MAX_PROGRAM_INSTRUCTION_ACCOUNTS"));
|
||||||
|
assert!(crate_root.contains("ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED"));
|
||||||
|
assert!(crate_root.contains("ProgramInstruction"));
|
||||||
|
assert!(crate_root.contains("MAX_PROGRAM_INSTRUCTION_DATA_LEN"));
|
||||||
|
assert!(!crate_root.contains("TRACING_TARGET"));
|
||||||
|
assert!(!crate_root.contains("ksp_logging_lib"));
|
||||||
|
assert!(!std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs").exists());
|
||||||
|
let instruction_source = include_str!("../src/program_instruction.rs");
|
||||||
|
for forbidden in ["serde", "borsh", "bincode", "wincode", "solana_instruction", "ksp_logging_lib", "TRACING_TARGET"] {
|
||||||
|
assert!(!instruction_source.contains(forbidden), "forbidden Interface surface detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in section.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 name = match content.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !name.is_empty() {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_all_production_sources_preserve_the_dependency_firewall() {
|
||||||
|
let production_sources = [
|
||||||
|
include_str!("../src/error.rs"),
|
||||||
|
include_str!("../src/lib.rs"),
|
||||||
|
include_str!("../src/program_account_meta.rs"),
|
||||||
|
include_str!("../src/program_instruction.rs"),
|
||||||
|
];
|
||||||
|
for source in production_sources {
|
||||||
|
for forbidden in [
|
||||||
|
"borsh::",
|
||||||
|
"bincode::",
|
||||||
|
"ksp_config_lib::",
|
||||||
|
"ksp_logging_lib::",
|
||||||
|
"ksp_offchain_transport_lib::",
|
||||||
|
"ksp_onchain_transport_lib::",
|
||||||
|
"ksp_program_api::",
|
||||||
|
"ksp_program_lib::",
|
||||||
|
"ksp_store_api::",
|
||||||
|
"ksp_store_lib::",
|
||||||
|
"ksp_wallet_lib::",
|
||||||
|
"reqwest::",
|
||||||
|
"serde::",
|
||||||
|
"serde_json::",
|
||||||
|
"solana_instruction::",
|
||||||
|
"tauri::",
|
||||||
|
"tokio::",
|
||||||
|
"tonic::",
|
||||||
|
concat!("tracing", "::"),
|
||||||
|
"wincode::",
|
||||||
|
] {
|
||||||
|
assert!(!source.contains(forbidden), "forbidden production dependency path detected: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_instruction_source_has_no_narrowing_cast_or_hidden_codec_entry_point() {
|
||||||
|
let instruction_source = include_str!("../src/program_instruction.rs");
|
||||||
|
for forbidden in [" as u8", " as u16", " as u32", " as u64", "serialize", "deserialize", "encode", "decode"] {
|
||||||
|
assert!(!instruction_source.contains(forbidden), "forbidden instruction implementation pattern detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
27
crates/ksp-interface-lib/tests/external_consumer.rs
Normal file
27
crates/ksp-interface-lib/tests/external_consumer.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// file: crates/ksp-interface-lib/tests/external_consumer.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Downstream-style consumer canary for the public Interface facade.
|
||||||
|
|
||||||
|
fn consume_instruction(instruction: ksp_interface_lib::ProgramInstruction) -> (ksp_interface_lib::Pubkey, usize, usize) {
|
||||||
|
return (*instruction.program_id(), instruction.accounts().len(), instruction.data().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_external_consumer_uses_only_the_crate_root_facade() {
|
||||||
|
let program_id = ksp_interface_lib::Pubkey::new_from_array([0xA1_u8; 32]);
|
||||||
|
let account_id = ksp_interface_lib::Pubkey::new_from_array([0xA2_u8; 32]);
|
||||||
|
let readonly = ksp_interface_lib::ProgramAccountMeta::readonly(account_id, true);
|
||||||
|
let writable = ksp_interface_lib::ProgramAccountMeta::writable(account_id, false);
|
||||||
|
let instruction = ksp_interface_lib::ProgramInstruction::try_new(program_id, std::vec![readonly, writable, readonly], std::vec![7_u8, 8, 9]);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(consume_instruction(instruction), (program_id, 3, 3));
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||||
|
assert_eq!(ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.domain(), "interface");
|
||||||
|
return;
|
||||||
|
}
|
||||||
79
crates/ksp-interface-lib/tests/public_api.rs
Normal file
79
crates/ksp-interface-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// file: crates/ksp-interface-lib/tests/public_api.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Integration canaries for the public `ksp-interface-lib` foundation.
|
||||||
|
|
||||||
|
fn consume_pubkey(pubkey: ksp_interface_lib::Pubkey) -> [u8; 32] {
|
||||||
|
return pubkey.to_bytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_002_pubkey_contract_is_available_from_crate_root() {
|
||||||
|
let pubkey = ksp_interface_lib::Pubkey::default();
|
||||||
|
assert_eq!(consume_pubkey(pubkey), [0_u8; 32]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_003_account_meta_and_limit_contracts_are_available_from_crate_root() {
|
||||||
|
let pubkey = ksp_interface_lib::Pubkey::new_from_array([3_u8; 32]);
|
||||||
|
let readonly = ksp_interface_lib::ProgramAccountMeta::readonly(pubkey, true);
|
||||||
|
let writable = ksp_interface_lib::ProgramAccountMeta::writable(pubkey, false);
|
||||||
|
assert_eq!(readonly.pubkey(), &pubkey);
|
||||||
|
assert!(readonly.is_signer());
|
||||||
|
assert!(!readonly.is_writable());
|
||||||
|
assert_eq!(writable.pubkey(), &pubkey);
|
||||||
|
assert!(!writable.is_signer());
|
||||||
|
assert!(writable.is_writable());
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_003_interface_error_code_is_stable_and_core_owned() {
|
||||||
|
let code: ksp_core_lib::ErrorCode = ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED;
|
||||||
|
assert_eq!(code.domain(), "interface");
|
||||||
|
assert_eq!(code.code(), "program_instruction_limit_exceeded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_004_program_instruction_contract_is_available_from_crate_root() {
|
||||||
|
let program_id = ksp_interface_lib::Pubkey::new_from_array([4_u8; 32]);
|
||||||
|
let account = ksp_interface_lib::ProgramAccountMeta::readonly(ksp_interface_lib::Pubkey::new_from_array([5_u8; 32]), true);
|
||||||
|
let instruction = ksp_interface_lib::ProgramInstruction::try_new(program_id, std::vec![account], std::vec![1_u8, 2, 3]);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(instruction.program_id(), &program_id);
|
||||||
|
assert_eq!(instruction.accounts(), &[account]);
|
||||||
|
assert_eq!(instruction.data(), &[1_u8, 2, 3]);
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_005_limit_error_is_observable_without_private_module_access() {
|
||||||
|
let program_id = ksp_interface_lib::Pubkey::new_from_array([0xC1_u8; 32]);
|
||||||
|
let rejected = ksp_interface_lib::ProgramInstruction::try_new(
|
||||||
|
program_id,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec![0xC2_u8; ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN + 1],
|
||||||
|
);
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
let error = match rejected {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED);
|
||||||
|
assert_eq!(error.context().len(), 3);
|
||||||
|
assert_eq!(error.context()[0].key(), "field");
|
||||||
|
assert_eq!(error.context()[0].value(), "data");
|
||||||
|
assert_eq!(error.context()[1].key(), "actual_len");
|
||||||
|
assert_eq!(error.context()[1].value(), "10241");
|
||||||
|
assert_eq!(error.context()[2].key(), "maximum_len");
|
||||||
|
assert_eq!(error.context()[2].value(), "10240");
|
||||||
|
return;
|
||||||
|
}
|
||||||
75
crates/ksp-interface-lib/tests/release_completeness.rs
Normal file
75
crates/ksp-interface-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// file: crates/ksp-interface-lib/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Release-level completeness canaries for the `0.2.13` Interface foundation.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_exact_crate_root_export_inventory_is_stable() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
let mut actual = std::vec::Vec::new();
|
||||||
|
for line in crate_root.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("pub use ") {
|
||||||
|
actual.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actual.sort_unstable();
|
||||||
|
let mut expected = std::vec![
|
||||||
|
"pub use self::error::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED;",
|
||||||
|
"pub use self::program_account_meta::MAX_PROGRAM_INSTRUCTION_ACCOUNTS;",
|
||||||
|
"pub use self::program_account_meta::ProgramAccountMeta;",
|
||||||
|
"pub use self::program_instruction::MAX_PROGRAM_INSTRUCTION_DATA_LEN;",
|
||||||
|
"pub use self::program_instruction::ProgramInstruction;",
|
||||||
|
"pub use ksp_core_lib::Pubkey;",
|
||||||
|
];
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(actual, expected);
|
||||||
|
assert!(!crate_root.contains("pub mod "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_production_module_inventory_contains_no_second_wire_domain() -> std::io::Result<()> {
|
||||||
|
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
let entries = match std::fs::read_dir(source_root) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let file_type = match entry.file_type() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match entry.file_name().into_string() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if name.ends_with(".rs") {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, std::vec!["error.rs", "lib.rs", "program_account_meta.rs", "program_instruction.rs"]);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_foundation_has_one_error_code_and_two_bounded_passive_types() {
|
||||||
|
assert_eq!(ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.domain(), "interface");
|
||||||
|
assert_eq!(ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.code(), "program_instruction_limit_exceeded");
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||||
|
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||||
|
let program_id = ksp_interface_lib::Pubkey::new_from_array([0xB1_u8; 32]);
|
||||||
|
let account = ksp_interface_lib::ProgramAccountMeta::readonly(ksp_interface_lib::Pubkey::new_from_array([0xB2_u8; 32]), false);
|
||||||
|
let instruction = ksp_interface_lib::ProgramInstruction::try_new(program_id, std::vec![account], std::vec![0xB3_u8]);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
return;
|
||||||
|
}
|
||||||
31
crates/ksp-interface-lib/unit_tests/program_account_meta.rs
Normal file
31
crates/ksp-interface-lib/unit_tests/program_account_meta.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// file: crates/ksp-interface-lib/unit_tests/program_account_meta.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readonly_and_writable_constructors_preserve_identity_signer_and_writable_flags() {
|
||||||
|
let readonly_pubkey = crate::Pubkey::new_from_array([7_u8; 32]);
|
||||||
|
let writable_pubkey = crate::Pubkey::new_from_array([9_u8; 32]);
|
||||||
|
let readonly = crate::ProgramAccountMeta::readonly(readonly_pubkey, true);
|
||||||
|
assert_eq!(readonly.pubkey(), &readonly_pubkey);
|
||||||
|
assert!(readonly.is_signer());
|
||||||
|
assert!(!readonly.is_writable());
|
||||||
|
let writable = crate::ProgramAccountMeta::writable(writable_pubkey, false);
|
||||||
|
assert_eq!(writable.pubkey(), &writable_pubkey);
|
||||||
|
assert!(!writable.is_signer());
|
||||||
|
assert!(writable.is_writable());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_meta_accepts_opaque_pubkeys_without_program_registry_validation() {
|
||||||
|
let opaque_pubkey = crate::Pubkey::new_from_array([0xA5_u8; 32]);
|
||||||
|
let meta = crate::ProgramAccountMeta::readonly(opaque_pubkey, false);
|
||||||
|
assert_eq!(meta.pubkey(), &opaque_pubkey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_limit_constant_matches_the_interface_admission_contract() {
|
||||||
|
assert_eq!(crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||||
|
return;
|
||||||
|
}
|
||||||
162
crates/ksp-interface-lib/unit_tests/program_instruction.rs
Normal file
162
crates/ksp-interface-lib/unit_tests/program_instruction.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// file: crates/ksp-interface-lib/unit_tests/program_instruction.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn meta(byte: u8, writable: bool) -> crate::ProgramAccountMeta {
|
||||||
|
let pubkey = crate::Pubkey::new_from_array([byte; 32]);
|
||||||
|
if writable {
|
||||||
|
return crate::ProgramAccountMeta::writable(pubkey, false);
|
||||||
|
}
|
||||||
|
return crate::ProgramAccountMeta::readonly(pubkey, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_accepts_empty_accounts_and_data_with_opaque_program_id() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0xD3_u8; 32]);
|
||||||
|
let instruction = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), std::vec::Vec::new());
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(instruction.program_id(), &program_id);
|
||||||
|
assert!(instruction.accounts().is_empty());
|
||||||
|
assert!(instruction.data().is_empty());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_preserves_account_order_duplicates_and_opaque_data() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0x41_u8; 32]);
|
||||||
|
let first = meta(1, false);
|
||||||
|
let duplicate = meta(2, true);
|
||||||
|
let accounts = std::vec![first, duplicate, first];
|
||||||
|
let data = std::vec![0_u8, 1, 2, 0xFF];
|
||||||
|
let instruction = crate::ProgramInstruction::try_new(program_id, accounts, data.clone());
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(instruction.accounts(), &[first, duplicate, first]);
|
||||||
|
assert_eq!(instruction.data(), data.as_slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_accepts_account_limit_and_rejects_one_above_it() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0x51_u8; 32]);
|
||||||
|
let account = meta(7, false);
|
||||||
|
let accepted = crate::ProgramInstruction::try_new(program_id, std::vec![account; crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS], std::vec::Vec::new());
|
||||||
|
assert!(accepted.is_ok());
|
||||||
|
let rejected = crate::ProgramInstruction::try_new(program_id, std::vec![account; crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS + 1], std::vec::Vec::new());
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
let error = match rejected {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED);
|
||||||
|
assert_eq!(error.message(), "Program instruction account count exceeds the Interface admission limit");
|
||||||
|
assert_eq!(error.context().len(), 3);
|
||||||
|
assert_eq!(error.context()[0].key(), "field");
|
||||||
|
assert_eq!(error.context()[0].value(), "accounts");
|
||||||
|
assert_eq!(error.context()[1].key(), "actual_len");
|
||||||
|
assert_eq!(error.context()[1].value(), "256");
|
||||||
|
assert_eq!(error.context()[2].key(), "maximum_len");
|
||||||
|
assert_eq!(error.context()[2].value(), "255");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_accepts_data_limit_and_rejects_one_byte_above_it() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0x61_u8; 32]);
|
||||||
|
let accepted = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), std::vec![0xA5_u8; crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN]);
|
||||||
|
assert!(accepted.is_ok());
|
||||||
|
let rejected = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), std::vec![0x5A_u8; crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN + 1]);
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
let error = match rejected {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED);
|
||||||
|
assert_eq!(error.message(), "Program instruction data length exceeds the Interface admission limit");
|
||||||
|
assert_eq!(error.context().len(), 3);
|
||||||
|
assert_eq!(error.context()[0].value(), "data");
|
||||||
|
assert_eq!(error.context()[1].value(), "10241");
|
||||||
|
assert_eq!(error.context()[2].value(), "10240");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_debug_is_bounded_and_omits_accounts_and_payload_bytes() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0x71_u8; 32]);
|
||||||
|
let payload = b"PAYLOAD_SENTINEL_NEVER_RENDER".to_vec();
|
||||||
|
let instruction = crate::ProgramInstruction::try_new(program_id, std::vec![meta(8, false), meta(9, true)], payload);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let rendered = std::format!("{instruction:?}");
|
||||||
|
assert!(rendered.contains("ProgramInstruction"));
|
||||||
|
assert!(rendered.contains("account_count: 2"));
|
||||||
|
assert!(rendered.contains("data_len: 29"));
|
||||||
|
assert!(!rendered.contains("PAYLOAD_SENTINEL_NEVER_RENDER"));
|
||||||
|
assert!(!rendered.contains("is_signer"));
|
||||||
|
assert!(!rendered.contains("is_writable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_preserves_owned_vector_allocations_without_internal_reallocation() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0x81_u8; 32]);
|
||||||
|
let accounts = std::vec![meta(10, false), meta(11, true), meta(12, false)];
|
||||||
|
let accounts_ptr = accounts.as_ptr();
|
||||||
|
let data = std::vec![0x10_u8, 0x20, 0x30, 0x40];
|
||||||
|
let data_ptr = data.as_ptr();
|
||||||
|
let instruction = crate::ProgramInstruction::try_new(program_id, accounts, data);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(instruction.accounts().as_ptr(), accounts_ptr);
|
||||||
|
assert_eq!(instruction.data().as_ptr(), data_ptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_limit_errors_do_not_echo_hostile_payload_or_account_material() {
|
||||||
|
let program_id = crate::Pubkey::new_from_array([0x91_u8; 32]);
|
||||||
|
let hostile_marker = b"HOSTILE_INTERFACE_PAYLOAD_SENTINEL";
|
||||||
|
let mut hostile_data = hostile_marker.to_vec();
|
||||||
|
hostile_data.resize(crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN + 1, 0xA5_u8);
|
||||||
|
let data_error = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), hostile_data);
|
||||||
|
assert!(data_error.is_err());
|
||||||
|
let data_error = match data_error {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
let data_display = std::format!("{data_error}");
|
||||||
|
let data_debug = std::format!("{data_error:?}");
|
||||||
|
assert!(!data_display.contains("HOSTILE_INTERFACE_PAYLOAD_SENTINEL"));
|
||||||
|
assert!(!data_debug.contains("HOSTILE_INTERFACE_PAYLOAD_SENTINEL"));
|
||||||
|
for context in data_error.context() {
|
||||||
|
assert!(!context.value().contains("HOSTILE_INTERFACE_PAYLOAD_SENTINEL"));
|
||||||
|
}
|
||||||
|
let hostile_account = meta(0xE1, true);
|
||||||
|
let hostile_account_debug = std::format!("{hostile_account:?}");
|
||||||
|
let account_error =
|
||||||
|
crate::ProgramInstruction::try_new(program_id, std::vec![hostile_account; crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS + 1], std::vec::Vec::new());
|
||||||
|
assert!(account_error.is_err());
|
||||||
|
let account_error = match account_error {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
let account_debug = std::format!("{account_error:?}");
|
||||||
|
assert!(!account_debug.contains(hostile_account_debug.as_str()));
|
||||||
|
assert_eq!(account_error.context().len(), 3);
|
||||||
|
assert_eq!(account_error.context()[0].value(), "accounts");
|
||||||
|
assert_eq!(account_error.context()[1].value(), "256");
|
||||||
|
assert_eq!(account_error.context()[2].value(), "255");
|
||||||
|
return;
|
||||||
|
}
|
||||||
15
crates/ksp-program-api/Cargo.toml
Normal file
15
crates/ksp-program-api/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# file: crates/ksp-program-api/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-program-api"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-interface-lib = { path = "../ksp-interface-lib" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
156
crates/ksp-program-api/README.md
Normal file
156
crates/ksp-program-api/README.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<!-- file: crates/ksp-program-api/README.md -->
|
||||||
|
<!-- version: 4 -->
|
||||||
|
|
||||||
|
# ksp-program-api
|
||||||
|
|
||||||
|
`ksp-program-api` est la façade publique ouverte du domaine Program KSP. Elle porte uniquement les contrats communs nécessaires aux implémentations Program officielles futures comme aux crates externes ; elle ne possède aucun decoder concret, registry runtime, payload canonique DECODE ni logique d'exécution.
|
||||||
|
|
||||||
|
La surface candidate de `0.2.14` est volontairement **instruction-only** et reste indépendante des couches runtime supérieures.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
Le graphe normal est limité à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-program-api
|
||||||
|
├── ksp-core-lib
|
||||||
|
└── ksp-interface-lib
|
||||||
|
└── ksp-core-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Core reste propriétaire de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error
|
||||||
|
ErrorCode
|
||||||
|
ErrorContext
|
||||||
|
Result
|
||||||
|
Pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
Interface reste propriétaire de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProgramAccountMeta
|
||||||
|
ProgramInstruction
|
||||||
|
```
|
||||||
|
|
||||||
|
Program API possède :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProgramInstructionRecognition
|
||||||
|
ProgramInstructionDecodeOutcome<Decoded>
|
||||||
|
ProgramInstructionDecoder
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-program-api` réexporte toute sa surface consommable depuis son crate-root. Aucun module interne n'est public.
|
||||||
|
|
||||||
|
## Surface publique candidate
|
||||||
|
|
||||||
|
L'inventaire crate-root de `0.2.14` contient exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error
|
||||||
|
ErrorCode
|
||||||
|
ErrorContext
|
||||||
|
Result
|
||||||
|
Pubkey
|
||||||
|
ProgramAccountMeta
|
||||||
|
ProgramInstruction
|
||||||
|
ProgramInstructionRecognition
|
||||||
|
ProgramInstructionDecodeOutcome<Decoded>
|
||||||
|
ProgramInstructionDecoder
|
||||||
|
```
|
||||||
|
|
||||||
|
Les canaris de release completeness verrouillent cet inventaire ainsi que les trois fichiers/modules Rust de production de la crate.
|
||||||
|
|
||||||
|
## Recognition
|
||||||
|
|
||||||
|
`ProgramInstructionRecognition` est `#[non_exhaustive]` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
NoMatch l'implémentation ne revendique pas l'instruction
|
||||||
|
ProgramMatch le Program correspond, sans reconnaissance instruction-local exacte
|
||||||
|
ExactMatch l'implémentation affirme un match instruction-local exact
|
||||||
|
```
|
||||||
|
|
||||||
|
La reconnaissance ne porte aucun score, priorité, proof, confidence, discriminator textuel ni inventaire central de Programs. `ExactMatch` reste une affirmation de l'implémentation, pas une preuve indépendante produite par KSP.
|
||||||
|
|
||||||
|
## Decode outcome
|
||||||
|
|
||||||
|
`ProgramInstructionDecodeOutcome<Decoded>` est `#[non_exhaustive]` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Decoded(Decoded) valeur typée possédée par l'implémentation
|
||||||
|
Unsupported instruction reconnue mais non supportée par cette capability
|
||||||
|
```
|
||||||
|
|
||||||
|
Les échecs réels utilisent le `Result` Core. Il n'existe aucune variante parallèle `Failed` ou `Ignored`.
|
||||||
|
|
||||||
|
Le `Debug` de l'outcome n'impose pas `Decoded: Debug` et n'affiche jamais la valeur `Decoded`.
|
||||||
|
|
||||||
|
## Decoder instruction-only
|
||||||
|
|
||||||
|
`ProgramInstructionDecoder` est un trait ouvert `Send + Sync` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
type Decoded
|
||||||
|
program_ids(&self) -> &[Pubkey]
|
||||||
|
recognize(&self, &ProgramInstruction) -> ProgramInstructionRecognition
|
||||||
|
decode(&self, &ProgramInstruction) -> Result<ProgramInstructionDecodeOutcome<Self::Decoded>>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Decoded` ne reçoit aucun bound implicite supplémentaire : une implémentation reste propriétaire de son type de sortie concret. Le trait ne fournit aucun default method et ne promet pas de composition `dyn` hétérogène.
|
||||||
|
|
||||||
|
`program_ids()` expose des `Pubkey` opaques ; une implémentation externe peut prendre en charge un Program ID absent du registry Core. Aucun enum central, `Any`, JSON ou descriptor global n'est nécessaire.
|
||||||
|
|
||||||
|
`program_ids` et `recognize` servent à la sélection explicite. `decode` consomme par référence une `ProgramInstruction` déjà admise/bornée par Interface et déjà sélectionnée pour le decoder.
|
||||||
|
|
||||||
|
## Hardening validé
|
||||||
|
|
||||||
|
La candidate verrouille notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Program Pubkey non enregistré accepté
|
||||||
|
input Interface maximal 255 accounts + 10_240 bytes accepté à la frontière decoder
|
||||||
|
payload hostile aucun echo automatique ajouté par Program API
|
||||||
|
Debug outcome valeur Decoded jamais rendue
|
||||||
|
associated Decoded aucun Debug/Clone/Send/Sync imposé
|
||||||
|
closed-world Program enum absent
|
||||||
|
registry / descriptors / priority absents
|
||||||
|
ProgramExecutionPreparer absent
|
||||||
|
serde / JSON / Any / codecs absents
|
||||||
|
logging / runtime / filesystem / environment / I/O absents
|
||||||
|
```
|
||||||
|
|
||||||
|
Une implémentation tierce reste responsable du contenu des erreurs qu'elle construit explicitement. `ksp-program-api` garantit seulement qu'il n'ajoute aucun canal parallèle ni copie automatique du payload d'entrée.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
La foundation `0.2.14` ne contient pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-program-lib
|
||||||
|
registry runtime
|
||||||
|
identity/version/coverage de decoder
|
||||||
|
payload canonique D3
|
||||||
|
ProgramAccountDecoder
|
||||||
|
ProgramEventDecoder
|
||||||
|
ProgramReturnDataDecoder
|
||||||
|
ProgramExecutionPreparer
|
||||||
|
serde / serde_json
|
||||||
|
borsh / wincode / bincode
|
||||||
|
solana-instruction
|
||||||
|
network / async runtime
|
||||||
|
logging / tracing
|
||||||
|
Wallet / Transport / Store / Materializer / Config / Tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces surfaces restent reportées jusqu'aux vertical slices qui démontreront leurs contrats réels. En particulier, les codecs wire officiels restent possédés par `ksp-interface-lib` et ne sont introduits qu'en présence d'un protocole réel.
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- [Usage public](USAGE.md)
|
||||||
|
- [Plan `0.2.14`](../../docs/plans/021-V0_2_14_PROGRAM_API_PLAN.md)
|
||||||
|
- [Validation `0.2.14`](../../docs/validation/017-V0_2_14_PROGRAM_API.md)
|
||||||
|
- [Architecture Wire + Program](../../docs/architecture/006-WIRE_AND_PROGRAM.md)
|
||||||
166
crates/ksp-program-api/USAGE.md
Normal file
166
crates/ksp-program-api/USAGE.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<!-- file: crates/ksp-program-api/USAGE.md -->
|
||||||
|
<!-- version: 4 -->
|
||||||
|
|
||||||
|
# Usage de ksp-program-api
|
||||||
|
|
||||||
|
Cette page décrit la surface publique candidate de `0.2.14`. Utiliser uniquement les exports du crate-root ; aucun module interne ne fait partie du contrat consommable.
|
||||||
|
|
||||||
|
## Construire un input Program avec la façade
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let program_id = ksp_program_api::Pubkey::new_from_array([1_u8; 32]);
|
||||||
|
let account_id = ksp_program_api::Pubkey::new_from_array([2_u8; 32]);
|
||||||
|
let account = ksp_program_api::ProgramAccountMeta::readonly(account_id, true);
|
||||||
|
|
||||||
|
let instruction = ksp_program_api::ProgramInstruction::try_new(
|
||||||
|
program_id,
|
||||||
|
std::vec![account],
|
||||||
|
std::vec![0x01_u8, 0x02, 0x03],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
```
|
||||||
|
|
||||||
|
`Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` conservent leur ownership Core/Interface. Les bornes `255` account metas et `10_240` bytes de data sont appliquées par Interface avant l'entrée dans le decoder.
|
||||||
|
|
||||||
|
## Implémenter un decoder externe
|
||||||
|
|
||||||
|
Le type décodé reste entièrement possédé par la crate d'implémentation :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct ExternalDecodedInstruction {
|
||||||
|
opcode: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExternalDecoder {
|
||||||
|
program_ids: [ksp_program_api::Pubkey; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_program_api::ProgramInstructionDecoder for ExternalDecoder {
|
||||||
|
type Decoded = ExternalDecodedInstruction;
|
||||||
|
|
||||||
|
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||||
|
return &self.program_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recognize(
|
||||||
|
&self,
|
||||||
|
instruction: &ksp_program_api::ProgramInstruction,
|
||||||
|
) -> ksp_program_api::ProgramInstructionRecognition {
|
||||||
|
if instruction.program_id() != &self.program_ids[0] {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if instruction.data().first() == std::option::Option::Some(&0x2A_u8) {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::ExactMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::ProgramMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(
|
||||||
|
&self,
|
||||||
|
instruction: &ksp_program_api::ProgramInstruction,
|
||||||
|
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||||
|
let opcode = match instruction.data().first() {
|
||||||
|
std::option::Option::Some(value) if *value == 0x2A_u8 => *value,
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Ok(
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return std::result::Result::Ok(
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(
|
||||||
|
ExternalDecodedInstruction { opcode },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `ksp-program-lib`, enum centrale, `Any`, JSON ou codec n'est nécessaire. Le Program ID peut être un `Pubkey` opaque non enregistré par Core.
|
||||||
|
|
||||||
|
## Sélection explicite
|
||||||
|
|
||||||
|
La sélection reste distincte du décodage :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let recognition = ksp_program_api::ProgramInstructionDecoder::recognize(
|
||||||
|
&decoder,
|
||||||
|
&instruction,
|
||||||
|
);
|
||||||
|
|
||||||
|
match recognition {
|
||||||
|
ksp_program_api::ProgramInstructionRecognition::NoMatch => {}
|
||||||
|
ksp_program_api::ProgramInstructionRecognition::ProgramMatch => {}
|
||||||
|
ksp_program_api::ProgramInstructionRecognition::ExactMatch => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
L'enum est `#[non_exhaustive]`. `ExactMatch` exprime l'affirmation du decoder. `decode` n'est pas un substitut à `recognize` : il traite une instruction déjà sélectionnée pour cette implémentation.
|
||||||
|
|
||||||
|
## Outcome et erreur
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(
|
||||||
|
&decoder,
|
||||||
|
&instruction,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => {
|
||||||
|
let _opcode = value.opcode;
|
||||||
|
}
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Une erreur réelle est un `Err(ksp_program_api::Error)`. `Unsupported` n'est pas une deuxième forme d'erreur : il indique qu'une instruction reconnue n'est volontairement pas décodée par cette capability.
|
||||||
|
|
||||||
|
Program API ne recopie automatiquement ni le payload de l'instruction ni les account metas dans l'erreur. Une implémentation externe reste responsable des messages/contextes qu'elle construit explicitement.
|
||||||
|
|
||||||
|
## Output sans bounds implicites
|
||||||
|
|
||||||
|
L'associated type `Decoded` n'impose pas `Debug`, `Clone`, `Send` ou `Sync`. Les supertraits `Send + Sync` s'appliquent au decoder lui-même, pas à la valeur décodée :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct LocalDecoded(std::rc::Rc<std::cell::Cell<u8>>);
|
||||||
|
```
|
||||||
|
|
||||||
|
Un decoder peut utiliser ce type comme `Decoded` tant que son propre état satisfait `Send + Sync`.
|
||||||
|
|
||||||
|
## Debug sûr
|
||||||
|
|
||||||
|
`ProgramInstructionDecodeOutcome<Decoded>` possède un `Debug` volontairement opaque :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct SecretDecoded;
|
||||||
|
|
||||||
|
let outcome = ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(SecretDecoded);
|
||||||
|
assert_eq!(std::format!("{outcome:?}"), "Decoded");
|
||||||
|
```
|
||||||
|
|
||||||
|
`SecretDecoded` n'a pas besoin d'implémenter `Debug` et sa valeur n'est jamais rendue par l'outcome.
|
||||||
|
|
||||||
|
## Ce qui n'est pas simulé côté consumer
|
||||||
|
|
||||||
|
Il n'existe dans `0.2.14` aucun :
|
||||||
|
|
||||||
|
```text
|
||||||
|
registry de decoders
|
||||||
|
composition dyn hétérogène
|
||||||
|
identity/version/coverage descriptor
|
||||||
|
priority/conflict policy
|
||||||
|
payload canonique D3
|
||||||
|
ProgramAccountDecoder / Event / ReturnData
|
||||||
|
ProgramExecutionPreparer
|
||||||
|
execution policy
|
||||||
|
serde / JSON / codec
|
||||||
|
logging / runtime réseau
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces surfaces ne doivent pas être recréées localement comme si elles faisaient déjà partie du contrat commun. Elles attendent les vertical slices qui justifieront leurs invariants réels.
|
||||||
37
crates/ksp-program-api/src/lib.rs
Normal file
37
crates/ksp-program-api/src/lib.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// file: crates/ksp-program-api/src/lib.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Open Program contracts shared by KSP and external Program implementations.
|
||||||
|
//!
|
||||||
|
//! The foundation exposes Core/Interface types plus the minimal instruction
|
||||||
|
//! recognition/decode vocabulary and the instruction decoder trait. Registries,
|
||||||
|
//! codecs, runtime logging and execution preparation remain outside this
|
||||||
|
//! foundation until their ownership is justified.
|
||||||
|
|
||||||
|
mod program_instruction_decode;
|
||||||
|
mod program_instruction_decoder;
|
||||||
|
|
||||||
|
/// Result of a successful Program instruction decode attempt.
|
||||||
|
pub use self::program_instruction_decode::ProgramInstructionDecodeOutcome;
|
||||||
|
/// Recognition strength reported by one Program instruction implementation.
|
||||||
|
pub use self::program_instruction_decode::ProgramInstructionRecognition;
|
||||||
|
/// Open contract implemented by one Program instruction decoder.
|
||||||
|
pub use self::program_instruction_decoder::ProgramInstructionDecoder;
|
||||||
|
/// Common KSP error type used by Program-facing contracts.
|
||||||
|
pub use ksp_core_lib::Error;
|
||||||
|
/// Stable structured code identifying a KSP error category and condition.
|
||||||
|
pub use ksp_core_lib::ErrorCode;
|
||||||
|
/// Structured contextual field attached to a KSP error.
|
||||||
|
pub use ksp_core_lib::ErrorContext;
|
||||||
|
/// Canonical Solana account address primitive owned by `ksp-core-lib`.
|
||||||
|
pub use ksp_core_lib::Pubkey;
|
||||||
|
/// Common KSP result alias using [`Error`].
|
||||||
|
pub use ksp_core_lib::Result;
|
||||||
|
/// Passive account metadata attached to one Program instruction.
|
||||||
|
pub use ksp_interface_lib::ProgramAccountMeta;
|
||||||
|
/// Passive, bounded Program instruction wire contract.
|
||||||
|
pub use ksp_interface_lib::ProgramInstruction;
|
||||||
50
crates/ksp-program-api/src/program_instruction_decode.rs
Normal file
50
crates/ksp-program-api/src/program_instruction_decode.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// file: crates/ksp-program-api/src/program_instruction_decode.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Recognition strength reported by one Program instruction implementation.
|
||||||
|
///
|
||||||
|
/// Recognition is intentionally instruction-local. It does not encode registry
|
||||||
|
/// priority, a persisted proof, a textual discriminator or a global Program
|
||||||
|
/// kind. [`Self::ExactMatch`] is an assertion made by the implementation for
|
||||||
|
/// the current instruction, while [`Self::ProgramMatch`] only establishes the
|
||||||
|
/// Program-level match.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ProgramInstructionRecognition {
|
||||||
|
/// The implementation does not claim the instruction.
|
||||||
|
NoMatch,
|
||||||
|
/// The Program or Program family matches, but the instruction is not proven exact.
|
||||||
|
ProgramMatch,
|
||||||
|
/// The implementation claims an exact instruction-local match.
|
||||||
|
ExactMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful Program instruction decode attempt.
|
||||||
|
///
|
||||||
|
/// Decode failures are represented by the surrounding KSP [`crate::Result`],
|
||||||
|
/// not by a parallel failure variant. `Unsupported` is reserved for a known
|
||||||
|
/// Program instruction that the implementation deliberately does not decode.
|
||||||
|
///
|
||||||
|
/// The custom [`std::fmt::Debug`] implementation never formats the `Decoded`
|
||||||
|
/// value, so external decoded payloads are not exposed accidentally through
|
||||||
|
/// generic diagnostics.
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ProgramInstructionDecodeOutcome<Decoded> {
|
||||||
|
/// The instruction was decoded into the implementation-owned output type.
|
||||||
|
Decoded(Decoded),
|
||||||
|
/// The instruction is known but unsupported by this decode capability.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Decoded> std::fmt::Debug for ProgramInstructionDecodeOutcome<Decoded> {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Decoded(_) => return formatter.write_str("Decoded"),
|
||||||
|
Self::Unsupported => return formatter.write_str("Unsupported"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/program_instruction_decode.rs"]
|
||||||
|
mod tests;
|
||||||
28
crates/ksp-program-api/src/program_instruction_decoder.rs
Normal file
28
crates/ksp-program-api/src/program_instruction_decoder.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// file: crates/ksp-program-api/src/program_instruction_decoder.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Open contract implemented by one Program instruction decoder.
|
||||||
|
///
|
||||||
|
/// The decoder owns its concrete [`Self::Decoded`] type. No central Program
|
||||||
|
/// enum, erased `Any` payload or serialization contract is required. Program
|
||||||
|
/// identifiers remain opaque [`crate::Pubkey`] values and do not need to be
|
||||||
|
/// registered by Core.
|
||||||
|
///
|
||||||
|
/// Candidate selection is explicit: callers use [`Self::program_ids`] and
|
||||||
|
/// [`Self::recognize`] before invoking [`Self::decode`]. `decode` therefore
|
||||||
|
/// reports only a successful typed value, an intentional unsupported state, or
|
||||||
|
/// a KSP [`crate::Result`] error. The trait defines no default methods and does
|
||||||
|
/// not promise heterogeneous runtime object composition.
|
||||||
|
pub trait ProgramInstructionDecoder: Send + Sync {
|
||||||
|
/// Concrete decoded instruction type owned by the implementation.
|
||||||
|
type Decoded;
|
||||||
|
|
||||||
|
/// Returns the opaque Program identifiers claimed by this decoder.
|
||||||
|
fn program_ids(&self) -> &[crate::Pubkey];
|
||||||
|
|
||||||
|
/// Reports how strongly this decoder recognizes one bounded instruction.
|
||||||
|
fn recognize(&self, instruction: &crate::ProgramInstruction) -> crate::ProgramInstructionRecognition;
|
||||||
|
|
||||||
|
/// Decodes one instruction already selected for this decoder.
|
||||||
|
fn decode(&self, instruction: &crate::ProgramInstruction) -> crate::Result<crate::ProgramInstructionDecodeOutcome<Self::Decoded>>;
|
||||||
|
}
|
||||||
105
crates/ksp-program-api/tests/dependency_boundary.rs
Normal file
105
crates/ksp-program-api/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// file: crates/ksp-program-api/tests/dependency_boundary.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Dependency and declarative-surface canaries for the Program API foundation.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_002_manifest_has_exact_core_and_interface_runtime_dependencies() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||||
|
assert!(dependencies_tail.is_some(), "Program API dependencies section must exist");
|
||||||
|
let dependencies_tail = match dependencies_tail {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib", "ksp-interface-lib"]);
|
||||||
|
for forbidden in [
|
||||||
|
"ksp-config-lib",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"ksp-materializer-api",
|
||||||
|
"ksp-materializer-lib",
|
||||||
|
"ksp-offchain-transport-lib",
|
||||||
|
"ksp-onchain-transport-lib",
|
||||||
|
"ksp-program-lib",
|
||||||
|
"ksp-store-api",
|
||||||
|
"ksp-store-lib",
|
||||||
|
"ksp-wallet-lib",
|
||||||
|
"borsh",
|
||||||
|
"bincode",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"solana-instruction",
|
||||||
|
"tauri",
|
||||||
|
"tokio",
|
||||||
|
"tonic",
|
||||||
|
"tracing",
|
||||||
|
"wincode",
|
||||||
|
] {
|
||||||
|
assert!(!dependencies.contains(forbidden), "forbidden Program API dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_crate_root_adds_decoder_without_runtime_surface() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
for required in [
|
||||||
|
"ProgramInstructionDecodeOutcome",
|
||||||
|
"ProgramInstructionDecoder",
|
||||||
|
"ProgramInstructionRecognition",
|
||||||
|
"Error",
|
||||||
|
"ErrorCode",
|
||||||
|
"ErrorContext",
|
||||||
|
"Pubkey",
|
||||||
|
"Result",
|
||||||
|
"ProgramAccountMeta",
|
||||||
|
"ProgramInstruction",
|
||||||
|
] {
|
||||||
|
assert!(crate_root.contains(required), "required Program API facade export missing: {required}");
|
||||||
|
}
|
||||||
|
for forbidden in ["pub mod ", "ProgramExecutionPreparer", "TRACING_TARGET", "ksp_logging_lib", "serde", "Any"] {
|
||||||
|
assert!(!crate_root.contains(forbidden), "forbidden pre.004 Program API surface detected: {forbidden}");
|
||||||
|
}
|
||||||
|
let decoder_source = include_str!("../src/program_instruction_decoder.rs");
|
||||||
|
assert!(decoder_source.contains("pub trait ProgramInstructionDecoder: Send + Sync"));
|
||||||
|
assert!(decoder_source.contains("type Decoded;"));
|
||||||
|
assert!(decoder_source.contains("fn program_ids(&self) -> &[crate::Pubkey];"));
|
||||||
|
assert!(decoder_source.contains("fn recognize(&self, instruction: &crate::ProgramInstruction)"));
|
||||||
|
assert!(decoder_source.contains("crate::Result<crate::ProgramInstructionDecodeOutcome<Self::Decoded>>"));
|
||||||
|
assert!(!decoder_source.contains("ProgramExecutionPreparer"));
|
||||||
|
assert!(!decoder_source.contains("serde"));
|
||||||
|
assert!(!decoder_source.contains("std::any::Any"));
|
||||||
|
let outcome_source = include_str!("../src/program_instruction_decode.rs");
|
||||||
|
for forbidden in ["Ignored", "Failed", "serde", "Any"] {
|
||||||
|
assert!(!outcome_source.contains(forbidden), "forbidden recognition/outcome concept detected: {forbidden}");
|
||||||
|
}
|
||||||
|
assert!(!std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs").exists());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in section.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 name = match content.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !name.is_empty() {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
115
crates/ksp-program-api/tests/external_implementation.rs
Normal file
115
crates/ksp-program-api/tests/external_implementation.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// file: crates/ksp-program-api/tests/external_implementation.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Downstream-style implementation canary for the open Program decoder contract.
|
||||||
|
|
||||||
|
const EXTERNAL_PROGRAM_ID_BYTES: [u8; 32] = [0xE7_u8; 32];
|
||||||
|
const FOREIGN_PROGRAM_ID_BYTES: [u8; 32] = [0xE8_u8; 32];
|
||||||
|
const SUPPORTED_OPCODE: u8 = 0x2A_u8;
|
||||||
|
|
||||||
|
struct ExternalDecodedInstruction {
|
||||||
|
opcode: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExternalProgramDecoder {
|
||||||
|
program_ids: [ksp_program_api::Pubkey; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalProgramDecoder {
|
||||||
|
fn new() -> Self {
|
||||||
|
return Self { program_ids: [ksp_program_api::Pubkey::new_from_array(EXTERNAL_PROGRAM_ID_BYTES)] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_program_api::ProgramInstructionDecoder for ExternalProgramDecoder {
|
||||||
|
type Decoded = ExternalDecodedInstruction;
|
||||||
|
|
||||||
|
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||||
|
return &self.program_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recognize(&self, instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||||
|
if instruction.program_id() != &self.program_ids[0] {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||||
|
}
|
||||||
|
if instruction.data().first() == std::option::Option::Some(&SUPPORTED_OPCODE) {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::ExactMatch;
|
||||||
|
}
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::ProgramMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(
|
||||||
|
&self,
|
||||||
|
instruction: &ksp_program_api::ProgramInstruction,
|
||||||
|
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||||
|
if instruction.program_id() != &self.program_ids[0] {
|
||||||
|
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported);
|
||||||
|
}
|
||||||
|
let opcode = match instruction.data().first() {
|
||||||
|
std::option::Option::Some(value) if *value == SUPPORTED_OPCODE => *value,
|
||||||
|
_ => return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(ExternalDecodedInstruction { opcode }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_send_sync<T: Send + Sync>(_value: &T) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_external_decoder_uses_unregistered_pubkey_and_implementation_owned_output() {
|
||||||
|
let decoder = ExternalProgramDecoder::new();
|
||||||
|
assert_send_sync(&decoder);
|
||||||
|
let external_program_id = ksp_program_api::Pubkey::new_from_array(EXTERNAL_PROGRAM_ID_BYTES);
|
||||||
|
assert!(ksp_core_lib::find_program_pubkey(&external_program_id).is_none());
|
||||||
|
assert_eq!(ksp_program_api::ProgramInstructionDecoder::program_ids(&decoder), &[external_program_id]);
|
||||||
|
let exact = ksp_program_api::ProgramInstruction::try_new(external_program_id, std::vec![], std::vec![SUPPORTED_OPCODE]);
|
||||||
|
assert!(exact.is_ok());
|
||||||
|
let exact = match exact {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(ksp_program_api::ProgramInstructionDecoder::recognize(&decoder, &exact), ksp_program_api::ProgramInstructionRecognition::ExactMatch);
|
||||||
|
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &exact);
|
||||||
|
assert!(outcome.is_ok());
|
||||||
|
let outcome = match outcome {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let decoded = match outcome {
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
assert_eq!(decoded.opcode, SUPPORTED_OPCODE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_004_external_decoder_distinguishes_program_match_unsupported_and_no_match() {
|
||||||
|
let decoder = ExternalProgramDecoder::new();
|
||||||
|
let external_program_id = ksp_program_api::Pubkey::new_from_array(EXTERNAL_PROGRAM_ID_BYTES);
|
||||||
|
let program_only = ksp_program_api::ProgramInstruction::try_new(external_program_id, std::vec![], std::vec![0x11_u8]);
|
||||||
|
assert!(program_only.is_ok());
|
||||||
|
let program_only = match program_only {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(ksp_program_api::ProgramInstructionDecoder::recognize(&decoder, &program_only), ksp_program_api::ProgramInstructionRecognition::ProgramMatch);
|
||||||
|
let unsupported = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &program_only);
|
||||||
|
assert!(unsupported.is_ok());
|
||||||
|
let unsupported = match unsupported {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(matches!(unsupported, ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported));
|
||||||
|
let foreign_program_id = ksp_program_api::Pubkey::new_from_array(FOREIGN_PROGRAM_ID_BYTES);
|
||||||
|
let foreign = ksp_program_api::ProgramInstruction::try_new(foreign_program_id, std::vec![], std::vec![SUPPORTED_OPCODE]);
|
||||||
|
assert!(foreign.is_ok());
|
||||||
|
let foreign = match foreign {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(ksp_program_api::ProgramInstructionDecoder::recognize(&decoder, &foreign), ksp_program_api::ProgramInstructionRecognition::NoMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
77
crates/ksp-program-api/tests/public_api.rs
Normal file
77
crates/ksp-program-api/tests/public_api.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// file: crates/ksp-program-api/tests/public_api.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Integration canaries for the public `ksp-program-api` foundation.
|
||||||
|
|
||||||
|
fn consume_result(value: ksp_program_api::Result<ksp_program_api::Pubkey>) -> ksp_program_api::Result<ksp_program_api::Pubkey> {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_002_core_and_interface_facade_is_available_from_crate_root() {
|
||||||
|
let program_id = ksp_program_api::Pubkey::new_from_array([0xA1_u8; 32]);
|
||||||
|
let account_id = ksp_program_api::Pubkey::new_from_array([0xA2_u8; 32]);
|
||||||
|
let account = ksp_program_api::ProgramAccountMeta::readonly(account_id, true);
|
||||||
|
let instruction = ksp_program_api::ProgramInstruction::try_new(program_id, std::vec![account], std::vec![0xA3_u8]);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let forwarded = consume_result(std::result::Result::Ok(program_id));
|
||||||
|
assert!(forwarded.is_ok());
|
||||||
|
let error_code_type: std::option::Option<ksp_program_api::ErrorCode> = std::option::Option::None;
|
||||||
|
let error_context_type: std::option::Option<ksp_program_api::ErrorContext> = std::option::Option::None;
|
||||||
|
let error_type: std::option::Option<ksp_program_api::Error> = std::option::Option::None;
|
||||||
|
assert!(error_code_type.is_none());
|
||||||
|
assert!(error_context_type.is_none());
|
||||||
|
assert!(error_type.is_none());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_002_scaffold_does_not_require_private_modules() {
|
||||||
|
let source = include_str!("../src/lib.rs");
|
||||||
|
assert!(!source.contains("pub mod "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_003_recognition_and_decode_outcome_are_available_from_crate_root() {
|
||||||
|
let recognition = ksp_program_api::ProgramInstructionRecognition::ProgramMatch;
|
||||||
|
assert_eq!(std::format!("{recognition:?}"), "ProgramMatch");
|
||||||
|
let decoded = ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(31_u16);
|
||||||
|
assert_eq!(std::format!("{decoded:?}"), "Decoded");
|
||||||
|
let decoded_value = match decoded {
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||||
|
_ => 0_u16,
|
||||||
|
};
|
||||||
|
assert_eq!(decoded_value, 31_u16);
|
||||||
|
let unsupported = ksp_program_api::ProgramInstructionDecodeOutcome::<u16>::Unsupported;
|
||||||
|
assert_eq!(std::format!("{unsupported:?}"), "Unsupported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_004_instruction_decoder_trait_is_available_from_crate_root() {
|
||||||
|
let decoder = NeverInstantiatedDecoder;
|
||||||
|
assert!(ksp_program_api::ProgramInstructionDecoder::program_ids(&decoder).is_empty());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NeverInstantiatedDecoder;
|
||||||
|
|
||||||
|
impl ksp_program_api::ProgramInstructionDecoder for NeverInstantiatedDecoder {
|
||||||
|
type Decoded = u8;
|
||||||
|
|
||||||
|
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||||
|
return &[];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recognize(&self, _instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(
|
||||||
|
&self,
|
||||||
|
_instruction: &ksp_program_api::ProgramInstruction,
|
||||||
|
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||||
|
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
crates/ksp-program-api/tests/release_completeness.rs
Normal file
128
crates/ksp-program-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// file: crates/ksp-program-api/tests/release_completeness.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Release-level completeness canaries for the `0.2.14` Program API foundation.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_exact_crate_root_export_inventory_is_stable() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
let mut actual = std::vec::Vec::new();
|
||||||
|
for line in crate_root.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("pub use ") {
|
||||||
|
actual.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actual.sort_unstable();
|
||||||
|
let mut expected = std::vec![
|
||||||
|
"pub use self::program_instruction_decode::ProgramInstructionDecodeOutcome;",
|
||||||
|
"pub use self::program_instruction_decode::ProgramInstructionRecognition;",
|
||||||
|
"pub use self::program_instruction_decoder::ProgramInstructionDecoder;",
|
||||||
|
"pub use ksp_core_lib::Error;",
|
||||||
|
"pub use ksp_core_lib::ErrorCode;",
|
||||||
|
"pub use ksp_core_lib::ErrorContext;",
|
||||||
|
"pub use ksp_core_lib::Pubkey;",
|
||||||
|
"pub use ksp_core_lib::Result;",
|
||||||
|
"pub use ksp_interface_lib::ProgramAccountMeta;",
|
||||||
|
"pub use ksp_interface_lib::ProgramInstruction;",
|
||||||
|
];
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(actual, expected);
|
||||||
|
assert!(!crate_root.contains("pub mod "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_production_module_inventory_is_instruction_only() -> std::io::Result<()> {
|
||||||
|
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
let entries = match std::fs::read_dir(source_root) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let file_type = match entry.file_type() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match entry.file_name().into_string() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if name.ends_with(".rs") {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, std::vec!["lib.rs", "program_instruction_decode.rs", "program_instruction_decoder.rs"]);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_public_enum_and_trait_inventory_remains_open_world() {
|
||||||
|
let sources = [include_str!("../src/lib.rs"), include_str!("../src/program_instruction_decode.rs"), include_str!("../src/program_instruction_decoder.rs")];
|
||||||
|
let mut public_enums = std::vec::Vec::new();
|
||||||
|
let mut public_traits = std::vec::Vec::new();
|
||||||
|
for source in sources {
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("pub enum ") {
|
||||||
|
public_enums.push(trimmed);
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("pub trait ") {
|
||||||
|
public_traits.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public_enums.sort_unstable();
|
||||||
|
public_traits.sort_unstable();
|
||||||
|
assert_eq!(public_enums, std::vec!["pub enum ProgramInstructionDecodeOutcome<Decoded> {", "pub enum ProgramInstructionRecognition {"]);
|
||||||
|
assert_eq!(public_traits, std::vec!["pub trait ProgramInstructionDecoder: Send + Sync {"]);
|
||||||
|
let decode_source = include_str!("../src/program_instruction_decode.rs");
|
||||||
|
assert!(decode_source.contains("#[non_exhaustive]\npub enum ProgramInstructionRecognition"));
|
||||||
|
assert!(decode_source.contains("#[non_exhaustive]\npub enum ProgramInstructionDecodeOutcome<Decoded>"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_production_sources_have_no_registry_preparer_codec_or_runtime_creep() {
|
||||||
|
let direct_tracing_path = ["tracing", "::"].concat();
|
||||||
|
let sources = [include_str!("../src/lib.rs"), include_str!("../src/program_instruction_decode.rs"), include_str!("../src/program_instruction_decoder.rs")];
|
||||||
|
for source in sources {
|
||||||
|
for forbidden in [
|
||||||
|
"pub enum ProgramKind",
|
||||||
|
"pub struct ProgramRegistry",
|
||||||
|
"pub trait ProgramAccountDecoder",
|
||||||
|
"pub trait ProgramEventDecoder",
|
||||||
|
"pub trait ProgramReturnDataDecoder",
|
||||||
|
"pub trait ProgramExecutionPreparer",
|
||||||
|
"std::any::Any",
|
||||||
|
"serde::",
|
||||||
|
"serde_json::",
|
||||||
|
"borsh::",
|
||||||
|
"bincode::",
|
||||||
|
"wincode::",
|
||||||
|
"ksp_logging_lib::",
|
||||||
|
"reqwest::",
|
||||||
|
"tokio::",
|
||||||
|
"tonic::",
|
||||||
|
"tauri::",
|
||||||
|
"std::env::",
|
||||||
|
"std::fs::",
|
||||||
|
"std::net::",
|
||||||
|
"dyn ProgramInstructionDecoder",
|
||||||
|
"dyn crate::ProgramInstructionDecoder",
|
||||||
|
] {
|
||||||
|
assert!(!source.contains(forbidden), "forbidden Program API production surface detected: {forbidden}");
|
||||||
|
}
|
||||||
|
assert!(!source.contains(direct_tracing_path.as_str()), "forbidden Program API production direct tracing path detected");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
156
crates/ksp-program-api/tests/security_hardening.rs
Normal file
156
crates/ksp-program-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// file: crates/ksp-program-api/tests/security_hardening.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Adversarial and bound-safety canaries for the Program API foundation.
|
||||||
|
|
||||||
|
const HOSTILE_MARKER: &str = "PROGRAM-SECRET-CANARY";
|
||||||
|
const MALFORMED_OPCODE: u8 = 0xFF_u8;
|
||||||
|
const PROGRAM_ID_BYTES: [u8; 32] = [0xD1_u8; 32];
|
||||||
|
|
||||||
|
struct BoundsObserved {
|
||||||
|
account_count: usize,
|
||||||
|
data_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BoundedDecoder {
|
||||||
|
program_ids: [ksp_program_api::Pubkey; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundedDecoder {
|
||||||
|
fn new() -> Self {
|
||||||
|
return Self { program_ids: [ksp_program_api::Pubkey::new_from_array(PROGRAM_ID_BYTES)] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_program_api::ProgramInstructionDecoder for BoundedDecoder {
|
||||||
|
type Decoded = BoundsObserved;
|
||||||
|
|
||||||
|
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||||
|
return &self.program_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recognize(&self, instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||||
|
if instruction.program_id() == &self.program_ids[0] {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::ExactMatch;
|
||||||
|
}
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(
|
||||||
|
&self,
|
||||||
|
instruction: &ksp_program_api::ProgramInstruction,
|
||||||
|
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||||
|
if instruction.data().first() == std::option::Option::Some(&MALFORMED_OPCODE) {
|
||||||
|
return std::result::Result::Err(ksp_program_api::Error::new(
|
||||||
|
ksp_program_api::ErrorCode::new("program_test", "malformed_instruction"),
|
||||||
|
"malformed external Program instruction",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(BoundsObserved {
|
||||||
|
account_count: instruction.accounts().len(),
|
||||||
|
data_len: instruction.data().len(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BoundlessDecoded {
|
||||||
|
marker: std::rc::Rc<std::cell::Cell<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BoundlessOutputDecoder;
|
||||||
|
|
||||||
|
impl ksp_program_api::ProgramInstructionDecoder for BoundlessOutputDecoder {
|
||||||
|
type Decoded = BoundlessDecoded;
|
||||||
|
|
||||||
|
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||||
|
return &[];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recognize(&self, _instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||||
|
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(
|
||||||
|
&self,
|
||||||
|
_instruction: &ksp_program_api::ProgramInstruction,
|
||||||
|
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||||
|
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(BoundlessDecoded {
|
||||||
|
marker: std::rc::Rc::new(std::cell::Cell::new(0x5A_u8)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_max_interface_instruction_crosses_decoder_boundary_without_new_contract() {
|
||||||
|
let decoder = BoundedDecoder::new();
|
||||||
|
let account = ksp_program_api::ProgramAccountMeta::readonly(ksp_program_api::Pubkey::new_from_array([0xD2_u8; 32]), false);
|
||||||
|
let accounts = std::vec![account; ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS];
|
||||||
|
let data = std::vec![0x5A_u8; ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN];
|
||||||
|
let instruction = ksp_program_api::ProgramInstruction::try_new(decoder.program_ids[0], accounts, data);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &instruction);
|
||||||
|
assert!(outcome.is_ok());
|
||||||
|
let outcome = match outcome {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let observed = match outcome {
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
assert_eq!(observed.account_count, ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS);
|
||||||
|
assert_eq!(observed.data_len, ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_malformed_payload_error_path_does_not_gain_automatic_payload_echo() {
|
||||||
|
let decoder = BoundedDecoder::new();
|
||||||
|
let mut payload = std::vec![MALFORMED_OPCODE];
|
||||||
|
payload.extend_from_slice(HOSTILE_MARKER.as_bytes());
|
||||||
|
let instruction = ksp_program_api::ProgramInstruction::try_new(decoder.program_ids[0], std::vec![], payload);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &instruction);
|
||||||
|
assert!(outcome.is_err());
|
||||||
|
let error = match outcome {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code().domain(), "program_test");
|
||||||
|
assert_eq!(error.code().code(), "malformed_instruction");
|
||||||
|
assert!(!std::format!("{error}").contains(HOSTILE_MARKER));
|
||||||
|
assert!(!std::format!("{error:?}").contains(HOSTILE_MARKER));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_005_associated_decoded_type_keeps_no_implicit_debug_clone_send_or_sync_bound() {
|
||||||
|
let decoder = BoundlessOutputDecoder;
|
||||||
|
let instruction = ksp_program_api::ProgramInstruction::try_new(ksp_program_api::Pubkey::new_from_array([0xD3_u8; 32]), std::vec![], std::vec![]);
|
||||||
|
assert!(instruction.is_ok());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &instruction);
|
||||||
|
assert!(outcome.is_ok());
|
||||||
|
let outcome = match outcome {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(std::format!("{outcome:?}"), "Decoded");
|
||||||
|
let decoded = match outcome {
|
||||||
|
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
assert_eq!(decoded.marker.get(), 0x5A_u8);
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// file: crates/ksp-program-api/unit_tests/program_instruction_decode.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognition_variants_are_distinct_and_payload_free() {
|
||||||
|
assert_ne!(crate::ProgramInstructionRecognition::NoMatch, crate::ProgramInstructionRecognition::ProgramMatch);
|
||||||
|
assert_ne!(crate::ProgramInstructionRecognition::ProgramMatch, crate::ProgramInstructionRecognition::ExactMatch);
|
||||||
|
assert_eq!(std::format!("{:?}", crate::ProgramInstructionRecognition::NoMatch), "NoMatch");
|
||||||
|
assert_eq!(std::format!("{:?}", crate::ProgramInstructionRecognition::ProgramMatch), "ProgramMatch");
|
||||||
|
assert_eq!(std::format!("{:?}", crate::ProgramInstructionRecognition::ExactMatch), "ExactMatch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_outcome_preserves_decoded_value_and_unsupported_state() {
|
||||||
|
let decoded = crate::ProgramInstructionDecodeOutcome::Decoded(17_u64);
|
||||||
|
let decoded_value = match decoded {
|
||||||
|
crate::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||||
|
crate::ProgramInstructionDecodeOutcome::Unsupported => 0_u64,
|
||||||
|
};
|
||||||
|
assert_eq!(decoded_value, 17_u64);
|
||||||
|
let unsupported = crate::ProgramInstructionDecodeOutcome::<u64>::Unsupported;
|
||||||
|
assert!(matches!(unsupported, crate::ProgramInstructionDecodeOutcome::Unsupported));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_outcome_debug_never_requires_or_renders_decoded_debug() {
|
||||||
|
struct ExternalDecoded {
|
||||||
|
secret_marker: u8,
|
||||||
|
}
|
||||||
|
let decoded = crate::ProgramInstructionDecodeOutcome::Decoded(ExternalDecoded { secret_marker: 0xA7_u8 });
|
||||||
|
assert_eq!(std::format!("{decoded:?}"), "Decoded");
|
||||||
|
let secret_marker = match decoded {
|
||||||
|
crate::ProgramInstructionDecodeOutcome::Decoded(value) => value.secret_marker,
|
||||||
|
crate::ProgramInstructionDecodeOutcome::Unsupported => 0_u8,
|
||||||
|
};
|
||||||
|
assert_eq!(secret_marker, 0xA7_u8);
|
||||||
|
assert_eq!(std::format!("{:?}", crate::ProgramInstructionDecodeOutcome::<ExternalDecoded>::Unsupported), "Unsupported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
14
crates/ksp-store-api/Cargo.toml
Normal file
14
crates/ksp-store-api/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# file: crates/ksp-store-api/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-store-api"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
19
crates/ksp-store-api/src/capability.rs
Normal file
19
crates/ksp-store-api/src/capability.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// file: crates/ksp-store-api/src/capability.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Private home for backend-agnostic Store capability contracts.
|
||||||
|
//!
|
||||||
|
//! Capabilities are split by persistent family and operation direction so a
|
||||||
|
//! backend can implement only the contracts it actually supports. The runtime
|
||||||
|
//! Store facade, backend selection and concrete database implementations remain
|
||||||
|
//! outside `ksp-store-api`.
|
||||||
|
|
||||||
|
pub(crate) mod raw_account;
|
||||||
|
pub(crate) mod raw_retention;
|
||||||
|
pub(crate) mod raw_transaction;
|
||||||
|
|
||||||
|
/// Boxed async operation returned by object-safe Store capability contracts.
|
||||||
|
///
|
||||||
|
/// The alias uses only standard-library primitives so backend implementations
|
||||||
|
/// need no async helper dependency merely to implement `ksp-store-api`.
|
||||||
|
pub type StoreApiFuture<'a, T> = std::pin::Pin<std::boxed::Box<dyn std::future::Future<Output = T> + std::marker::Send + 'a>>;
|
||||||
61
crates/ksp-store-api/src/capability/raw_account.rs
Normal file
61
crates/ksp-store-api/src/capability/raw_account.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// file: crates/ksp-store-api/src/capability/raw_account.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// Read capability for complete canonical RAW account states.
|
||||||
|
///
|
||||||
|
/// Implementations must return the common Store model without leaking backend
|
||||||
|
/// rows, SQL handles or acquisition transport types. Absence is represented by
|
||||||
|
/// `None`; backend/runtime failures use the common KSP error contract.
|
||||||
|
pub trait RawAccountStateRead: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Reads one complete canonical RAW account state by durable reference.
|
||||||
|
fn get_raw_account_state<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a crate::RawAccountStateReference,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawAccountState>>>;
|
||||||
|
|
||||||
|
/// Lists deterministic account-state references using an opaque backend cursor.
|
||||||
|
///
|
||||||
|
/// The backend must not invent a KSP policy ceiling below the caller's
|
||||||
|
/// request. A real backend/physical limitation may determine the page that
|
||||||
|
/// can be served and continuation is represented by the opaque cursor.
|
||||||
|
fn list_raw_account_states<'a>(
|
||||||
|
&'a self,
|
||||||
|
query: &'a crate::RawAccountStateQuery,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawPage<crate::RawAccountStateReference>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write capability for complete RAW account-state acquisitions.
|
||||||
|
///
|
||||||
|
/// The account state and its acquisition observation form one logical
|
||||||
|
/// persistence operation. An implementation must not leave one side durable if
|
||||||
|
/// the other side fails. Divergent content for one logical identity is a stable
|
||||||
|
/// conflict error, never a silent overwrite.
|
||||||
|
pub trait RawAccountStateWrite: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Persists one complete RAW account state together with one observation atomically.
|
||||||
|
fn persist_raw_account_acquisition<'a>(
|
||||||
|
&'a self,
|
||||||
|
state: crate::RawAccountState,
|
||||||
|
observation: crate::RawAccountObservation,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawAcquisitionWriteOutcome>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read capability for persisted RAW account-state observations.
|
||||||
|
pub trait RawAccountObservationRead: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Reads one account observation by deterministic producer-owned idempotence key.
|
||||||
|
fn get_raw_account_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation_key: &'a crate::RawObservationKey,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawAccountObservation>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write capability for an additional observation of an already persisted RAW account state.
|
||||||
|
///
|
||||||
|
/// This capability allows repeated HTTP/WS/gRPC acquisitions to be retained
|
||||||
|
/// without resubmitting account bytes. The referenced state must already exist.
|
||||||
|
pub trait RawAccountObservationWrite: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Persists one additional acquisition observation for an existing RAW account state.
|
||||||
|
fn record_raw_account_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation: crate::RawAccountObservation,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawObservationWriteOutcome>>;
|
||||||
|
}
|
||||||
33
crates/ksp-store-api/src/capability/raw_retention.rs
Normal file
33
crates/ksp-store-api/src/capability/raw_retention.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// file: crates/ksp-store-api/src/capability/raw_retention.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// Read capability for canonical RAW transaction retention metadata.
|
||||||
|
pub trait RawTransactionRetentionRead: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Reads the logical retention state for one known transaction identity.
|
||||||
|
fn get_raw_transaction_retention_state<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a crate::RawTransactionReference,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawRetentionState>>>;
|
||||||
|
|
||||||
|
/// Reads the minimal durable tombstone when the transaction payload was purged.
|
||||||
|
fn get_raw_transaction_tombstone<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a crate::RawTransactionReference,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawTransactionTombstone>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write capability for policy-authorized RAW transaction retention transitions.
|
||||||
|
///
|
||||||
|
/// This contract applies a transition selected by an external worker/job policy;
|
||||||
|
/// the Store backend never decides by itself that higher processing is complete.
|
||||||
|
/// Implementations must compare the persisted state with `transition.expected()`
|
||||||
|
/// atomically with the update. A concurrent state change must return
|
||||||
|
/// [`crate::RawRetentionWriteOutcome::ExpectedStateMismatch`] unless the entity
|
||||||
|
/// is already at the requested target.
|
||||||
|
pub trait RawTransactionRetentionWrite: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Applies one atomic forward lifecycle transition chosen by the caller.
|
||||||
|
fn transition_raw_transaction_retention<'a>(
|
||||||
|
&'a self,
|
||||||
|
transition: crate::RawTransactionRetentionTransition,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawRetentionWriteOutcome>>;
|
||||||
|
}
|
||||||
63
crates/ksp-store-api/src/capability/raw_transaction.rs
Normal file
63
crates/ksp-store-api/src/capability/raw_transaction.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// file: crates/ksp-store-api/src/capability/raw_transaction.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// Read capability for canonical RAW transactions.
|
||||||
|
///
|
||||||
|
/// Implementations must return the canonical Store model without exposing
|
||||||
|
/// backend rows, SQL handles or transport-specific DTOs. Absence is represented
|
||||||
|
/// by `None`; backend/runtime failures use the common KSP error contract.
|
||||||
|
pub trait RawTransactionRead: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Reads one canonical RAW transaction by durable backend-independent reference.
|
||||||
|
fn get_raw_transaction<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a crate::RawTransactionReference,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawTransaction>>>;
|
||||||
|
|
||||||
|
/// Lists deterministic transaction references using an opaque backend cursor.
|
||||||
|
///
|
||||||
|
/// The backend must honor the caller-requested page limit unless a real
|
||||||
|
/// backend/physical limitation prevents it. `ksp-store-api` imposes no
|
||||||
|
/// arbitrary smaller result ceiling or executor policy.
|
||||||
|
fn list_raw_transactions<'a>(
|
||||||
|
&'a self,
|
||||||
|
query: &'a crate::RawTransactionQuery,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawPage<crate::RawTransactionReference>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write capability for canonical RAW transaction acquisitions.
|
||||||
|
///
|
||||||
|
/// The transaction and its acquisition observation form one logical persistence
|
||||||
|
/// operation. An implementation must not leave one side durable if the other
|
||||||
|
/// side fails. Divergent content for one logical identity is a stable conflict
|
||||||
|
/// error, never a silent overwrite.
|
||||||
|
pub trait RawTransactionWrite: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Persists one complete RAW transaction together with one observation atomically.
|
||||||
|
fn persist_raw_transaction_acquisition<'a>(
|
||||||
|
&'a self,
|
||||||
|
transaction: crate::RawTransaction,
|
||||||
|
observation: crate::RawTransactionObservation,
|
||||||
|
mode: crate::RawTransactionAcquisitionMode,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawAcquisitionWriteOutcome>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read capability for persisted RAW transaction observations.
|
||||||
|
pub trait RawTransactionObservationRead: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Reads one transaction observation by deterministic producer-owned idempotence key.
|
||||||
|
fn get_raw_transaction_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation_key: &'a crate::RawObservationKey,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawTransactionObservation>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write capability for an additional observation of an already persisted RAW transaction.
|
||||||
|
///
|
||||||
|
/// This capability exists so repeated acquisitions can be recorded without
|
||||||
|
/// resubmitting the potentially large canonical transaction payload. The
|
||||||
|
/// referenced transaction must already exist.
|
||||||
|
pub trait RawTransactionObservationWrite: std::marker::Send + std::marker::Sync {
|
||||||
|
/// Persists one additional acquisition observation for an existing RAW transaction.
|
||||||
|
fn record_raw_transaction_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation: crate::RawTransactionObservation,
|
||||||
|
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawObservationWriteOutcome>>;
|
||||||
|
}
|
||||||
15
crates/ksp-store-api/src/error.rs
Normal file
15
crates/ksp-store-api/src/error.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// file: crates/ksp-store-api/src/error.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// Error code used when a RAW write collides with divergent content for the same logical identity.
|
||||||
|
pub const ERROR_CODE_RAW_CONFLICT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_conflict");
|
||||||
|
/// Error code used when a RAW Store model violates one of its backend-agnostic invariants.
|
||||||
|
pub const ERROR_CODE_RAW_MODEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_model_invalid");
|
||||||
|
/// Error code used when a KSP-owned RAW persistence payload violates its format or admission contract.
|
||||||
|
pub const ERROR_CODE_RAW_PAYLOAD_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_payload_invalid");
|
||||||
|
/// Error code used when acquisition provenance is malformed, unsafe or internally inconsistent.
|
||||||
|
pub const ERROR_CODE_RAW_PROVENANCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_provenance_invalid");
|
||||||
|
/// Error code used when one RAW query or cursor violates backend-agnostic query invariants.
|
||||||
|
pub const ERROR_CODE_RAW_QUERY_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_query_invalid");
|
||||||
|
/// Error code used when a RAW retention transition violates the logical lifecycle contract.
|
||||||
|
pub const ERROR_CODE_RAW_RETENTION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_retention_invalid");
|
||||||
142
crates/ksp-store-api/src/lib.rs
Normal file
142
crates/ksp-store-api/src/lib.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// file: crates/ksp-store-api/src/lib.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Backend-agnostic persistence contracts for KSP Store implementations.
|
||||||
|
//!
|
||||||
|
//! `ksp-store-api` owns persistent models plus the contracts that operate on
|
||||||
|
//! them. The `0.3.1` release is limited to N1 RAW/acquisition data. N2
|
||||||
|
//! STRUCTURAL is a later, distinct layer and no Program decode belongs to this
|
||||||
|
//! crate.
|
||||||
|
//!
|
||||||
|
//! Models and capabilities deliberately have separate private module homes.
|
||||||
|
//! Backend implementations, SQL, migrations, Config, Transport and runtime
|
||||||
|
//! dispatch remain outside this crate.
|
||||||
|
|
||||||
|
mod capability;
|
||||||
|
mod error;
|
||||||
|
mod model;
|
||||||
|
|
||||||
|
/// Boxed async operation returned by object-safe Store capability contracts.
|
||||||
|
pub use self::capability::StoreApiFuture;
|
||||||
|
/// Read capability for persisted RAW account-state observations.
|
||||||
|
pub use self::capability::raw_account::RawAccountObservationRead;
|
||||||
|
/// Write capability for additional observations of already persisted RAW account states.
|
||||||
|
pub use self::capability::raw_account::RawAccountObservationWrite;
|
||||||
|
/// Read capability for complete canonical RAW account states.
|
||||||
|
pub use self::capability::raw_account::RawAccountStateRead;
|
||||||
|
/// Write capability for complete canonical RAW account-state acquisitions.
|
||||||
|
pub use self::capability::raw_account::RawAccountStateWrite;
|
||||||
|
/// Read capability for canonical RAW transaction retention metadata.
|
||||||
|
pub use self::capability::raw_retention::RawTransactionRetentionRead;
|
||||||
|
/// Write capability for policy-authorized RAW transaction retention transitions.
|
||||||
|
pub use self::capability::raw_retention::RawTransactionRetentionWrite;
|
||||||
|
/// Read capability for persisted RAW transaction observations.
|
||||||
|
pub use self::capability::raw_transaction::RawTransactionObservationRead;
|
||||||
|
/// Write capability for additional observations of already persisted RAW transactions.
|
||||||
|
pub use self::capability::raw_transaction::RawTransactionObservationWrite;
|
||||||
|
/// Read capability for canonical RAW transactions.
|
||||||
|
pub use self::capability::raw_transaction::RawTransactionRead;
|
||||||
|
/// Write capability for canonical RAW transaction acquisitions.
|
||||||
|
pub use self::capability::raw_transaction::RawTransactionWrite;
|
||||||
|
/// Error code used when a RAW write collides with divergent content for the same logical identity.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_CONFLICT;
|
||||||
|
/// Error code used when a RAW Store model violates one of its backend-agnostic invariants.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_MODEL_INVALID;
|
||||||
|
/// Error code used when a KSP-owned RAW persistence payload violates its format or admission contract.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_PAYLOAD_INVALID;
|
||||||
|
/// Error code used when acquisition provenance is malformed, unsafe or internally inconsistent.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_PROVENANCE_INVALID;
|
||||||
|
/// Error code used when one RAW query or cursor violates backend-agnostic query invariants.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_QUERY_INVALID;
|
||||||
|
/// Error code used when a RAW retention transition violates the logical lifecycle contract.
|
||||||
|
pub use self::error::ERROR_CODE_RAW_RETENTION_INVALID;
|
||||||
|
/// Persistable acquisition observation linked to one complete canonical RAW account state.
|
||||||
|
pub use self::model::raw_account::RawAccountObservation;
|
||||||
|
/// Canonical complete N1 RAW account state independent from acquisition transport.
|
||||||
|
pub use self::model::raw_account::RawAccountState;
|
||||||
|
/// Durable backend-independent identity of one canonical RAW account state.
|
||||||
|
pub use self::model::raw_account::RawAccountStateReference;
|
||||||
|
/// Combined outcome of one atomic canonical RAW entity plus observation acquisition.
|
||||||
|
pub use self::model::raw_outcome::RawAcquisitionWriteOutcome;
|
||||||
|
/// Outcome for one canonical RAW entity in an idempotent persistence operation.
|
||||||
|
pub use self::model::raw_outcome::RawEntityWriteOutcome;
|
||||||
|
/// Outcome for one deterministic acquisition observation write.
|
||||||
|
pub use self::model::raw_outcome::RawObservationWriteOutcome;
|
||||||
|
/// Maximum opaque query cursor length admitted by the Store API.
|
||||||
|
pub use self::model::raw_pagination::MAX_RAW_PAGE_CURSOR_BYTES;
|
||||||
|
/// Backend-independent list query for complete canonical RAW account states.
|
||||||
|
pub use self::model::raw_pagination::RawAccountStateQuery;
|
||||||
|
/// One deterministic page of backend-independent Store results.
|
||||||
|
pub use self::model::raw_pagination::RawPage;
|
||||||
|
/// Opaque backend-owned cursor returned by one deterministic Store query.
|
||||||
|
pub use self::model::raw_pagination::RawPageCursor;
|
||||||
|
/// Caller-requested page size without an arbitrary KSP policy ceiling.
|
||||||
|
pub use self::model::raw_pagination::RawPageLimit;
|
||||||
|
/// Opaque-cursor page request used by backend-independent list operations.
|
||||||
|
pub use self::model::raw_pagination::RawPageRequest;
|
||||||
|
/// Optional inclusive Solana slot bounds for one Store query.
|
||||||
|
pub use self::model::raw_pagination::RawSlotRange;
|
||||||
|
/// Deterministic traversal direction for Store list queries.
|
||||||
|
pub use self::model::raw_pagination::RawSortDirection;
|
||||||
|
/// Backend-independent list query for canonical RAW transactions.
|
||||||
|
pub use self::model::raw_pagination::RawTransactionQuery;
|
||||||
|
/// Maximum complete RAW account-data length admitted by the Store API.
|
||||||
|
pub use self::model::raw_primitives::MAX_RAW_ACCOUNT_DATA_BYTES;
|
||||||
|
/// Maximum UTF-8 byte length accepted for one safe logical RAW/provenance code.
|
||||||
|
pub use self::model::raw_primitives::MAX_RAW_CODE_BYTES;
|
||||||
|
/// Maximum KSP-owned canonical RAW payload admitted by the Store API.
|
||||||
|
pub use self::model::raw_primitives::MAX_RAW_PAYLOAD_BYTES;
|
||||||
|
/// Maximum source-wire payload size recorded as acquisition metadata.
|
||||||
|
pub use self::model::raw_primitives::MAX_RAW_SOURCE_PAYLOAD_BYTES;
|
||||||
|
/// Maximum supported Unix millisecond timestamp.
|
||||||
|
pub use self::model::raw_primitives::MAX_RAW_UNIX_MILLIS;
|
||||||
|
/// Origin category describing why one acquisition was performed.
|
||||||
|
pub use self::model::raw_primitives::RawAcquisitionOrigin;
|
||||||
|
/// Safe source-independent acquisition provenance attached to one persisted observation.
|
||||||
|
pub use self::model::raw_primitives::RawAcquisitionProvenance;
|
||||||
|
/// Fixed-size digest identifying canonical or source bytes without retaining them.
|
||||||
|
pub use self::model::raw_primitives::RawContentHash;
|
||||||
|
/// Bounded identifier of one KSP-owned source-independent RAW persistence format.
|
||||||
|
pub use self::model::raw_primitives::RawFormatId;
|
||||||
|
/// Bounded logical network/cluster identifier used in backend-independent Store identities.
|
||||||
|
pub use self::model::raw_primitives::RawNetworkId;
|
||||||
|
/// Stable deterministic idempotence key for one persisted acquisition observation.
|
||||||
|
pub use self::model::raw_primitives::RawObservationKey;
|
||||||
|
/// Bounded source-independent KSP RAW persistence payload.
|
||||||
|
pub use self::model::raw_primitives::RawPayload;
|
||||||
|
/// Bounded logical code used by acquisition provenance fields.
|
||||||
|
pub use self::model::raw_primitives::RawProvenanceCode;
|
||||||
|
/// Bounded UTC timestamp represented as whole milliseconds since Unix epoch.
|
||||||
|
pub use self::model::raw_primitives::RawTimestamp;
|
||||||
|
/// Canonical 64-byte Solana transaction signature used by Store identities.
|
||||||
|
pub use self::model::raw_primitives::RawTransactionSignature;
|
||||||
|
/// Logical availability state of one canonical RAW payload.
|
||||||
|
pub use self::model::raw_retention::RawRetentionState;
|
||||||
|
/// Outcome of one atomic RAW retention transition.
|
||||||
|
pub use self::model::raw_retention::RawRetentionWriteOutcome;
|
||||||
|
/// Explicit write mode for canonical RAW transaction acquisitions.
|
||||||
|
pub use self::model::raw_retention::RawTransactionAcquisitionMode;
|
||||||
|
/// Requested compare-and-transition operation for one RAW transaction retention state.
|
||||||
|
pub use self::model::raw_retention::RawTransactionRetentionTransition;
|
||||||
|
/// Minimal durable identity retained after a canonical RAW transaction payload is purged.
|
||||||
|
pub use self::model::raw_retention::RawTransactionTombstone;
|
||||||
|
/// Canonical source-independent N1 RAW transaction persisted by Store backends.
|
||||||
|
pub use self::model::raw_transaction::RawTransaction;
|
||||||
|
/// Persistable acquisition observation linked to one canonical RAW transaction.
|
||||||
|
pub use self::model::raw_transaction::RawTransactionObservation;
|
||||||
|
/// Durable backend-independent identity of one canonical RAW transaction.
|
||||||
|
pub use self::model::raw_transaction::RawTransactionReference;
|
||||||
|
/// Common KSP error type used by Store-facing contracts.
|
||||||
|
pub use ksp_core_lib::Error;
|
||||||
|
/// Stable structured code identifying a KSP error category and condition.
|
||||||
|
pub use ksp_core_lib::ErrorCode;
|
||||||
|
/// Structured contextual field attached to a KSP error.
|
||||||
|
pub use ksp_core_lib::ErrorContext;
|
||||||
|
/// Canonical Solana account address primitive shared by persistent models.
|
||||||
|
pub use ksp_core_lib::Pubkey;
|
||||||
|
/// Common KSP result alias using [`Error`].
|
||||||
|
pub use ksp_core_lib::Result;
|
||||||
16
crates/ksp-store-api/src/model.rs
Normal file
16
crates/ksp-store-api/src/model.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Private home for persistent Store models.
|
||||||
|
//!
|
||||||
|
//! The current release is N1 RAW-only. Future N2 STRUCTURAL models are a
|
||||||
|
//! separate data layer and are not introduced here. Persistent models and
|
||||||
|
//! backend capabilities remain separate even when one capability operates on
|
||||||
|
//! one or more models.
|
||||||
|
|
||||||
|
pub(crate) mod raw_account;
|
||||||
|
pub(crate) mod raw_outcome;
|
||||||
|
pub(crate) mod raw_pagination;
|
||||||
|
pub(crate) mod raw_primitives;
|
||||||
|
pub(crate) mod raw_retention;
|
||||||
|
pub(crate) mod raw_transaction;
|
||||||
231
crates/ksp-store-api/src/model/raw_account.rs
Normal file
231
crates/ksp-store-api/src/model/raw_account.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model/raw_account.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Durable backend-independent identity of one canonical RAW account state.
|
||||||
|
///
|
||||||
|
/// The content hash is part of the identity because one account can be written more than once
|
||||||
|
/// inside the same slot while standard HTTP/WebSocket surfaces do not expose Yellowstone's
|
||||||
|
/// `write_version`. Multiple observations of the same complete state therefore converge on the
|
||||||
|
/// same reference without making a provider-specific write ordinal part of the common model.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawAccountStateReference {
|
||||||
|
network: crate::RawNetworkId,
|
||||||
|
pubkey: ksp_core_lib::Pubkey,
|
||||||
|
slot: u64,
|
||||||
|
state_hash: crate::RawContentHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAccountStateReference {
|
||||||
|
/// Creates one durable account-state identity from network, account, slot and canonical state digest.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(network: crate::RawNetworkId, pubkey: ksp_core_lib::Pubkey, slot: u64, state_hash: crate::RawContentHash) -> Self {
|
||||||
|
return Self { network, pubkey, slot, state_hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical Solana network/cluster identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn network(&self) -> &crate::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account public key.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||||
|
return &self.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the slot associated with this complete account state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the producer-supplied digest of the complete canonical account state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn state_hash(&self) -> crate::RawContentHash {
|
||||||
|
return self.state_hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical complete N1 RAW account state independent from HTTP, WebSocket or gRPC acquisition.
|
||||||
|
///
|
||||||
|
/// Only complete raw account bytes are admissible. A transport response using `jsonParsed`, a
|
||||||
|
/// request-side data slice, or a response without a durable slot context must be normalized or
|
||||||
|
/// reacquired before this model is constructed.
|
||||||
|
pub struct RawAccountState {
|
||||||
|
data: std::boxed::Box<[u8]>,
|
||||||
|
executable: bool,
|
||||||
|
lamports: u64,
|
||||||
|
owner: ksp_core_lib::Pubkey,
|
||||||
|
reference: crate::RawAccountStateReference,
|
||||||
|
rent_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAccountState {
|
||||||
|
/// Creates one complete canonical RAW account state after Store-owned admission checks.
|
||||||
|
pub fn try_new(
|
||||||
|
reference: crate::RawAccountStateReference,
|
||||||
|
lamports: u64,
|
||||||
|
owner: ksp_core_lib::Pubkey,
|
||||||
|
executable: bool,
|
||||||
|
rent_epoch: u64,
|
||||||
|
data: std::boxed::Box<[u8]>,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
if data.len() > crate::MAX_RAW_ACCOUNT_DATA_BYTES {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_MODEL_INVALID, "invalid backend-agnostic RAW Store model")
|
||||||
|
.with_context("field", "account_data")
|
||||||
|
.with_context("actual_len", data.len().to_string())
|
||||||
|
.with_context("maximum_len", crate::MAX_RAW_ACCOUNT_DATA_BYTES.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { data, executable, lamports, owner, reference, rent_epoch });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact complete account bytes used by future decoders.
|
||||||
|
#[must_use]
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
return self.data.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the complete account-data length in bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn data_len(&self) -> usize {
|
||||||
|
return self.data.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the account is executable.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn executable(&self) -> bool {
|
||||||
|
return self.executable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account lamport balance.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn lamports(&self) -> u64 {
|
||||||
|
return self.lamports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account owner program public key.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn owner(&self) -> &ksp_core_lib::Pubkey {
|
||||||
|
return &self.owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the durable source-independent account-state identity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference(&self) -> &crate::RawAccountStateReference {
|
||||||
|
return &self.reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rent epoch reported for this account state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn rent_epoch(&self) -> u64 {
|
||||||
|
return self.rent_epoch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RawAccountState {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("RawAccountState")
|
||||||
|
.field("reference", &self.reference)
|
||||||
|
.field("lamports", &self.lamports)
|
||||||
|
.field("owner", &self.owner)
|
||||||
|
.field("executable", &self.executable)
|
||||||
|
.field("rent_epoch", &self.rent_epoch)
|
||||||
|
.field("data_len", &self.data.len())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistable acquisition observation linked to one complete canonical RAW account state.
|
||||||
|
///
|
||||||
|
/// Yellowstone-only metadata remains optional observation detail and never changes the canonical
|
||||||
|
/// account state itself. HTTP/WS acquisitions therefore use the same observation type without
|
||||||
|
/// inventing a `write_version`, transaction signature or startup flag.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawAccountObservation {
|
||||||
|
account: crate::RawAccountStateReference,
|
||||||
|
is_startup: std::option::Option<bool>,
|
||||||
|
observation_key: crate::RawObservationKey,
|
||||||
|
provenance: crate::RawAcquisitionProvenance,
|
||||||
|
transaction_signature: std::option::Option<crate::RawTransactionSignature>,
|
||||||
|
write_version: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAccountObservation {
|
||||||
|
/// Creates one successful observation of a complete canonical RAW account state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(observation_key: crate::RawObservationKey, account: crate::RawAccountStateReference, provenance: crate::RawAcquisitionProvenance) -> Self {
|
||||||
|
return Self {
|
||||||
|
account,
|
||||||
|
is_startup: std::option::Option::None,
|
||||||
|
observation_key,
|
||||||
|
provenance,
|
||||||
|
transaction_signature: std::option::Option::None,
|
||||||
|
write_version: std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches a provider-reported startup/replay marker when the source exposes one.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_is_startup(mut self, value: bool) -> Self {
|
||||||
|
self.is_startup = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the transaction signature associated with the account write when exposed by the source.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_transaction_signature(mut self, value: crate::RawTransactionSignature) -> Self {
|
||||||
|
self.transaction_signature = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the source-specific account write version when the source exposes one.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_write_version(mut self, value: u64) -> Self {
|
||||||
|
self.write_version = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the durable account-state identity observed by this acquisition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn account(&self) -> &crate::RawAccountStateReference {
|
||||||
|
return &self.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional source-reported startup/replay marker.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_startup(&self) -> std::option::Option<bool> {
|
||||||
|
return self.is_startup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deterministic producer-owned observation idempotence key.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn observation_key(&self) -> crate::RawObservationKey {
|
||||||
|
return self.observation_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns safe source-independent acquisition provenance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> &crate::RawAcquisitionProvenance {
|
||||||
|
return &self.provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional transaction signature associated with this account write.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn transaction_signature(&self) -> std::option::Option<crate::RawTransactionSignature> {
|
||||||
|
return self.transaction_signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional source-specific account write version.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn write_version(&self) -> std::option::Option<u64> {
|
||||||
|
return self.write_version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../../unit_tests/model/raw_account.rs"]
|
||||||
|
mod tests;
|
||||||
55
crates/ksp-store-api/src/model/raw_outcome.rs
Normal file
55
crates/ksp-store-api/src/model/raw_outcome.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model/raw_outcome.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Outcome for one canonical RAW entity in an idempotent persistence operation.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawEntityWriteOutcome {
|
||||||
|
/// The canonical RAW entity was inserted for the first time.
|
||||||
|
Inserted,
|
||||||
|
/// Identical canonical content was already durable.
|
||||||
|
AlreadyPresent,
|
||||||
|
/// A previously purged RAW entity was explicitly rehydrated.
|
||||||
|
Rehydrated,
|
||||||
|
/// Normal persistence skipped a durable purged tombstone.
|
||||||
|
SkippedPurged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome for one deterministic acquisition observation write.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawObservationWriteOutcome {
|
||||||
|
/// The observation was inserted for the first time.
|
||||||
|
Inserted,
|
||||||
|
/// The same observation key already identified identical durable content.
|
||||||
|
AlreadyPresent,
|
||||||
|
/// No observation was recorded because the associated RAW entity was intentionally skipped.
|
||||||
|
NotRecorded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined outcome of one atomic canonical RAW entity plus observation acquisition.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawAcquisitionWriteOutcome {
|
||||||
|
entity: crate::RawEntityWriteOutcome,
|
||||||
|
observation: crate::RawObservationWriteOutcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAcquisitionWriteOutcome {
|
||||||
|
/// Creates one backend-independent atomic acquisition outcome.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(entity: crate::RawEntityWriteOutcome, observation: crate::RawObservationWriteOutcome) -> Self {
|
||||||
|
return Self { entity, observation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical RAW entity write outcome.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn entity(&self) -> crate::RawEntityWriteOutcome {
|
||||||
|
return self.entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the acquisition observation write outcome.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn observation(&self) -> crate::RawObservationWriteOutcome {
|
||||||
|
return self.observation;
|
||||||
|
}
|
||||||
|
}
|
||||||
267
crates/ksp-store-api/src/model/raw_pagination.rs
Normal file
267
crates/ksp-store-api/src/model/raw_pagination.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model/raw_pagination.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Maximum opaque cursor length accepted by Store API queries.
|
||||||
|
///
|
||||||
|
/// This bounds untrusted token material only. It is not a policy limit on the
|
||||||
|
/// number of rows/results a backend may return.
|
||||||
|
pub const MAX_RAW_PAGE_CURSOR_BYTES: usize = 4 * 1024;
|
||||||
|
|
||||||
|
/// Opaque backend-owned cursor returned by one deterministic Store query.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct RawPageCursor(std::boxed::Box<[u8]>);
|
||||||
|
|
||||||
|
impl RawPageCursor {
|
||||||
|
/// Creates one non-empty bounded opaque cursor.
|
||||||
|
pub fn try_new(bytes: std::boxed::Box<[u8]>) -> crate::Result<Self> {
|
||||||
|
if bytes.is_empty() || bytes.len() > crate::MAX_RAW_PAGE_CURSOR_BYTES {
|
||||||
|
return std::result::Result::Err(raw_query_error("cursor"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque cursor bytes unchanged.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
|
return self.0.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RawPageCursor {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("RawPageCursor").field("len", &self.0.len()).finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-requested page size without an arbitrary KSP policy ceiling.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawPageLimit(u64);
|
||||||
|
|
||||||
|
impl RawPageLimit {
|
||||||
|
/// Creates one strictly positive requested page size.
|
||||||
|
///
|
||||||
|
/// `ksp-store-api` deliberately imposes no smaller functional maximum.
|
||||||
|
/// Concrete backends may expose or return their real physical limitations.
|
||||||
|
pub fn new(value: u64) -> crate::Result<Self> {
|
||||||
|
if value == 0 {
|
||||||
|
return std::result::Result::Err(raw_query_error("limit"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact caller-requested item count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn get(&self) -> u64 {
|
||||||
|
return self.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque-cursor page request used by backend-independent list operations.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawPageRequest {
|
||||||
|
cursor: std::option::Option<crate::RawPageCursor>,
|
||||||
|
limit: crate::RawPageLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawPageRequest {
|
||||||
|
/// Creates one first-page request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn first(limit: crate::RawPageLimit) -> Self {
|
||||||
|
return Self { cursor: std::option::Option::None, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates one continuation-page request with an opaque backend cursor.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn after(limit: crate::RawPageLimit, cursor: crate::RawPageCursor) -> Self {
|
||||||
|
return Self { cursor: std::option::Option::Some(cursor), limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opaque continuation cursor when present.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cursor(&self) -> std::option::Option<&crate::RawPageCursor> {
|
||||||
|
return self.cursor.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact caller-requested page size.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn limit(&self) -> crate::RawPageLimit {
|
||||||
|
return self.limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One deterministic page of backend-independent Store results.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RawPage<T> {
|
||||||
|
items: std::vec::Vec<T>,
|
||||||
|
next_cursor: std::option::Option<crate::RawPageCursor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> RawPage<T> {
|
||||||
|
/// Creates one result page from backend-provided items and optional continuation cursor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(items: std::vec::Vec<T>, next_cursor: std::option::Option<crate::RawPageCursor>) -> Self {
|
||||||
|
return Self { items, next_cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current page items.
|
||||||
|
#[must_use]
|
||||||
|
pub fn items(&self) -> &[T] {
|
||||||
|
return self.items.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the page and returns its items.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_items(self) -> std::vec::Vec<T> {
|
||||||
|
return self.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the next opaque cursor when more results are available.
|
||||||
|
#[must_use]
|
||||||
|
pub fn next_cursor(&self) -> std::option::Option<&crate::RawPageCursor> {
|
||||||
|
return self.next_cursor.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic traversal direction for Store list queries.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawSortDirection {
|
||||||
|
/// Oldest/lower ordered keys first.
|
||||||
|
Ascending,
|
||||||
|
/// Newest/higher ordered keys first.
|
||||||
|
Descending,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional inclusive Solana slot bounds for one Store query.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawSlotRange {
|
||||||
|
end_inclusive: std::option::Option<u64>,
|
||||||
|
start_inclusive: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawSlotRange {
|
||||||
|
/// Creates one optional inclusive slot range and rejects reversed bounds.
|
||||||
|
pub fn new(start_inclusive: std::option::Option<u64>, end_inclusive: std::option::Option<u64>) -> crate::Result<Self> {
|
||||||
|
if let (std::option::Option::Some(start), std::option::Option::Some(end)) = (start_inclusive, end_inclusive)
|
||||||
|
&& start > end
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(raw_query_error("slot_range"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { end_inclusive, start_inclusive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional inclusive ending slot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn end_inclusive(&self) -> std::option::Option<u64> {
|
||||||
|
return self.end_inclusive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional inclusive starting slot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn start_inclusive(&self) -> std::option::Option<u64> {
|
||||||
|
return self.start_inclusive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backend-independent list query for canonical RAW transactions.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawTransactionQuery {
|
||||||
|
direction: crate::RawSortDirection,
|
||||||
|
network: crate::RawNetworkId,
|
||||||
|
page: crate::RawPageRequest,
|
||||||
|
slots: crate::RawSlotRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransactionQuery {
|
||||||
|
/// Creates one deterministic transaction-reference query.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(network: crate::RawNetworkId, slots: crate::RawSlotRange, direction: crate::RawSortDirection, page: crate::RawPageRequest) -> Self {
|
||||||
|
return Self { direction, network, page, slots };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the requested deterministic traversal direction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn direction(&self) -> crate::RawSortDirection {
|
||||||
|
return self.direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the required logical network scope.
|
||||||
|
#[must_use]
|
||||||
|
pub fn network(&self) -> &crate::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns pagination inputs without exposing backend cursor contents.
|
||||||
|
#[must_use]
|
||||||
|
pub fn page(&self) -> &crate::RawPageRequest {
|
||||||
|
return &self.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional inclusive slot bounds.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slots(&self) -> crate::RawSlotRange {
|
||||||
|
return self.slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backend-independent list query for complete canonical RAW account states.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawAccountStateQuery {
|
||||||
|
direction: crate::RawSortDirection,
|
||||||
|
network: crate::RawNetworkId,
|
||||||
|
page: crate::RawPageRequest,
|
||||||
|
pubkey: std::option::Option<crate::Pubkey>,
|
||||||
|
slots: crate::RawSlotRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAccountStateQuery {
|
||||||
|
/// Creates one deterministic account-state-reference query.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
network: crate::RawNetworkId,
|
||||||
|
pubkey: std::option::Option<crate::Pubkey>,
|
||||||
|
slots: crate::RawSlotRange,
|
||||||
|
direction: crate::RawSortDirection,
|
||||||
|
page: crate::RawPageRequest,
|
||||||
|
) -> Self {
|
||||||
|
return Self { direction, network, page, pubkey, slots };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the requested deterministic traversal direction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn direction(&self) -> crate::RawSortDirection {
|
||||||
|
return self.direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the required logical network scope.
|
||||||
|
#[must_use]
|
||||||
|
pub fn network(&self) -> &crate::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns pagination inputs without exposing backend cursor contents.
|
||||||
|
#[must_use]
|
||||||
|
pub fn page(&self) -> &crate::RawPageRequest {
|
||||||
|
return &self.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an optional account-address restriction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn pubkey(&self) -> std::option::Option<&crate::Pubkey> {
|
||||||
|
return self.pubkey.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional inclusive slot bounds.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slots(&self) -> crate::RawSlotRange {
|
||||||
|
return self.slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_query_error(field: &'static str) -> crate::Error {
|
||||||
|
return crate::Error::new(crate::ERROR_CODE_RAW_QUERY_INVALID, "invalid backend-agnostic RAW Store query").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../../unit_tests/model/raw_pagination.rs"]
|
||||||
|
mod tests;
|
||||||
453
crates/ksp-store-api/src/model/raw_primitives.rs
Normal file
453
crates/ksp-store-api/src/model/raw_primitives.rs
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model/raw_primitives.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// Maximum complete RAW account-data length admitted by the Store API.
|
||||||
|
///
|
||||||
|
/// This is a Store admission guard, not a Solana protocol-size claim.
|
||||||
|
pub const MAX_RAW_ACCOUNT_DATA_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
/// Maximum UTF-8 byte length accepted for one safe logical RAW/provenance code.
|
||||||
|
pub const MAX_RAW_CODE_BYTES: usize = 128;
|
||||||
|
/// Maximum KSP-owned canonical RAW payload admitted by the Store API.
|
||||||
|
///
|
||||||
|
/// This is a Store admission guard, not a Solana protocol-size claim.
|
||||||
|
pub const MAX_RAW_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
/// Maximum source-wire payload size recorded as acquisition metadata.
|
||||||
|
///
|
||||||
|
/// The source payload itself is never retained by this metadata field.
|
||||||
|
pub const MAX_RAW_SOURCE_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
/// Maximum supported Unix millisecond timestamp (`9999-12-31T23:59:59.999Z`).
|
||||||
|
pub const MAX_RAW_UNIX_MILLIS: u64 = 253_402_300_799_999;
|
||||||
|
|
||||||
|
/// Fixed-size digest identifying canonical or source bytes without retaining them.
|
||||||
|
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawContentHash([u8; 32]);
|
||||||
|
|
||||||
|
impl RawContentHash {
|
||||||
|
/// Creates one opaque 32-byte KSP content digest.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(bytes: [u8; 32]) -> Self {
|
||||||
|
return Self(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact digest bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||||
|
return &self.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RawContentHash {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("RawContentHash(..)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable deterministic idempotence key for one persisted acquisition observation.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawObservationKey([u8; 32]);
|
||||||
|
|
||||||
|
impl RawObservationKey {
|
||||||
|
/// Creates one producer-owned deterministic observation key.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(bytes: [u8; 32]) -> Self {
|
||||||
|
return Self(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact observation-key bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||||
|
return &self.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical 64-byte Solana transaction signature used by Store identities.
|
||||||
|
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct RawTransactionSignature([u8; 64]);
|
||||||
|
|
||||||
|
impl RawTransactionSignature {
|
||||||
|
/// Creates one canonical signature from already-decoded Solana signature bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(bytes: [u8; 64]) -> Self {
|
||||||
|
return Self(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact signature bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_bytes(&self) -> &[u8; 64] {
|
||||||
|
return &self.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RawTransactionSignature {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("RawTransactionSignature(..)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded logical network/cluster identifier used in backend-independent Store identities.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct RawNetworkId(std::string::String);
|
||||||
|
|
||||||
|
impl RawNetworkId {
|
||||||
|
/// Creates one safe non-empty network/cluster identifier.
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
if !valid_raw_code(value.as_str()) {
|
||||||
|
return std::result::Result::Err(raw_model_error("network"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the network/cluster identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded logical code used by acquisition provenance fields.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct RawProvenanceCode(std::string::String);
|
||||||
|
|
||||||
|
impl RawProvenanceCode {
|
||||||
|
/// Creates one safe non-empty provenance code.
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
if !valid_raw_code(value.as_str()) {
|
||||||
|
return std::result::Result::Err(raw_provenance_error("code"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated provenance code.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded identifier of one KSP-owned source-independent RAW persistence format.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct RawFormatId(std::string::String);
|
||||||
|
|
||||||
|
impl RawFormatId {
|
||||||
|
/// Creates one safe non-empty RAW format identifier.
|
||||||
|
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
if !valid_raw_code(value.as_str()) {
|
||||||
|
return std::result::Result::Err(raw_payload_error("format_id"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP-owned RAW format identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded UTC timestamp represented as whole milliseconds since Unix epoch.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct RawTimestamp {
|
||||||
|
unix_millis: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTimestamp {
|
||||||
|
/// Creates a bounded UTC timestamp from Unix milliseconds.
|
||||||
|
pub fn from_unix_millis(unix_millis: u64) -> ksp_core_lib::Result<Self> {
|
||||||
|
if unix_millis > crate::MAX_RAW_UNIX_MILLIS {
|
||||||
|
return std::result::Result::Err(raw_model_error("timestamp"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { unix_millis });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whole milliseconds since Unix epoch.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn unix_millis(&self) -> u64 {
|
||||||
|
return self.unix_millis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origin category describing why one acquisition was performed.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawAcquisitionOrigin {
|
||||||
|
/// Historical acquisition intended to fill missing durable data.
|
||||||
|
Backfill,
|
||||||
|
/// Explicit import from a non-live source controlled by the caller.
|
||||||
|
Import,
|
||||||
|
/// Live acquisition from a currently active transport/session.
|
||||||
|
Live,
|
||||||
|
/// Explicit repair or reconciliation of previously known data.
|
||||||
|
Repair,
|
||||||
|
/// Explicit replay of an already-known source or archived acquisition.
|
||||||
|
Replay,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe source-independent acquisition provenance attached to one persisted observation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawAcquisitionProvenance {
|
||||||
|
acquisition_method: crate::RawProvenanceCode,
|
||||||
|
capture_session_id: std::option::Option<crate::RawProvenanceCode>,
|
||||||
|
commitment: std::option::Option<crate::RawProvenanceCode>,
|
||||||
|
endpoint_id: std::option::Option<crate::RawProvenanceCode>,
|
||||||
|
filter_id: std::option::Option<crate::RawProvenanceCode>,
|
||||||
|
observed_at: std::option::Option<crate::RawTimestamp>,
|
||||||
|
origin: crate::RawAcquisitionOrigin,
|
||||||
|
protocol: crate::RawProvenanceCode,
|
||||||
|
provider: crate::RawProvenanceCode,
|
||||||
|
received_at: crate::RawTimestamp,
|
||||||
|
source_payload_hash: std::option::Option<crate::RawContentHash>,
|
||||||
|
source_payload_size_bytes: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAcquisitionProvenance {
|
||||||
|
/// Creates one successful acquisition provenance record with only mandatory safe metadata.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
provider: crate::RawProvenanceCode,
|
||||||
|
protocol: crate::RawProvenanceCode,
|
||||||
|
acquisition_method: crate::RawProvenanceCode,
|
||||||
|
origin: crate::RawAcquisitionOrigin,
|
||||||
|
received_at: crate::RawTimestamp,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
acquisition_method,
|
||||||
|
capture_session_id: std::option::Option::None,
|
||||||
|
commitment: std::option::Option::None,
|
||||||
|
endpoint_id: std::option::Option::None,
|
||||||
|
filter_id: std::option::Option::None,
|
||||||
|
observed_at: std::option::Option::None,
|
||||||
|
origin,
|
||||||
|
protocol,
|
||||||
|
provider,
|
||||||
|
received_at,
|
||||||
|
source_payload_hash: std::option::Option::None,
|
||||||
|
source_payload_size_bytes: std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches one safe logical capture/session identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_capture_session_id(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||||
|
self.capture_session_id = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches one safe commitment code captured at acquisition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_commitment(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||||
|
self.commitment = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches one Config-owned logical endpoint identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_endpoint_id(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||||
|
self.endpoint_id = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches one safe logical filter identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_filter_id(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||||
|
self.filter_id = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the source observation timestamp when it does not follow local receipt.
|
||||||
|
pub fn try_with_observed_at(mut self, value: crate::RawTimestamp) -> ksp_core_lib::Result<Self> {
|
||||||
|
if value > self.received_at {
|
||||||
|
return std::result::Result::Err(raw_provenance_error("observed_at"));
|
||||||
|
}
|
||||||
|
self.observed_at = std::option::Option::Some(value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the digest of source-specific bytes without retaining those bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_source_payload_hash(mut self, value: crate::RawContentHash) -> Self {
|
||||||
|
self.source_payload_hash = std::option::Option::Some(value);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the bounded source-wire payload size.
|
||||||
|
pub fn try_with_source_payload_size_bytes(mut self, value: u64) -> ksp_core_lib::Result<Self> {
|
||||||
|
if value > crate::MAX_RAW_SOURCE_PAYLOAD_BYTES {
|
||||||
|
return std::result::Result::Err(raw_provenance_error("source_payload_size_bytes"));
|
||||||
|
}
|
||||||
|
self.source_payload_size_bytes = std::option::Option::Some(value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical acquisition method code.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn acquisition_method(&self) -> &crate::RawProvenanceCode {
|
||||||
|
return &self.acquisition_method;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional logical capture/session identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn capture_session_id(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||||
|
return self.capture_session_id.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional commitment code captured at acquisition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn commitment(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||||
|
return self.commitment.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional Config-owned logical endpoint identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoint_id(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||||
|
return self.endpoint_id.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional logical filter identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn filter_id(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||||
|
return self.filter_id.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional source observation timestamp when the source supplies one.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn observed_at(&self) -> std::option::Option<crate::RawTimestamp> {
|
||||||
|
return self.observed_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the acquisition origin category.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn origin(&self) -> crate::RawAcquisitionOrigin {
|
||||||
|
return self.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical transport/protocol code.
|
||||||
|
#[must_use]
|
||||||
|
pub fn protocol(&self) -> &crate::RawProvenanceCode {
|
||||||
|
return &self.protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the safe provider code.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provider(&self) -> &crate::RawProvenanceCode {
|
||||||
|
return &self.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local receipt timestamp.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn received_at(&self) -> crate::RawTimestamp {
|
||||||
|
return self.received_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional digest of source-specific bytes without retaining those bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn source_payload_hash(&self) -> std::option::Option<crate::RawContentHash> {
|
||||||
|
return self.source_payload_hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional source-wire payload size.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn source_payload_size_bytes(&self) -> std::option::Option<u64> {
|
||||||
|
return self.source_payload_size_bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded source-independent KSP RAW persistence payload.
|
||||||
|
pub struct RawPayload {
|
||||||
|
bytes: std::boxed::Box<[u8]>,
|
||||||
|
content_hash: crate::RawContentHash,
|
||||||
|
format_id: crate::RawFormatId,
|
||||||
|
format_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawPayload {
|
||||||
|
/// Creates one canonical RAW payload after enforcing Store-owned admission invariants.
|
||||||
|
///
|
||||||
|
/// The supplied bytes must already use the KSP-owned source-independent format identified
|
||||||
|
/// by `format_id` and `format_version`; this constructor performs no transport conversion.
|
||||||
|
pub fn try_new(
|
||||||
|
format_id: crate::RawFormatId,
|
||||||
|
format_version: u32,
|
||||||
|
bytes: std::boxed::Box<[u8]>,
|
||||||
|
content_hash: crate::RawContentHash,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
if format_version == 0 {
|
||||||
|
return std::result::Result::Err(raw_payload_error("format_version"));
|
||||||
|
}
|
||||||
|
if bytes.is_empty() || bytes.len() > crate::MAX_RAW_PAYLOAD_BYTES {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
raw_payload_error("bytes")
|
||||||
|
.with_context("actual_len", bytes.len().to_string())
|
||||||
|
.with_context("maximum_len", crate::MAX_RAW_PAYLOAD_BYTES.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { bytes, content_hash, format_id, format_version });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical RAW bytes without transport/provider interpretation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bytes(&self) -> &[u8] {
|
||||||
|
return self.bytes.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deterministic content digest supplied for these exact canonical bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn content_hash(&self) -> crate::RawContentHash {
|
||||||
|
return self.content_hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP-owned source-independent format identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn format_id(&self) -> &crate::RawFormatId {
|
||||||
|
return &self.format_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP-owned format version.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn format_version(&self) -> u32 {
|
||||||
|
return self.format_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical payload length in bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn byte_len(&self) -> usize {
|
||||||
|
return self.bytes.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RawPayload {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("RawPayload")
|
||||||
|
.field("format_id", &self.format_id)
|
||||||
|
.field("format_version", &self.format_version)
|
||||||
|
.field("len", &self.bytes.len())
|
||||||
|
.field("content_hash", &self.content_hash)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_model_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_MODEL_INVALID, "invalid backend-agnostic RAW Store model").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_payload_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_PAYLOAD_INVALID, "invalid KSP RAW persistence payload").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_provenance_error(field: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_PROVENANCE_INVALID, "invalid RAW acquisition provenance").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_raw_code(value: &str) -> bool {
|
||||||
|
if value.is_empty() || value.len() > crate::MAX_RAW_CODE_BYTES {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.bytes().all(|byte| return byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../../unit_tests/model/raw_primitives.rs"]
|
||||||
|
mod tests;
|
||||||
157
crates/ksp-store-api/src/model/raw_retention.rs
Normal file
157
crates/ksp-store-api/src/model/raw_retention.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model/raw_retention.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
/// Logical availability state of one canonical RAW payload.
|
||||||
|
///
|
||||||
|
/// These states describe payload retention only. They never prove that a
|
||||||
|
/// STRUCTURAL/DECODED/DOMAIN processor has completed.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawRetentionState {
|
||||||
|
/// Complete canonical RAW payload is retained in ordinary Store availability.
|
||||||
|
Full,
|
||||||
|
/// Complete logical RAW content is retained in a compacted representation.
|
||||||
|
Compacted,
|
||||||
|
/// RAW content has been moved to an archive managed outside the hot Store path.
|
||||||
|
Archived,
|
||||||
|
/// RAW payload is absent while a durable tombstone remains.
|
||||||
|
Purged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit write mode for canonical RAW transaction acquisitions.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawTransactionAcquisitionMode {
|
||||||
|
/// Normal acquisition must skip an existing purged tombstone.
|
||||||
|
Normal,
|
||||||
|
/// Explicitly allow an existing purged transaction to be rehydrated.
|
||||||
|
ForceRehydrate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal durable identity retained after a canonical RAW transaction payload is purged.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawTransactionTombstone {
|
||||||
|
content_hash: crate::RawContentHash,
|
||||||
|
format_id: crate::RawFormatId,
|
||||||
|
format_version: u32,
|
||||||
|
reference: crate::RawTransactionReference,
|
||||||
|
slot: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransactionTombstone {
|
||||||
|
/// Creates one minimal transaction tombstone from canonical RAW identity metadata.
|
||||||
|
pub fn try_new(
|
||||||
|
reference: crate::RawTransactionReference,
|
||||||
|
slot: u64,
|
||||||
|
format_id: crate::RawFormatId,
|
||||||
|
format_version: u32,
|
||||||
|
content_hash: crate::RawContentHash,
|
||||||
|
) -> crate::Result<Self> {
|
||||||
|
if format_version == 0 {
|
||||||
|
return std::result::Result::Err(raw_retention_error("format_version"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { content_hash, format_id, format_version, reference, slot });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical content hash retained after purge.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn content_hash(&self) -> crate::RawContentHash {
|
||||||
|
return self.content_hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP-owned canonical RAW format identifier retained after purge.
|
||||||
|
#[must_use]
|
||||||
|
pub fn format_id(&self) -> &crate::RawFormatId {
|
||||||
|
return &self.format_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the KSP-owned canonical RAW format version retained after purge.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn format_version(&self) -> u32 {
|
||||||
|
return self.format_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the durable backend-independent transaction identity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference(&self) -> &crate::RawTransactionReference {
|
||||||
|
return &self.reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the known transaction slot retained after purge.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requested compare-and-transition operation for one RAW transaction retention state.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawTransactionRetentionTransition {
|
||||||
|
expected: crate::RawRetentionState,
|
||||||
|
reference: crate::RawTransactionReference,
|
||||||
|
target: crate::RawRetentionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransactionRetentionTransition {
|
||||||
|
/// Creates one allowed forward retention transition.
|
||||||
|
///
|
||||||
|
/// Rehydration from `Purged` is deliberately excluded and uses
|
||||||
|
/// [`crate::RawTransactionAcquisitionMode::ForceRehydrate`] instead.
|
||||||
|
pub fn try_new(reference: crate::RawTransactionReference, expected: crate::RawRetentionState, target: crate::RawRetentionState) -> crate::Result<Self> {
|
||||||
|
if !allowed_transition(expected, target) {
|
||||||
|
return std::result::Result::Err(raw_retention_error("transition"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { expected, reference, target });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the state the caller expects before applying the transition.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn expected(&self) -> crate::RawRetentionState {
|
||||||
|
return self.expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the durable transaction identity targeted by the transition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference(&self) -> &crate::RawTransactionReference {
|
||||||
|
return &self.reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the requested forward retention state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn target(&self) -> crate::RawRetentionState {
|
||||||
|
return self.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of one atomic RAW retention transition.
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum RawRetentionWriteOutcome {
|
||||||
|
/// The requested forward transition was applied.
|
||||||
|
Applied,
|
||||||
|
/// The entity was already at the requested target state.
|
||||||
|
AlreadyAtTarget,
|
||||||
|
/// The current state matched neither the caller's expected state nor the requested target.
|
||||||
|
///
|
||||||
|
/// This outcome makes a lost compare-and-transition race observable without
|
||||||
|
/// treating concurrent policy progress as malformed input.
|
||||||
|
ExpectedStateMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_transition(expected: crate::RawRetentionState, target: crate::RawRetentionState) -> bool {
|
||||||
|
return matches!(
|
||||||
|
(expected, target),
|
||||||
|
(crate::RawRetentionState::Full, crate::RawRetentionState::Compacted)
|
||||||
|
| (crate::RawRetentionState::Full, crate::RawRetentionState::Archived)
|
||||||
|
| (crate::RawRetentionState::Compacted, crate::RawRetentionState::Archived)
|
||||||
|
| (crate::RawRetentionState::Archived, crate::RawRetentionState::Purged)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_retention_error(field: &'static str) -> crate::Error {
|
||||||
|
return crate::Error::new(crate::ERROR_CODE_RAW_RETENTION_INVALID, "invalid RAW retention lifecycle request").with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../../unit_tests/model/raw_retention.rs"]
|
||||||
|
mod tests;
|
||||||
113
crates/ksp-store-api/src/model/raw_transaction.rs
Normal file
113
crates/ksp-store-api/src/model/raw_transaction.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// file: crates/ksp-store-api/src/model/raw_transaction.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Durable backend-independent identity of one canonical RAW transaction.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct RawTransactionReference {
|
||||||
|
network: crate::RawNetworkId,
|
||||||
|
signature: crate::RawTransactionSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransactionReference {
|
||||||
|
/// Creates one durable transaction identity from network and canonical Solana signature.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(network: crate::RawNetworkId, signature: crate::RawTransactionSignature) -> Self {
|
||||||
|
return Self { network, signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical Solana network/cluster identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn network(&self) -> &crate::RawNetworkId {
|
||||||
|
return &self.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical transaction signature.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn signature(&self) -> crate::RawTransactionSignature {
|
||||||
|
return self.signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical source-independent N1 RAW transaction persisted by Store backends.
|
||||||
|
///
|
||||||
|
/// The payload must contain the complete KSP canonical transaction representation required
|
||||||
|
/// for future STRUCTURAL replay, including transaction execution metadata and transaction
|
||||||
|
/// log messages when the canonical format defines them. Provider/transport provenance is
|
||||||
|
/// deliberately kept in [`crate::RawTransactionObservation`] instead.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RawTransaction {
|
||||||
|
block_time: std::option::Option<crate::RawTimestamp>,
|
||||||
|
payload: crate::RawPayload,
|
||||||
|
reference: crate::RawTransactionReference,
|
||||||
|
slot: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransaction {
|
||||||
|
/// Creates one complete canonical RAW transaction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(reference: crate::RawTransactionReference, slot: u64, block_time: std::option::Option<crate::RawTimestamp>, payload: crate::RawPayload) -> Self {
|
||||||
|
return Self { block_time, payload, reference, slot };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional canonical block timestamp.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn block_time(&self) -> std::option::Option<crate::RawTimestamp> {
|
||||||
|
return self.block_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the complete KSP-owned canonical RAW payload.
|
||||||
|
#[must_use]
|
||||||
|
pub fn payload(&self) -> &crate::RawPayload {
|
||||||
|
return &self.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the durable backend-independent transaction identity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference(&self) -> &crate::RawTransactionReference {
|
||||||
|
return &self.reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the Solana slot containing the transaction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slot(&self) -> u64 {
|
||||||
|
return self.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistable acquisition observation linked to one canonical RAW transaction.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RawTransactionObservation {
|
||||||
|
observation_key: crate::RawObservationKey,
|
||||||
|
provenance: crate::RawAcquisitionProvenance,
|
||||||
|
transaction: crate::RawTransactionReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransactionObservation {
|
||||||
|
/// Creates one successful observation of a complete canonical RAW transaction.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(observation_key: crate::RawObservationKey, transaction: crate::RawTransactionReference, provenance: crate::RawAcquisitionProvenance) -> Self {
|
||||||
|
return Self { observation_key, provenance, transaction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the deterministic producer-owned observation idempotence key.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn observation_key(&self) -> crate::RawObservationKey {
|
||||||
|
return self.observation_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns safe source-independent acquisition provenance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> &crate::RawAcquisitionProvenance {
|
||||||
|
return &self.provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the durable transaction identity observed by this acquisition.
|
||||||
|
#[must_use]
|
||||||
|
pub fn transaction(&self) -> &crate::RawTransactionReference {
|
||||||
|
return &self.transaction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../../unit_tests/model/raw_transaction.rs"]
|
||||||
|
mod tests;
|
||||||
152
crates/ksp-store-api/tests/dependency_boundary.rs
Normal file
152
crates/ksp-store-api/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// file: crates/ksp-store-api/tests/dependency_boundary.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
//! Dependency canaries for the Store API RAW foundation.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_manifest_keeps_exact_core_only_runtime_dependency() {
|
||||||
|
let manifest = include_str!("../Cargo.toml");
|
||||||
|
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||||
|
assert!(dependencies_tail.is_some(), "Store API dependencies section must exist");
|
||||||
|
let dependencies_tail = match dependencies_tail {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib"]);
|
||||||
|
for forbidden in [
|
||||||
|
"ksp-config-lib",
|
||||||
|
"ksp-interface-lib",
|
||||||
|
"ksp-logging-lib",
|
||||||
|
"ksp-materializer-api",
|
||||||
|
"ksp-offchain-transport-lib",
|
||||||
|
"ksp-onchain-transport-lib",
|
||||||
|
"ksp-program-api",
|
||||||
|
"ksp-program-lib",
|
||||||
|
"ksp-store-lib",
|
||||||
|
"ksp-store-postgres-lib",
|
||||||
|
"ksp-wallet-lib",
|
||||||
|
"async-trait",
|
||||||
|
"bincode",
|
||||||
|
"chrono",
|
||||||
|
"postgres",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tauri",
|
||||||
|
"tokio",
|
||||||
|
"tokio-postgres",
|
||||||
|
"tonic",
|
||||||
|
"tracing",
|
||||||
|
"wincode",
|
||||||
|
] {
|
||||||
|
assert!(!dependencies.contains(forbidden), "forbidden Store API dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_source_boundary_keeps_models_and_capabilities_backend_free() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
let model_home = include_str!("../src/model.rs");
|
||||||
|
let raw_account = include_str!("../src/model/raw_account.rs");
|
||||||
|
let raw_outcome = include_str!("../src/model/raw_outcome.rs");
|
||||||
|
let raw_pagination = include_str!("../src/model/raw_pagination.rs");
|
||||||
|
let raw_primitives = include_str!("../src/model/raw_primitives.rs");
|
||||||
|
let raw_retention = include_str!("../src/model/raw_retention.rs");
|
||||||
|
let raw_transaction = include_str!("../src/model/raw_transaction.rs");
|
||||||
|
let capability_home = include_str!("../src/capability.rs");
|
||||||
|
let raw_account_capability = include_str!("../src/capability/raw_account.rs");
|
||||||
|
let raw_retention_capability = include_str!("../src/capability/raw_retention.rs");
|
||||||
|
let raw_transaction_capability = include_str!("../src/capability/raw_transaction.rs");
|
||||||
|
assert!(crate_root.contains("mod capability;"));
|
||||||
|
assert!(crate_root.contains("mod error;"));
|
||||||
|
assert!(crate_root.contains("mod model;"));
|
||||||
|
assert!(model_home.contains("raw_account"));
|
||||||
|
assert!(model_home.contains("raw_outcome"));
|
||||||
|
assert!(model_home.contains("raw_pagination"));
|
||||||
|
assert!(model_home.contains("raw_primitives"));
|
||||||
|
assert!(model_home.contains("raw_retention"));
|
||||||
|
assert!(model_home.contains("raw_transaction"));
|
||||||
|
assert!(capability_home.contains("raw_account"));
|
||||||
|
assert!(capability_home.contains("raw_retention"));
|
||||||
|
assert!(capability_home.contains("raw_transaction"));
|
||||||
|
for source in [
|
||||||
|
crate_root,
|
||||||
|
model_home,
|
||||||
|
raw_account,
|
||||||
|
raw_outcome,
|
||||||
|
raw_pagination,
|
||||||
|
raw_primitives,
|
||||||
|
raw_retention,
|
||||||
|
raw_transaction,
|
||||||
|
capability_home,
|
||||||
|
raw_account_capability,
|
||||||
|
raw_retention_capability,
|
||||||
|
raw_transaction_capability,
|
||||||
|
] {
|
||||||
|
for forbidden in [
|
||||||
|
"ksp_store_lib",
|
||||||
|
"ksp_store_postgres_lib",
|
||||||
|
"ksp_onchain_transport_lib",
|
||||||
|
"ksp_program_api",
|
||||||
|
"serde::",
|
||||||
|
"sqlx::",
|
||||||
|
"tokio::",
|
||||||
|
"tokio_postgres::",
|
||||||
|
"std::env::",
|
||||||
|
"std::fs::",
|
||||||
|
"std::net::",
|
||||||
|
] {
|
||||||
|
assert!(!source.contains(forbidden), "forbidden Store API dependency/runtime path detected: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!raw_transaction.contains("RawLog"));
|
||||||
|
for forbidden in ["TransactionStatusObservation", "RawLogNotification", "RawSlotEvent", "RawVoteEvent", "RawBlock", "YellowstoneEntry"] {
|
||||||
|
assert!(!crate_root.contains(forbidden), "deferred pre.006 model leaked into Store API surface: {forbidden}");
|
||||||
|
}
|
||||||
|
assert!(raw_transaction_capability.contains("trait RawTransactionRead"));
|
||||||
|
assert!(raw_transaction_capability.contains("trait RawTransactionWrite"));
|
||||||
|
assert!(raw_transaction_capability.contains("trait RawTransactionObservationRead"));
|
||||||
|
assert!(raw_transaction_capability.contains("trait RawTransactionObservationWrite"));
|
||||||
|
assert!(raw_retention_capability.contains("trait RawTransactionRetentionRead"));
|
||||||
|
assert!(raw_retention_capability.contains("trait RawTransactionRetentionWrite"));
|
||||||
|
assert!(raw_account_capability.contains("trait RawAccountStateRead"));
|
||||||
|
assert!(raw_account_capability.contains("trait RawAccountStateWrite"));
|
||||||
|
assert!(raw_account_capability.contains("trait RawAccountObservationRead"));
|
||||||
|
assert!(raw_account_capability.contains("trait RawAccountObservationWrite"));
|
||||||
|
for forbidden in ["trait StoreBackend", "trait Store", "PostgresStore", "MySqlStore", "Arc<dyn"] {
|
||||||
|
assert!(!capability_home.contains(forbidden));
|
||||||
|
assert!(!raw_account_capability.contains(forbidden));
|
||||||
|
assert!(!raw_retention_capability.contains(forbidden));
|
||||||
|
assert!(!raw_transaction_capability.contains(forbidden));
|
||||||
|
}
|
||||||
|
assert!(!raw_pagination.contains("u64::MAX"));
|
||||||
|
assert!(!raw_pagination.contains("MAX_RAW_PAGE_ITEMS"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for line in section.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 name = match content.split('=').next() {
|
||||||
|
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !name.is_empty() {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
194
crates/ksp-store-api/tests/external_backend.rs
Normal file
194
crates/ksp-store-api/tests/external_backend.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// file: crates/ksp-store-api/tests/external_backend.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! External-implementation canary for object-safe Store API capabilities.
|
||||||
|
|
||||||
|
struct ExternalMemoryBackend;
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRead for ExternalMemoryBackend {
|
||||||
|
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 _ = reference;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(std::option::Option::None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _ = query;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawPage::new(std::vec::Vec::new(), std::option::Option::None));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionWrite for ExternalMemoryBackend {
|
||||||
|
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;
|
||||||
|
let _ = observation;
|
||||||
|
let _ = mode;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawAcquisitionWriteOutcome::new(
|
||||||
|
ksp_store_api::RawEntityWriteOutcome::Inserted,
|
||||||
|
ksp_store_api::RawObservationWriteOutcome::Inserted,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionObservationRead for ExternalMemoryBackend {
|
||||||
|
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>>> {
|
||||||
|
let _ = observation_key;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(std::option::Option::None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionObservationWrite for ExternalMemoryBackend {
|
||||||
|
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 _ = observation;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawAccountStateRead for ExternalMemoryBackend {
|
||||||
|
fn get_raw_account_state<'a>(
|
||||||
|
&'a self,
|
||||||
|
reference: &'a ksp_store_api::RawAccountStateReference,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawAccountState>>> {
|
||||||
|
let _ = reference;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(std::option::Option::None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_raw_account_states<'a>(
|
||||||
|
&'a self,
|
||||||
|
query: &'a ksp_store_api::RawAccountStateQuery,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawPage<ksp_store_api::RawAccountStateReference>>> {
|
||||||
|
let _ = query;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawPage::new(std::vec::Vec::new(), std::option::Option::None));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawAccountStateWrite for ExternalMemoryBackend {
|
||||||
|
fn persist_raw_account_acquisition<'a>(
|
||||||
|
&'a self,
|
||||||
|
state: ksp_store_api::RawAccountState,
|
||||||
|
observation: ksp_store_api::RawAccountObservation,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawAcquisitionWriteOutcome>> {
|
||||||
|
let _ = state;
|
||||||
|
let _ = observation;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawAcquisitionWriteOutcome::new(
|
||||||
|
ksp_store_api::RawEntityWriteOutcome::Inserted,
|
||||||
|
ksp_store_api::RawObservationWriteOutcome::Inserted,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawAccountObservationRead for ExternalMemoryBackend {
|
||||||
|
fn get_raw_account_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::RawAccountObservation>>> {
|
||||||
|
let _ = observation_key;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(std::option::Option::None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawAccountObservationWrite for ExternalMemoryBackend {
|
||||||
|
fn record_raw_account_observation<'a>(
|
||||||
|
&'a self,
|
||||||
|
observation: ksp_store_api::RawAccountObservation,
|
||||||
|
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawObservationWriteOutcome>> {
|
||||||
|
let _ = observation;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRetentionRead for ExternalMemoryBackend {
|
||||||
|
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 _ = reference;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(std::option::Option::None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _ = reference;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(std::option::Option::None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ksp_store_api::RawTransactionRetentionWrite for ExternalMemoryBackend {
|
||||||
|
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 _ = transition;
|
||||||
|
return std::boxed::Box::pin(async {
|
||||||
|
return std::result::Result::Ok(ksp_store_api::RawRetentionWriteOutcome::Applied);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_external_backend_implements_each_capability_without_store_runtime_crate() {
|
||||||
|
let backend = ExternalMemoryBackend;
|
||||||
|
let transaction_read: &dyn ksp_store_api::RawTransactionRead = &backend;
|
||||||
|
let transaction_write: &dyn ksp_store_api::RawTransactionWrite = &backend;
|
||||||
|
let transaction_observation_read: &dyn ksp_store_api::RawTransactionObservationRead = &backend;
|
||||||
|
let transaction_observation_write: &dyn ksp_store_api::RawTransactionObservationWrite = &backend;
|
||||||
|
let account_read: &dyn ksp_store_api::RawAccountStateRead = &backend;
|
||||||
|
let account_write: &dyn ksp_store_api::RawAccountStateWrite = &backend;
|
||||||
|
let account_observation_read: &dyn ksp_store_api::RawAccountObservationRead = &backend;
|
||||||
|
let account_observation_write: &dyn ksp_store_api::RawAccountObservationWrite = &backend;
|
||||||
|
let retention_read: &dyn ksp_store_api::RawTransactionRetentionRead = &backend;
|
||||||
|
let retention_write: &dyn ksp_store_api::RawTransactionRetentionWrite = &backend;
|
||||||
|
let _ = transaction_read;
|
||||||
|
let _ = transaction_write;
|
||||||
|
let _ = transaction_observation_read;
|
||||||
|
let _ = transaction_observation_write;
|
||||||
|
let _ = account_read;
|
||||||
|
let _ = account_write;
|
||||||
|
let _ = account_observation_read;
|
||||||
|
let _ = account_observation_write;
|
||||||
|
let _ = retention_read;
|
||||||
|
let _ = retention_write;
|
||||||
|
return;
|
||||||
|
}
|
||||||
188
crates/ksp-store-api/tests/public_api.rs
Normal file
188
crates/ksp-store-api/tests/public_api.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// file: crates/ksp-store-api/tests/public_api.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
//! Integration canaries for the public `ksp-store-api` surface.
|
||||||
|
|
||||||
|
fn consume_result(value: ksp_store_api::Result<ksp_store_api::Pubkey>) -> ksp_store_api::Result<ksp_store_api::Pubkey> {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code(value: &str) -> std::option::Option<ksp_store_api::RawProvenanceCode> {
|
||||||
|
return match ksp_store_api::RawProvenanceCode::new(value.to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_002_core_facade_remains_available_from_crate_root() {
|
||||||
|
let pubkey = ksp_store_api::Pubkey::new_from_array([0x31_u8; 32]);
|
||||||
|
let forwarded = consume_result(std::result::Result::Ok(pubkey));
|
||||||
|
assert!(forwarded.is_ok());
|
||||||
|
let error_code_type: std::option::Option<ksp_store_api::ErrorCode> = std::option::Option::None;
|
||||||
|
let error_context_type: std::option::Option<ksp_store_api::ErrorContext> = std::option::Option::None;
|
||||||
|
let error_type: std::option::Option<ksp_store_api::Error> = std::option::Option::None;
|
||||||
|
assert!(error_code_type.is_none());
|
||||||
|
assert!(error_context_type.is_none());
|
||||||
|
assert!(error_type.is_none());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_003_raw_transaction_and_observation_are_constructible_from_crate_root() {
|
||||||
|
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let reference = ksp_store_api::RawTransactionReference::new(network, ksp_store_api::RawTransactionSignature::new([1_u8; 64]));
|
||||||
|
let format = match ksp_store_api::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let payload = match ksp_store_api::RawPayload::try_new(format, 1, vec![1_u8, 2_u8, 3_u8].into_boxed_slice(), ksp_store_api::RawContentHash::new([2_u8; 32]))
|
||||||
|
{
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let transaction = ksp_store_api::RawTransaction::new(reference.clone(), 123, std::option::Option::None, payload);
|
||||||
|
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1_000) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let provider = match code("provider") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let protocol = match code("solana_http") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let method = match code("getTransaction") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let provenance = ksp_store_api::RawAcquisitionProvenance::new(provider, protocol, method, ksp_store_api::RawAcquisitionOrigin::Backfill, received_at);
|
||||||
|
let observation = ksp_store_api::RawTransactionObservation::new(ksp_store_api::RawObservationKey::new([3_u8; 32]), reference, provenance);
|
||||||
|
assert_eq!(transaction.slot(), 123);
|
||||||
|
assert_eq!(transaction.payload().bytes(), &[1_u8, 2_u8, 3_u8]);
|
||||||
|
assert_eq!(observation.transaction().network().as_str(), "mainnet-beta");
|
||||||
|
assert_eq!(observation.provenance().acquisition_method().as_str(), "getTransaction");
|
||||||
|
assert_eq!(ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID.domain(), "store_api");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_003_surface_keeps_backend_and_structural_types_out() {
|
||||||
|
let source = include_str!("../src/lib.rs");
|
||||||
|
assert!(!source.contains("pub mod "));
|
||||||
|
for forbidden in ["Postgres", "Sql", "Migration", "StructuralTransaction", "RawLog", "ProgramInstruction"] {
|
||||||
|
assert!(!source.contains(forbidden), "forbidden pre.003 Store API public concept detected: {forbidden}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_004_raw_account_state_and_observation_are_constructible_from_crate_root() {
|
||||||
|
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let reference = ksp_store_api::RawAccountStateReference::new(
|
||||||
|
network,
|
||||||
|
ksp_store_api::Pubkey::new_from_array([21_u8; 32]),
|
||||||
|
55,
|
||||||
|
ksp_store_api::RawContentHash::new([22_u8; 32]),
|
||||||
|
);
|
||||||
|
let state = ksp_store_api::RawAccountState::try_new(
|
||||||
|
reference.clone(),
|
||||||
|
123,
|
||||||
|
ksp_store_api::Pubkey::new_from_array([23_u8; 32]),
|
||||||
|
false,
|
||||||
|
9,
|
||||||
|
vec![1_u8, 2_u8].into_boxed_slice(),
|
||||||
|
);
|
||||||
|
assert!(state.is_ok());
|
||||||
|
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(2_000) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let provider = match code("provider") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let protocol = match code("solana_http") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let method = match code("getAccountInfo") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let provenance = ksp_store_api::RawAcquisitionProvenance::new(provider, protocol, method, ksp_store_api::RawAcquisitionOrigin::Backfill, received_at);
|
||||||
|
let observation = ksp_store_api::RawAccountObservation::new(ksp_store_api::RawObservationKey::new([24_u8; 32]), reference, provenance);
|
||||||
|
assert_eq!(observation.account().slot(), 55);
|
||||||
|
assert!(observation.write_version().is_none());
|
||||||
|
assert_eq!(ksp_store_api::MAX_RAW_ACCOUNT_DATA_BYTES, 16 * 1024 * 1024);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_005_capabilities_are_available_from_crate_root_and_dyn_compatible() {
|
||||||
|
let transaction_read: std::option::Option<&dyn ksp_store_api::RawTransactionRead> = std::option::Option::None;
|
||||||
|
let transaction_write: std::option::Option<&dyn ksp_store_api::RawTransactionWrite> = std::option::Option::None;
|
||||||
|
let transaction_observation_read: std::option::Option<&dyn ksp_store_api::RawTransactionObservationRead> = std::option::Option::None;
|
||||||
|
let transaction_observation_write: std::option::Option<&dyn ksp_store_api::RawTransactionObservationWrite> = std::option::Option::None;
|
||||||
|
let account_read: std::option::Option<&dyn ksp_store_api::RawAccountStateRead> = std::option::Option::None;
|
||||||
|
let account_write: std::option::Option<&dyn ksp_store_api::RawAccountStateWrite> = std::option::Option::None;
|
||||||
|
let account_observation_read: std::option::Option<&dyn ksp_store_api::RawAccountObservationRead> = std::option::Option::None;
|
||||||
|
let account_observation_write: std::option::Option<&dyn ksp_store_api::RawAccountObservationWrite> = std::option::Option::None;
|
||||||
|
assert!(transaction_read.is_none());
|
||||||
|
assert!(transaction_write.is_none());
|
||||||
|
assert!(transaction_observation_read.is_none());
|
||||||
|
assert!(transaction_observation_write.is_none());
|
||||||
|
assert!(account_read.is_none());
|
||||||
|
assert!(account_write.is_none());
|
||||||
|
assert!(account_observation_read.is_none());
|
||||||
|
assert!(account_observation_write.is_none());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_006_query_outcome_and_retention_contracts_are_available_from_crate_root() {
|
||||||
|
let limit = match ksp_store_api::RawPageLimit::new(5_000_000) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(limit.get(), 5_000_000);
|
||||||
|
assert!(ksp_store_api::RawPageLimit::new(u64::MAX).is_ok());
|
||||||
|
let slots = match ksp_store_api::RawSlotRange::new(std::option::Option::Some(1), std::option::Option::Some(2)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let query =
|
||||||
|
ksp_store_api::RawTransactionQuery::new(network, slots, ksp_store_api::RawSortDirection::Ascending, ksp_store_api::RawPageRequest::first(limit));
|
||||||
|
assert_eq!(query.page().limit().get(), 5_000_000);
|
||||||
|
let outcome =
|
||||||
|
ksp_store_api::RawAcquisitionWriteOutcome::new(ksp_store_api::RawEntityWriteOutcome::Inserted, ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||||
|
assert_eq!(outcome.entity(), ksp_store_api::RawEntityWriteOutcome::Inserted);
|
||||||
|
assert_eq!(outcome.observation(), ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||||
|
let retention_read: std::option::Option<&dyn ksp_store_api::RawTransactionRetentionRead> = std::option::Option::None;
|
||||||
|
let retention_write: std::option::Option<&dyn ksp_store_api::RawTransactionRetentionWrite> = std::option::Option::None;
|
||||||
|
assert!(retention_read.is_none());
|
||||||
|
assert!(retention_write.is_none());
|
||||||
|
assert_eq!(ksp_store_api::ERROR_CODE_RAW_CONFLICT.domain(), "store_api");
|
||||||
|
assert_eq!(ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID.domain(), "store_api");
|
||||||
|
assert_eq!(ksp_store_api::ERROR_CODE_RAW_RETENTION_INVALID.domain(), "store_api");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_pre_007_retention_race_outcome_is_available_from_crate_root() {
|
||||||
|
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::Applied, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||||
|
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::AlreadyAtTarget, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
256
crates/ksp-store-api/tests/release_completeness.rs
Normal file
256
crates/ksp-store-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// file: crates/ksp-store-api/tests/release_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Release-level boundary and completeness canaries for the `0.3.1` Store API RAW surface.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_exact_crate_root_export_inventory_is_stable() {
|
||||||
|
let crate_root = include_str!("../src/lib.rs");
|
||||||
|
let mut actual = std::vec::Vec::new();
|
||||||
|
for line in crate_root.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("pub use ") {
|
||||||
|
actual.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actual.sort_unstable();
|
||||||
|
let mut expected = std::vec![
|
||||||
|
"pub use ksp_core_lib::Error;",
|
||||||
|
"pub use ksp_core_lib::ErrorCode;",
|
||||||
|
"pub use ksp_core_lib::ErrorContext;",
|
||||||
|
"pub use ksp_core_lib::Pubkey;",
|
||||||
|
"pub use ksp_core_lib::Result;",
|
||||||
|
"pub use self::capability::StoreApiFuture;",
|
||||||
|
"pub use self::capability::raw_account::RawAccountObservationRead;",
|
||||||
|
"pub use self::capability::raw_account::RawAccountObservationWrite;",
|
||||||
|
"pub use self::capability::raw_account::RawAccountStateRead;",
|
||||||
|
"pub use self::capability::raw_account::RawAccountStateWrite;",
|
||||||
|
"pub use self::capability::raw_retention::RawTransactionRetentionRead;",
|
||||||
|
"pub use self::capability::raw_retention::RawTransactionRetentionWrite;",
|
||||||
|
"pub use self::capability::raw_transaction::RawTransactionObservationRead;",
|
||||||
|
"pub use self::capability::raw_transaction::RawTransactionObservationWrite;",
|
||||||
|
"pub use self::capability::raw_transaction::RawTransactionRead;",
|
||||||
|
"pub use self::capability::raw_transaction::RawTransactionWrite;",
|
||||||
|
"pub use self::error::ERROR_CODE_RAW_CONFLICT;",
|
||||||
|
"pub use self::error::ERROR_CODE_RAW_MODEL_INVALID;",
|
||||||
|
"pub use self::error::ERROR_CODE_RAW_PAYLOAD_INVALID;",
|
||||||
|
"pub use self::error::ERROR_CODE_RAW_PROVENANCE_INVALID;",
|
||||||
|
"pub use self::error::ERROR_CODE_RAW_QUERY_INVALID;",
|
||||||
|
"pub use self::error::ERROR_CODE_RAW_RETENTION_INVALID;",
|
||||||
|
"pub use self::model::raw_account::RawAccountObservation;",
|
||||||
|
"pub use self::model::raw_account::RawAccountState;",
|
||||||
|
"pub use self::model::raw_account::RawAccountStateReference;",
|
||||||
|
"pub use self::model::raw_outcome::RawAcquisitionWriteOutcome;",
|
||||||
|
"pub use self::model::raw_outcome::RawEntityWriteOutcome;",
|
||||||
|
"pub use self::model::raw_outcome::RawObservationWriteOutcome;",
|
||||||
|
"pub use self::model::raw_pagination::MAX_RAW_PAGE_CURSOR_BYTES;",
|
||||||
|
"pub use self::model::raw_pagination::RawAccountStateQuery;",
|
||||||
|
"pub use self::model::raw_pagination::RawPage;",
|
||||||
|
"pub use self::model::raw_pagination::RawPageCursor;",
|
||||||
|
"pub use self::model::raw_pagination::RawPageLimit;",
|
||||||
|
"pub use self::model::raw_pagination::RawPageRequest;",
|
||||||
|
"pub use self::model::raw_pagination::RawSlotRange;",
|
||||||
|
"pub use self::model::raw_pagination::RawSortDirection;",
|
||||||
|
"pub use self::model::raw_pagination::RawTransactionQuery;",
|
||||||
|
"pub use self::model::raw_primitives::MAX_RAW_ACCOUNT_DATA_BYTES;",
|
||||||
|
"pub use self::model::raw_primitives::MAX_RAW_CODE_BYTES;",
|
||||||
|
"pub use self::model::raw_primitives::MAX_RAW_PAYLOAD_BYTES;",
|
||||||
|
"pub use self::model::raw_primitives::MAX_RAW_SOURCE_PAYLOAD_BYTES;",
|
||||||
|
"pub use self::model::raw_primitives::MAX_RAW_UNIX_MILLIS;",
|
||||||
|
"pub use self::model::raw_primitives::RawAcquisitionOrigin;",
|
||||||
|
"pub use self::model::raw_primitives::RawAcquisitionProvenance;",
|
||||||
|
"pub use self::model::raw_primitives::RawContentHash;",
|
||||||
|
"pub use self::model::raw_primitives::RawFormatId;",
|
||||||
|
"pub use self::model::raw_primitives::RawNetworkId;",
|
||||||
|
"pub use self::model::raw_primitives::RawObservationKey;",
|
||||||
|
"pub use self::model::raw_primitives::RawPayload;",
|
||||||
|
"pub use self::model::raw_primitives::RawProvenanceCode;",
|
||||||
|
"pub use self::model::raw_primitives::RawTimestamp;",
|
||||||
|
"pub use self::model::raw_primitives::RawTransactionSignature;",
|
||||||
|
"pub use self::model::raw_retention::RawRetentionState;",
|
||||||
|
"pub use self::model::raw_retention::RawRetentionWriteOutcome;",
|
||||||
|
"pub use self::model::raw_retention::RawTransactionAcquisitionMode;",
|
||||||
|
"pub use self::model::raw_retention::RawTransactionRetentionTransition;",
|
||||||
|
"pub use self::model::raw_retention::RawTransactionTombstone;",
|
||||||
|
"pub use self::model::raw_transaction::RawTransaction;",
|
||||||
|
"pub use self::model::raw_transaction::RawTransactionObservation;",
|
||||||
|
"pub use self::model::raw_transaction::RawTransactionReference;",
|
||||||
|
];
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(actual, expected);
|
||||||
|
assert!(!crate_root.contains("pub mod "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_exact_production_module_inventory_is_raw_only() {
|
||||||
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
let root_names = rust_file_names(root.as_path());
|
||||||
|
assert!(root_names.is_ok());
|
||||||
|
let root_names = match root_names {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(root_names, std::vec!["capability.rs", "error.rs", "lib.rs", "model.rs"]);
|
||||||
|
let model_names = rust_file_names(root.join("model").as_path());
|
||||||
|
assert!(model_names.is_ok());
|
||||||
|
let model_names = match model_names {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
model_names,
|
||||||
|
std::vec!["raw_account.rs", "raw_outcome.rs", "raw_pagination.rs", "raw_primitives.rs", "raw_retention.rs", "raw_transaction.rs"]
|
||||||
|
);
|
||||||
|
let capability_names = rust_file_names(root.join("capability").as_path());
|
||||||
|
assert!(capability_names.is_ok());
|
||||||
|
let capability_names = match capability_names {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(capability_names, std::vec!["raw_account.rs", "raw_retention.rs", "raw_transaction.rs"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_public_evolutive_enums_remain_non_exhaustive() {
|
||||||
|
let sources = [
|
||||||
|
include_str!("../src/model/raw_outcome.rs"),
|
||||||
|
include_str!("../src/model/raw_pagination.rs"),
|
||||||
|
include_str!("../src/model/raw_primitives.rs"),
|
||||||
|
include_str!("../src/model/raw_retention.rs"),
|
||||||
|
];
|
||||||
|
for enum_name in [
|
||||||
|
"RawAcquisitionOrigin",
|
||||||
|
"RawEntityWriteOutcome",
|
||||||
|
"RawObservationWriteOutcome",
|
||||||
|
"RawRetentionState",
|
||||||
|
"RawRetentionWriteOutcome",
|
||||||
|
"RawSortDirection",
|
||||||
|
"RawTransactionAcquisitionMode",
|
||||||
|
] {
|
||||||
|
assert_non_exhaustive(&sources, enum_name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_interface_store_ownership_and_negative_scope_remain_explicit() {
|
||||||
|
let interface_root = include_str!("../../ksp-interface-lib/src/lib.rs");
|
||||||
|
let store_sources = [
|
||||||
|
include_str!("../src/lib.rs"),
|
||||||
|
include_str!("../src/model.rs"),
|
||||||
|
include_str!("../src/model/raw_account.rs"),
|
||||||
|
include_str!("../src/model/raw_outcome.rs"),
|
||||||
|
include_str!("../src/model/raw_pagination.rs"),
|
||||||
|
include_str!("../src/model/raw_primitives.rs"),
|
||||||
|
include_str!("../src/model/raw_retention.rs"),
|
||||||
|
include_str!("../src/model/raw_transaction.rs"),
|
||||||
|
include_str!("../src/capability.rs"),
|
||||||
|
include_str!("../src/capability/raw_account.rs"),
|
||||||
|
include_str!("../src/capability/raw_retention.rs"),
|
||||||
|
include_str!("../src/capability/raw_transaction.rs"),
|
||||||
|
];
|
||||||
|
for persistent_name in ["RawTransaction", "RawAccountState", "RawRetentionState", "RawTransactionTombstone"] {
|
||||||
|
assert!(!interface_root.contains(persistent_name), "persistent Store model leaked into Interface crate root: {persistent_name}");
|
||||||
|
}
|
||||||
|
for source in store_sources {
|
||||||
|
for forbidden in [
|
||||||
|
"RawLogNotification",
|
||||||
|
"RawSlotEvent",
|
||||||
|
"RawVoteEvent",
|
||||||
|
"YellowstoneEntry",
|
||||||
|
"StructuralInstruction",
|
||||||
|
"StructuralTransaction",
|
||||||
|
"DecodedInstruction",
|
||||||
|
"DomainRecord",
|
||||||
|
"ProgramInstructionDecoder",
|
||||||
|
"PostgresStore",
|
||||||
|
"MySqlStore",
|
||||||
|
"StoreBackend",
|
||||||
|
] {
|
||||||
|
assert!(!source.contains(forbidden), "deferred or backend-specific Store surface detected: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_capability_inventory_stays_fine_grained_without_runtime_facade() {
|
||||||
|
let sources = [
|
||||||
|
include_str!("../src/capability/raw_account.rs"),
|
||||||
|
include_str!("../src/capability/raw_retention.rs"),
|
||||||
|
include_str!("../src/capability/raw_transaction.rs"),
|
||||||
|
];
|
||||||
|
let mut traits = std::vec::Vec::new();
|
||||||
|
for source in sources {
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("pub trait ") {
|
||||||
|
traits.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traits.sort_unstable();
|
||||||
|
let mut expected = std::vec![
|
||||||
|
"pub trait RawAccountObservationRead: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawAccountObservationWrite: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawAccountStateRead: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawAccountStateWrite: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawTransactionObservationRead: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawTransactionObservationWrite: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawTransactionRead: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawTransactionRetentionRead: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawTransactionRetentionWrite: std::marker::Send + std::marker::Sync {",
|
||||||
|
"pub trait RawTransactionWrite: std::marker::Send + std::marker::Sync {",
|
||||||
|
];
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(traits, expected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_non_exhaustive(sources: &[&str], enum_name: &str) {
|
||||||
|
let needle = "#[non_exhaustive]\n";
|
||||||
|
let declaration = std::format!("pub enum {enum_name}");
|
||||||
|
let mut found = false;
|
||||||
|
for source in sources {
|
||||||
|
if let std::option::Option::Some(position) = source.find(declaration.as_str()) {
|
||||||
|
let prefix = &source[..position];
|
||||||
|
let attribute_position = prefix.rfind(needle);
|
||||||
|
assert!(attribute_position.is_some(), "public evolutive enum is missing #[non_exhaustive]: {enum_name}");
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(found, "public evolutive enum not found: {enum_name}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_file_names(directory: &std::path::Path) -> std::io::Result<std::vec::Vec<std::string::String>> {
|
||||||
|
let entries = match std::fs::read_dir(directory) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut names = std::vec::Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let file_type = match entry.file_type() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match entry.file_name().into_string() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if name.ends_with(".rs") {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
return std::result::Result::Ok(names);
|
||||||
|
}
|
||||||
124
crates/ksp-store-api/tests/security_hardening.rs
Normal file
124
crates/ksp-store-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// file: crates/ksp-store-api/tests/security_hardening.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Adversarial and retention-race canaries for the Store API RAW foundation.
|
||||||
|
|
||||||
|
const HOSTILE_MARKER: &str = "STORE-RAW-SECRET-CANARY";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_hostile_model_query_and_provenance_errors_never_echo_input_material() {
|
||||||
|
let hostile_code = std::format!("https://example.invalid/{HOSTILE_MARKER}");
|
||||||
|
let network = ksp_store_api::RawNetworkId::new(hostile_code.clone());
|
||||||
|
assert!(network.is_err());
|
||||||
|
let network_error = match network {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(network_error.code(), ksp_store_api::ERROR_CODE_RAW_MODEL_INVALID);
|
||||||
|
assert!(!std::format!("{network_error}").contains(HOSTILE_MARKER));
|
||||||
|
assert!(!std::format!("{network_error:?}").contains(HOSTILE_MARKER));
|
||||||
|
let provenance = ksp_store_api::RawProvenanceCode::new(hostile_code.clone());
|
||||||
|
assert!(provenance.is_err());
|
||||||
|
let provenance_error = match provenance {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(provenance_error.code(), ksp_store_api::ERROR_CODE_RAW_PROVENANCE_INVALID);
|
||||||
|
assert!(!std::format!("{provenance_error}").contains(HOSTILE_MARKER));
|
||||||
|
assert!(!std::format!("{provenance_error:?}").contains(HOSTILE_MARKER));
|
||||||
|
let format = ksp_store_api::RawFormatId::new(hostile_code);
|
||||||
|
assert!(format.is_err());
|
||||||
|
let format_error = match format {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(format_error.code(), ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID);
|
||||||
|
assert!(!std::format!("{format_error}").contains(HOSTILE_MARKER));
|
||||||
|
assert!(!std::format!("{format_error:?}").contains(HOSTILE_MARKER));
|
||||||
|
let cursor = ksp_store_api::RawPageCursor::try_new(std::vec![0x41_u8; ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES + 1].into_boxed_slice());
|
||||||
|
assert!(cursor.is_err());
|
||||||
|
let cursor_error = match cursor {
|
||||||
|
std::result::Result::Err(value) => value,
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(cursor_error.code(), ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID);
|
||||||
|
assert!(!std::format!("{cursor_error}").contains(HOSTILE_MARKER));
|
||||||
|
assert!(!std::format!("{cursor_error:?}").contains(HOSTILE_MARKER));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_raw_debug_surfaces_do_not_render_payload_hash_signature_or_account_bytes() {
|
||||||
|
let marker_bytes = HOSTILE_MARKER.as_bytes().to_vec().into_boxed_slice();
|
||||||
|
let format = match ksp_store_api::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let payload = ksp_store_api::RawPayload::try_new(format, 1, marker_bytes, ksp_store_api::RawContentHash::new([0x53_u8; 32]));
|
||||||
|
assert!(payload.is_ok());
|
||||||
|
let payload = match payload {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(!std::format!("{payload:?}").contains(HOSTILE_MARKER));
|
||||||
|
assert_eq!(std::format!("{:?}", payload.content_hash()), "RawContentHash(..)");
|
||||||
|
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let signature = ksp_store_api::RawTransactionSignature::new([0x54_u8; 64]);
|
||||||
|
assert_eq!(std::format!("{signature:?}"), "RawTransactionSignature(..)");
|
||||||
|
let reference = ksp_store_api::RawTransactionReference::new(network.clone(), signature);
|
||||||
|
let transaction = ksp_store_api::RawTransaction::new(reference, 7, std::option::Option::None, payload);
|
||||||
|
assert!(!std::format!("{transaction:?}").contains(HOSTILE_MARKER));
|
||||||
|
let account_reference = ksp_store_api::RawAccountStateReference::new(
|
||||||
|
network,
|
||||||
|
ksp_store_api::Pubkey::new_from_array([0x55_u8; 32]),
|
||||||
|
8,
|
||||||
|
ksp_store_api::RawContentHash::new([0x56_u8; 32]),
|
||||||
|
);
|
||||||
|
let account = ksp_store_api::RawAccountState::try_new(
|
||||||
|
account_reference,
|
||||||
|
1,
|
||||||
|
ksp_store_api::Pubkey::new_from_array([0x57_u8; 32]),
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
HOSTILE_MARKER.as_bytes().to_vec().into_boxed_slice(),
|
||||||
|
);
|
||||||
|
assert!(account.is_ok());
|
||||||
|
let account = match account {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(!std::format!("{account:?}").contains(HOSTILE_MARKER));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_cursor_boundary_and_page_limit_preserve_security_without_executor_policy() {
|
||||||
|
let cursor = ksp_store_api::RawPageCursor::try_new(std::vec![0x5A_u8; ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES].into_boxed_slice());
|
||||||
|
assert!(cursor.is_ok());
|
||||||
|
let cursor = match cursor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(cursor.as_bytes().len(), ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES);
|
||||||
|
assert!(!std::format!("{cursor:?}").contains("ZZZZ"));
|
||||||
|
assert!(ksp_store_api::RawPageCursor::try_new(std::vec![0x5A_u8; ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES + 1].into_boxed_slice()).is_err());
|
||||||
|
assert!(ksp_store_api::RawPageLimit::new(0).is_err());
|
||||||
|
let unlimited_policy = ksp_store_api::RawPageLimit::new(u64::MAX);
|
||||||
|
assert!(unlimited_policy.is_ok());
|
||||||
|
let unlimited_policy = match unlimited_policy {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(unlimited_policy.get(), u64::MAX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_007_retention_outcome_distinguishes_lost_compare_and_transition_race() {
|
||||||
|
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::Applied, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||||
|
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::AlreadyAtTarget, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
108
crates/ksp-store-api/unit_tests/model/raw_account.rs
Normal file
108
crates/ksp-store-api/unit_tests/model/raw_account.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// file: crates/ksp-store-api/unit_tests/model/raw_account.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||||
|
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provenance() -> std::option::Option<crate::RawAcquisitionProvenance> {
|
||||||
|
let provider = match crate::RawProvenanceCode::new("publicnode".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let protocol = match crate::RawProvenanceCode::new("yellowstone_grpc".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let method = match crate::RawProvenanceCode::new("accounts".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let received_at = match crate::RawTimestamp::from_unix_millis(1_000) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(crate::RawAcquisitionProvenance::new(provider, protocol, method, crate::RawAcquisitionOrigin::Live, received_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_account_state_preserves_complete_common_fields_and_redacts_data_debug() {
|
||||||
|
let network = match network() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let pubkey = ksp_core_lib::Pubkey::new_from_array([1_u8; 32]);
|
||||||
|
let owner = ksp_core_lib::Pubkey::new_from_array([2_u8; 32]);
|
||||||
|
let reference = crate::RawAccountStateReference::new(network, pubkey, 42, crate::RawContentHash::new([3_u8; 32]));
|
||||||
|
let data = b"ACCOUNT_DATA_SENTINEL_NEVER_RENDER".to_vec().into_boxed_slice();
|
||||||
|
let state_result = crate::RawAccountState::try_new(reference.clone(), 500, owner, false, 7, data);
|
||||||
|
assert!(state_result.is_ok());
|
||||||
|
let state = match state_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(state.reference(), &reference);
|
||||||
|
assert_eq!(state.lamports(), 500);
|
||||||
|
assert!(!state.executable());
|
||||||
|
assert_eq!(state.rent_epoch(), 7);
|
||||||
|
assert_eq!(state.data(), b"ACCOUNT_DATA_SENTINEL_NEVER_RENDER");
|
||||||
|
let debug = format!("{state:?}");
|
||||||
|
assert!(!debug.contains("ACCOUNT_DATA_SENTINEL_NEVER_RENDER"));
|
||||||
|
assert!(debug.contains("data_len"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_account_state_rejects_only_oversized_data_and_allows_empty_accounts() {
|
||||||
|
let first_network = match network() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let first_reference =
|
||||||
|
crate::RawAccountStateReference::new(first_network, ksp_core_lib::Pubkey::new_from_array([4_u8; 32]), 1, crate::RawContentHash::new([5_u8; 32]));
|
||||||
|
let empty = crate::RawAccountState::try_new(
|
||||||
|
first_reference,
|
||||||
|
0,
|
||||||
|
ksp_core_lib::Pubkey::new_from_array([6_u8; 32]),
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
std::vec::Vec::new().into_boxed_slice(),
|
||||||
|
);
|
||||||
|
assert!(empty.is_ok());
|
||||||
|
let second_network = match network() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let second_reference =
|
||||||
|
crate::RawAccountStateReference::new(second_network, ksp_core_lib::Pubkey::new_from_array([7_u8; 32]), 2, crate::RawContentHash::new([8_u8; 32]));
|
||||||
|
let oversized = vec![0_u8; crate::MAX_RAW_ACCOUNT_DATA_BYTES + 1].into_boxed_slice();
|
||||||
|
let rejected = crate::RawAccountState::try_new(second_reference, 0, ksp_core_lib::Pubkey::new_from_array([9_u8; 32]), false, 0, oversized);
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_account_observation_keeps_yellowstone_specific_metadata_optional() {
|
||||||
|
let network = match network() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let reference =
|
||||||
|
crate::RawAccountStateReference::new(network, ksp_core_lib::Pubkey::new_from_array([10_u8; 32]), 99, crate::RawContentHash::new([11_u8; 32]));
|
||||||
|
let provenance = match provenance() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let observation = crate::RawAccountObservation::new(crate::RawObservationKey::new([12_u8; 32]), reference.clone(), provenance)
|
||||||
|
.with_write_version(17)
|
||||||
|
.with_transaction_signature(crate::RawTransactionSignature::new([13_u8; 64]))
|
||||||
|
.with_is_startup(false);
|
||||||
|
assert_eq!(observation.account(), &reference);
|
||||||
|
assert_eq!(observation.write_version(), std::option::Option::Some(17));
|
||||||
|
assert_eq!(observation.is_startup(), std::option::Option::Some(false));
|
||||||
|
assert_eq!(observation.transaction_signature(), std::option::Option::Some(crate::RawTransactionSignature::new([13_u8; 64])));
|
||||||
|
return;
|
||||||
|
}
|
||||||
54
crates/ksp-store-api/unit_tests/model/raw_pagination.rs
Normal file
54
crates/ksp-store-api/unit_tests/model/raw_pagination.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// file: crates/ksp-store-api/unit_tests/model/raw_pagination.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_limit_rejects_only_zero_and_keeps_no_ksp_policy_maximum() {
|
||||||
|
assert!(crate::RawPageLimit::new(0).is_err());
|
||||||
|
let maximum = crate::RawPageLimit::new(u64::MAX);
|
||||||
|
assert!(maximum.is_ok());
|
||||||
|
let maximum = match maximum {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(maximum.get(), u64::MAX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_is_opaque_bounded_and_debug_does_not_render_bytes() {
|
||||||
|
assert!(crate::RawPageCursor::try_new(std::boxed::Box::new([])).is_err());
|
||||||
|
assert!(crate::RawPageCursor::try_new(vec![7_u8; crate::MAX_RAW_PAGE_CURSOR_BYTES + 1].into_boxed_slice()).is_err());
|
||||||
|
let cursor = crate::RawPageCursor::try_new(vec![0xAB_u8; 8].into_boxed_slice());
|
||||||
|
assert!(cursor.is_ok());
|
||||||
|
let cursor = match cursor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let debug = format!("{cursor:?}");
|
||||||
|
assert!(debug.contains("len"));
|
||||||
|
assert!(!debug.contains("171"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_query_preserves_caller_limit_range_direction_and_network() {
|
||||||
|
let network = match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let slots = match crate::RawSlotRange::new(std::option::Option::Some(10), std::option::Option::Some(20)) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let limit = match crate::RawPageLimit::new(5_000_000) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let query = crate::RawTransactionQuery::new(network, slots, crate::RawSortDirection::Ascending, crate::RawPageRequest::first(limit));
|
||||||
|
assert_eq!(query.network().as_str(), "mainnet-beta");
|
||||||
|
assert_eq!(query.slots().start_inclusive(), std::option::Option::Some(10));
|
||||||
|
assert_eq!(query.page().limit().get(), 5_000_000);
|
||||||
|
assert_eq!(query.direction(), crate::RawSortDirection::Ascending);
|
||||||
|
assert!(crate::RawSlotRange::new(std::option::Option::Some(20), std::option::Option::Some(10)).is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
197
crates/ksp-store-api/unit_tests/model/raw_primitives.rs
Normal file
197
crates/ksp-store-api/unit_tests/model/raw_primitives.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// file: crates/ksp-store-api/unit_tests/model/raw_primitives.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn code(value: &str) -> std::option::Option<crate::RawProvenanceCode> {
|
||||||
|
return match crate::RawProvenanceCode::new(value.to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp(unix_millis: u64) -> std::option::Option<crate::RawTimestamp> {
|
||||||
|
return match crate::RawTimestamp::from_unix_millis(unix_millis) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_codes_are_bounded_nonempty_and_reject_url_like_or_control_values() {
|
||||||
|
let network = crate::RawNetworkId::new("mainnet-beta".to_owned());
|
||||||
|
assert!(network.is_ok());
|
||||||
|
let format = crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned());
|
||||||
|
assert!(format.is_ok());
|
||||||
|
let maximum = "a".repeat(crate::MAX_RAW_CODE_BYTES);
|
||||||
|
assert!(crate::RawProvenanceCode::new(maximum).is_ok());
|
||||||
|
assert!(crate::RawProvenanceCode::new(std::string::String::new()).is_err());
|
||||||
|
assert!(crate::RawProvenanceCode::new("bad value".to_owned()).is_err());
|
||||||
|
assert!(crate::RawProvenanceCode::new("https://secret.example".to_owned()).is_err());
|
||||||
|
assert!(crate::RawProvenanceCode::new("bad\nvalue".to_owned()).is_err());
|
||||||
|
assert!(crate::RawProvenanceCode::new("a".repeat(crate::MAX_RAW_CODE_BYTES + 1)).is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_timestamp_accepts_documented_maximum_and_rejects_larger_values() {
|
||||||
|
let maximum = crate::RawTimestamp::from_unix_millis(crate::MAX_RAW_UNIX_MILLIS);
|
||||||
|
assert!(maximum.is_ok());
|
||||||
|
let too_large = crate::RawTimestamp::from_unix_millis(crate::MAX_RAW_UNIX_MILLIS + 1);
|
||||||
|
assert!(too_large.is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_payload_is_nonempty_versioned_bounded_and_debug_omits_bytes() {
|
||||||
|
let format_result = crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned());
|
||||||
|
assert!(format_result.is_ok());
|
||||||
|
let format = match format_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let sentinel = b"RAW_PAYLOAD_SENTINEL_NEVER_RENDER".to_vec().into_boxed_slice();
|
||||||
|
let payload_result = crate::RawPayload::try_new(format, 1, sentinel, crate::RawContentHash::new([7_u8; 32]));
|
||||||
|
assert!(payload_result.is_ok());
|
||||||
|
let payload = match payload_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(payload.bytes(), b"RAW_PAYLOAD_SENTINEL_NEVER_RENDER");
|
||||||
|
assert_eq!(payload.byte_len(), b"RAW_PAYLOAD_SENTINEL_NEVER_RENDER".len());
|
||||||
|
assert_eq!(payload.format_version(), 1);
|
||||||
|
let debug = format!("{payload:?}");
|
||||||
|
assert!(!debug.contains("RAW_PAYLOAD_SENTINEL_NEVER_RENDER"));
|
||||||
|
assert!(debug.contains("len"));
|
||||||
|
let empty_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(crate::RawPayload::try_new(empty_format, 1, std::vec::Vec::new().into_boxed_slice(), crate::RawContentHash::new([0_u8; 32])).is_err());
|
||||||
|
let zero_version_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(crate::RawPayload::try_new(zero_version_format, 0, vec![1_u8].into_boxed_slice(), crate::RawContentHash::new([0_u8; 32])).is_err());
|
||||||
|
let oversized_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let oversized = vec![0_u8; crate::MAX_RAW_PAYLOAD_BYTES + 1].into_boxed_slice();
|
||||||
|
let error = crate::RawPayload::try_new(oversized_format, 1, oversized, crate::RawContentHash::new([0_u8; 32]));
|
||||||
|
assert!(error.is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquisition_provenance_preserves_safe_metadata_and_validates_time_and_size() {
|
||||||
|
let received_at = match timestamp(2_000) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let observed_at = match timestamp(1_000) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let provider = match code("publicnode") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let protocol = match code("yellowstone_grpc") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let method = match code("transactions") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let endpoint = match code("main") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let commitment = match code("confirmed") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let session = match code("session_1") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let filter = match code("all_transactions") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let provenance = crate::RawAcquisitionProvenance::new(provider, protocol, method, crate::RawAcquisitionOrigin::Live, received_at)
|
||||||
|
.with_endpoint_id(endpoint)
|
||||||
|
.with_commitment(commitment)
|
||||||
|
.with_capture_session_id(session)
|
||||||
|
.with_filter_id(filter)
|
||||||
|
.with_source_payload_hash(crate::RawContentHash::new([3_u8; 32]));
|
||||||
|
let provenance_result = provenance.try_with_observed_at(observed_at);
|
||||||
|
assert!(provenance_result.is_ok());
|
||||||
|
let provenance = match provenance_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let provenance_result = provenance.try_with_source_payload_size_bytes(4_096);
|
||||||
|
assert!(provenance_result.is_ok());
|
||||||
|
let provenance = match provenance_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(provenance.provider().as_str(), "publicnode");
|
||||||
|
assert_eq!(provenance.protocol().as_str(), "yellowstone_grpc");
|
||||||
|
assert_eq!(provenance.acquisition_method().as_str(), "transactions");
|
||||||
|
assert_eq!(provenance.origin(), crate::RawAcquisitionOrigin::Live);
|
||||||
|
assert_eq!(provenance.observed_at(), std::option::Option::Some(observed_at));
|
||||||
|
assert_eq!(provenance.received_at(), received_at);
|
||||||
|
assert_eq!(provenance.source_payload_size_bytes(), std::option::Option::Some(4_096));
|
||||||
|
let reversed_provider = match code("provider") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let reversed_protocol = match code("solana_websocket") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let reversed_method = match code("transactionSubscribe") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let reversed_received = match timestamp(1_000) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let reversed_observed = match timestamp(2_000) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let reversed =
|
||||||
|
crate::RawAcquisitionProvenance::new(reversed_provider, reversed_protocol, reversed_method, crate::RawAcquisitionOrigin::Live, reversed_received)
|
||||||
|
.try_with_observed_at(reversed_observed);
|
||||||
|
assert!(reversed.is_err());
|
||||||
|
let oversized_provider = match code("provider") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let oversized_protocol = match code("solana_http") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let oversized_method = match code("getTransaction") {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let oversized_received = match timestamp(2_000) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let oversized_source = crate::RawAcquisitionProvenance::new(
|
||||||
|
oversized_provider,
|
||||||
|
oversized_protocol,
|
||||||
|
oversized_method,
|
||||||
|
crate::RawAcquisitionOrigin::Backfill,
|
||||||
|
oversized_received,
|
||||||
|
)
|
||||||
|
.try_with_source_payload_size_bytes(crate::MAX_RAW_SOURCE_PAYLOAD_BYTES + 1);
|
||||||
|
assert!(oversized_source.is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
60
crates/ksp-store-api/unit_tests/model/raw_retention.rs
Normal file
60
crates/ksp-store-api/unit_tests/model/raw_retention.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// file: crates/ksp-store-api/unit_tests/model/raw_retention.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
fn reference() -> std::option::Option<crate::RawTransactionReference> {
|
||||||
|
let network = match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(crate::RawTransactionReference::new(network, crate::RawTransactionSignature::new([3_u8; 64])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retention_transitions_are_forward_only_and_force_rehydrate_is_separate() {
|
||||||
|
let reference = match reference() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Full, crate::RawRetentionState::Compacted).is_ok());
|
||||||
|
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Full, crate::RawRetentionState::Archived).is_ok());
|
||||||
|
assert!(
|
||||||
|
crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Compacted, crate::RawRetentionState::Archived).is_ok()
|
||||||
|
);
|
||||||
|
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Archived, crate::RawRetentionState::Purged).is_ok());
|
||||||
|
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Purged, crate::RawRetentionState::Full).is_err());
|
||||||
|
assert!(crate::RawTransactionRetentionTransition::try_new(reference, crate::RawRetentionState::Full, crate::RawRetentionState::Purged).is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tombstone_keeps_minimal_identity_without_raw_payload() {
|
||||||
|
let reference = match reference() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let tombstone = crate::RawTransactionTombstone::try_new(reference, 99, format, 1, crate::RawContentHash::new([4_u8; 32]));
|
||||||
|
assert!(tombstone.is_ok());
|
||||||
|
let tombstone = match tombstone {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(tombstone.slot(), 99);
|
||||||
|
assert_eq!(tombstone.format_version(), 1);
|
||||||
|
let second_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(crate::RawTransactionTombstone::try_new(tombstone.reference().clone(), tombstone.slot(), second_format, 0, tombstone.content_hash()).is_err());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retention_outcome_distinguishes_target_idempotence_from_lost_compare_and_transition() {
|
||||||
|
assert_ne!(crate::RawRetentionWriteOutcome::AlreadyAtTarget, crate::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||||
|
assert_ne!(crate::RawRetentionWriteOutcome::Applied, crate::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
85
crates/ksp-store-api/unit_tests/model/raw_transaction.rs
Normal file
85
crates/ksp-store-api/unit_tests/model/raw_transaction.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// file: crates/ksp-store-api/unit_tests/model/raw_transaction.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||||
|
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload() -> std::option::Option<crate::RawPayload> {
|
||||||
|
let format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return match crate::RawPayload::try_new(
|
||||||
|
format,
|
||||||
|
1,
|
||||||
|
b"canonical transaction including logs".to_vec().into_boxed_slice(),
|
||||||
|
crate::RawContentHash::new([5_u8; 32]),
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provenance() -> std::option::Option<crate::RawAcquisitionProvenance> {
|
||||||
|
let provider = match crate::RawProvenanceCode::new("publicnode".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let protocol = match crate::RawProvenanceCode::new("yellowstone_grpc".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let method = match crate::RawProvenanceCode::new("transactions".to_owned()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let received_at = match crate::RawTimestamp::from_unix_millis(1_000) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(crate::RawAcquisitionProvenance::new(provider, protocol, method, crate::RawAcquisitionOrigin::Backfill, received_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_transaction_identity_is_network_plus_signature_and_payload_remains_whole() {
|
||||||
|
let network = match network() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let payload = match payload() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let signature = crate::RawTransactionSignature::new([9_u8; 64]);
|
||||||
|
let reference = crate::RawTransactionReference::new(network, signature);
|
||||||
|
let transaction = crate::RawTransaction::new(reference, 42, std::option::Option::None, payload);
|
||||||
|
assert_eq!(transaction.reference().network().as_str(), "mainnet-beta");
|
||||||
|
assert_eq!(transaction.reference().signature(), signature);
|
||||||
|
assert_eq!(transaction.slot(), 42);
|
||||||
|
assert!(transaction.block_time().is_none());
|
||||||
|
assert_eq!(transaction.payload().bytes(), b"canonical transaction including logs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_observation_is_separate_from_canonical_raw_payload() {
|
||||||
|
let network = match network() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let provenance = match provenance() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
let signature = crate::RawTransactionSignature::new([11_u8; 64]);
|
||||||
|
let reference = crate::RawTransactionReference::new(network, signature);
|
||||||
|
let observation = crate::RawTransactionObservation::new(crate::RawObservationKey::new([12_u8; 32]), reference, provenance);
|
||||||
|
assert_eq!(observation.transaction().signature(), signature);
|
||||||
|
assert_eq!(observation.observation_key().as_bytes(), &[12_u8; 32]);
|
||||||
|
assert_eq!(observation.provenance().provider().as_str(), "publicnode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
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
|
||||||
95
crates/ksp-store-lib/README.md
Normal file
95
crates/ksp-store-lib/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<!-- file: crates/ksp-store-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# 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 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 — par exemple Devnet, Mainnet et Testnet — 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.
|
||||||
|
|
||||||
|
Les targets committed sont actuellement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
devnet -> network devnet -> base indépendante
|
||||||
|
mainnet -> network mainnet-beta -> base indépendante
|
||||||
|
testnet -> network testnet -> base indépendante
|
||||||
|
```
|
||||||
|
|
||||||
|
Les credentials restent dans les variables `KSP_SECRET_STORE_*_POSTGRES_URI` ou le `.env` possédé par Config.
|
||||||
|
|
||||||
|
## Surface actuelle et hors périmètre
|
||||||
|
|
||||||
|
La fondation runtime ne fournit encore aucune implémentation PostgreSQL des capabilities métier RAW de `ksp-store-api`.
|
||||||
|
|
||||||
|
Sont volontairement hors de cette surface :
|
||||||
|
|
||||||
|
- persistence/query/rétention PostgreSQL de `RawTransaction` ;
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Les premières vertical slices métier sont ajoutées séparément afin que la façade runtime reste stable et backend-neutral.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — construction des settings, ouverture, health et fermeture ;
|
||||||
|
- [`../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) — matrice de validation.
|
||||||
219
crates/ksp-store-lib/USAGE.md
Normal file
219
crates/ksp-store-lib/USAGE.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<!-- file: crates/ksp-store-lib/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de ksp-store-lib
|
||||||
|
|
||||||
|
## 1. Dépendance et features
|
||||||
|
|
||||||
|
Le consumer runtime normal dépend uniquement de la façade :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib" }
|
||||||
|
```
|
||||||
|
|
||||||
|
La feature par défaut est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour construire un binaire sans backend physique :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans ce mode, le type PostgreSQL reste connu par la surface de settings mais `Store::open` retourne `ERROR_CODE_BACKEND_NOT_COMPILED` avant toute I/O si PostgreSQL est sélectionné.
|
||||||
|
|
||||||
|
Un consumer ordinaire ne dépend pas directement de `ksp-store-postgres-lib`.
|
||||||
|
|
||||||
|
## 2. Construire des settings PostgreSQL programmatiquement
|
||||||
|
|
||||||
|
La construction directe est utile pour les tests, outils internes ou compositions qui n'utilisent pas `ksp-config-lib`.
|
||||||
|
|
||||||
|
```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),
|
||||||
|
);
|
||||||
|
|
||||||
|
let validation = settings.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::result::Result::Ok(settings);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PostgresStoreSettings` ne fournit volontairement aucun getter public de l'URI. Son `Debug` remplace cette valeur par `<redacted>`.
|
||||||
|
|
||||||
|
## 3. Ouvrir et fermer un Store
|
||||||
|
|
||||||
|
`Store::open` est async et ne retourne un succès qu'après que le backend compilé a prouvé sa fondation runtime.
|
||||||
|
|
||||||
|
```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 afin qu'une fermeture explicite ne puisse pas être suivie d'une nouvelle opération via la même valeur.
|
||||||
|
|
||||||
|
## 4. Construire les settings depuis Config
|
||||||
|
|
||||||
|
Le chemin applicatif recommandé utilise `ksp-config-lib`, propriétaire du document `std.store`, de `.env` et des secrets.
|
||||||
|
|
||||||
|
Après construction du `ConfigDocumentEngine` :
|
||||||
|
|
||||||
|
```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());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Targets committed :
|
||||||
|
|
||||||
|
```text
|
||||||
|
devnet -> RawNetworkId("devnet")
|
||||||
|
mainnet -> RawNetworkId("mainnet-beta")
|
||||||
|
testnet -> RawNetworkId("testnet")
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque target peut utiliser une URI PostgreSQL distincte. `default_profile` sélectionne un seul target ; `Store` ne route pas automatiquement entre plusieurs targets.
|
||||||
|
|
||||||
|
## 5. Settings disponibles
|
||||||
|
|
||||||
|
### `PostgresPoolSettings`
|
||||||
|
|
||||||
|
Valeurs par défaut :
|
||||||
|
|
||||||
|
```text
|
||||||
|
max_connections 8
|
||||||
|
connect_timeout 10 s
|
||||||
|
wait_timeout 5 s
|
||||||
|
create_timeout 10 s
|
||||||
|
recycle_timeout 5 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Les getters sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
max_connections()
|
||||||
|
connect_timeout()
|
||||||
|
wait_timeout()
|
||||||
|
create_timeout()
|
||||||
|
recycle_timeout()
|
||||||
|
```
|
||||||
|
|
||||||
|
`validate()` vérifie les bornes sans I/O.
|
||||||
|
|
||||||
|
### `PostgresBootstrapSettings`
|
||||||
|
|
||||||
|
Valeurs par défaut :
|
||||||
|
|
||||||
|
```text
|
||||||
|
auto_migrate true
|
||||||
|
migration_timeout 30 s
|
||||||
|
migration_lock_timeout 10 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Getters :
|
||||||
|
|
||||||
|
```text
|
||||||
|
auto_migrate()
|
||||||
|
migration_timeout()
|
||||||
|
migration_lock_timeout()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `StoreSettings`
|
||||||
|
|
||||||
|
La surface expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend()
|
||||||
|
backend_kind()
|
||||||
|
network()
|
||||||
|
shutdown_timeout()
|
||||||
|
validate()
|
||||||
|
```
|
||||||
|
|
||||||
|
`StoreSettings::new` permet de choisir explicitement le timeout de shutdown. `StoreSettings::with_default_shutdown` utilise la borne commune par défaut de 5 secondes.
|
||||||
|
|
||||||
|
## 6. Health et diagnostics
|
||||||
|
|
||||||
|
`StoreRuntimeSnapshot` est synchrone et ne déclenche aucune I/O. Il expose uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend_kind
|
||||||
|
network
|
||||||
|
pool_capacity
|
||||||
|
pool_size
|
||||||
|
pool_available
|
||||||
|
pool_waiting
|
||||||
|
```
|
||||||
|
|
||||||
|
`StoreHealthSnapshot` ajoute une probe async bornée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
state = Ready | NotReady
|
||||||
|
migration_version
|
||||||
|
pending_migration_count
|
||||||
|
last_error_code
|
||||||
|
runtime snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun snapshot n'expose URI, host, user, database, SQL, handle backend ou texte d'erreur PostgreSQL.
|
||||||
|
|
||||||
|
## 7. Limite fonctionnelle actuelle
|
||||||
|
|
||||||
|
`ksp-store-lib` réexporte les modèles et traits RAW de `ksp-store-api`, mais le backend PostgreSQL de la fondation n'implémente encore aucune capability `RawTransaction*` ou `RawAccount*`.
|
||||||
|
|
||||||
|
Les consumers ne doivent donc pas interpréter la disponibilité du runtime PostgreSQL comme une persistence métier déjà présente.
|
||||||
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";
|
||||||
29
crates/ksp-store-lib/src/error.rs
Normal file
29
crates/ksp-store-lib/src/error.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/error.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// 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 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 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 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 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");
|
||||||
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;
|
||||||
199
crates/ksp-store-lib/src/lib.rs
Normal file
199
crates/ksp-store-lib/src/lib.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/lib.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Common backend-neutral Store runtime facade for KSP.
|
||||||
|
//!
|
||||||
|
//! `0.3.2-pre.007` closes the physical PostgreSQL runtime composition with a
|
||||||
|
//! portable safe runtime snapshot and lightweight health/readiness projection,
|
||||||
|
//! while retaining the private migration/bootstrap foundation and no business
|
||||||
|
//! persistence schema.
|
||||||
|
//!
|
||||||
|
//! 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 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 bounded PostgreSQL pool operation reaches its deadline.
|
||||||
|
pub use self::error::ERROR_CODE_POSTGRES_POOL_TIMEOUT;
|
||||||
|
/// 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 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;
|
||||||
|
/// 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;
|
||||||
395
crates/ksp-store-lib/src/settings.rs
Normal file
395
crates/ksp-store-lib/src/settings.rs
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/settings.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
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 {
|
||||||
|
auto_migrate: bool,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresBootstrapSettings {
|
||||||
|
/// Creates explicit bootstrap behavior and migration deadlines.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(auto_migrate: bool, migration_timeout: std::time::Duration, migration_lock_timeout: std::time::Duration) -> Self {
|
||||||
|
return Self { auto_migrate, migration_lock_timeout, migration_timeout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether pending KSP-owned migrations may be applied during Store opening.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn auto_migrate(&self) -> bool {
|
||||||
|
return self.auto_migrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::new(
|
||||||
|
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;
|
||||||
236
crates/ksp-store-lib/src/store.rs
Normal file
236
crates/ksp-store-lib/src/store.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// file: crates/ksp-store-lib/src/store.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::new(
|
||||||
|
network.clone(),
|
||||||
|
settings.connection_uri(),
|
||||||
|
pool.max_connections(),
|
||||||
|
pool.connect_timeout(),
|
||||||
|
pool.wait_timeout(),
|
||||||
|
pool.create_timeout(),
|
||||||
|
pool.recycle_timeout(),
|
||||||
|
tls_mode,
|
||||||
|
bootstrap.auto_migrate(),
|
||||||
|
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 lifecycle 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::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::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,
|
||||||
|
_ => 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;
|
||||||
60
crates/ksp-store-lib/tests/dependency_boundary.rs
Normal file
60
crates/ksp-store-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/dependency_boundary.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
#![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;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
256
crates/ksp-store-lib/tests/hardening_completeness.rs
Normal file
256
crates/ksp-store-lib/tests/hardening_completeness.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/hardening_completeness.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![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_HEALTH_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_MIGRATION_FAILED",
|
||||||
|
"ERROR_CODE_POSTGRES_MIGRATION_MISMATCH",
|
||||||
|
"ERROR_CODE_POSTGRES_POOL_TIMEOUT",
|
||||||
|
"ERROR_CODE_POSTGRES_SCHEMA_NEWER",
|
||||||
|
"ERROR_CODE_POSTGRES_TLS_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_RETENTION_INVALID",
|
||||||
|
"ERROR_CODE_SETTINGS_INVALID",
|
||||||
|
"ERROR_CODE_SHUTDOWN_TIMEOUT",
|
||||||
|
"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(), 84);
|
||||||
|
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;
|
||||||
|
}
|
||||||
68
crates/ksp-store-lib/tests/public_api.rs
Normal file
68
crates/ksp-store-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// file: crates/ksp-store-lib/tests/public_api.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
#![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_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_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_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_BACKEND_CLOSED.code(), "backend_closed");
|
||||||
|
assert_eq!(ksp_store_lib::ERROR_CODE_SHUTDOWN_TIMEOUT.code(), "shutdown_timeout");
|
||||||
|
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;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
149
crates/ksp-store-lib/unit_tests/settings.rs
Normal file
149
crates/ksp-store-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// file: crates/ksp-store-lib/unit_tests/settings.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
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_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;
|
||||||
|
}
|
||||||
57
crates/ksp-store-lib/unit_tests/store.rs
Normal file
57
crates/ksp-store-lib/unit_tests/store.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// file: crates/ksp-store-lib/unit_tests/store.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
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
|
||||||
124
crates/ksp-store-postgres-lib/README.md
Normal file
124
crates/ksp-store-postgres-lib/README.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!-- file: crates/ksp-store-postgres-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# ksp-store-postgres-lib
|
||||||
|
|
||||||
|
`ksp-store-postgres-lib` est le backend PostgreSQL physique officiel du Store KSP.
|
||||||
|
|
||||||
|
La crate implémente la fondation connexion/pool/TLS/migrations/health 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/moteur de migrations privé KSP ;
|
||||||
|
- la table metadata `ksp_store_schema_migrations` ;
|
||||||
|
- le sentinel `V000__bootstrap.sql` et son checksum SHA-256 ;
|
||||||
|
- l'advisory transaction lock borné des migrations ;
|
||||||
|
- 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 d'intégration 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 ;
|
||||||
|
- vérification de l'identité serveur ;
|
||||||
|
- aucune dégradation automatique en plaintext.
|
||||||
|
|
||||||
|
Les configurations ne permettant pas de vérifier une identité serveur, comme `hostaddr` seul, sont rejetées.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
La fondation embarque uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
migrations/V000__bootstrap.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle crée la metadata privée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_store_schema_migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Le moteur vérifie version, 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.
|
||||||
|
|
||||||
|
Aucune migration métier RAW n'appartient à cette fondation.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
La politique de support de `0.3.2` fixe PostgreSQL 15 comme major minimal. Le test live de fondation refuse explicitement un serveur plus ancien ; le backend ne fixe aucun plafond arbitraire de major PostgreSQL. La compatibilité de migration reste basée sur le schéma KSP.
|
||||||
|
|
||||||
|
La preuve opérateur réelle et le major effectivement exercé sont conservés dans la matrice de validation, pas dans cette documentation durable.
|
||||||
|
|
||||||
|
## Hors périmètre actuel
|
||||||
|
|
||||||
|
La crate ne contient encore :
|
||||||
|
|
||||||
|
- aucune implémentation PostgreSQL des capabilities `RawTransaction*` ;
|
||||||
|
- aucune implémentation PostgreSQL des capabilities `RawAccount*` ;
|
||||||
|
- aucun repository métier RAW ;
|
||||||
|
- aucune table/index métier ;
|
||||||
|
- aucune orchestration worker/job ;
|
||||||
|
- aucun transport d'acquisition ou decoder Program.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — bridge physique et lifecycle ;
|
||||||
|
- [`../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) — preuves déterministes et PostgreSQL réel.
|
||||||
155
crates/ksp-store-postgres-lib/USAGE.md
Normal file
155
crates/ksp-store-postgres-lib/USAGE.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!-- file: crates/ksp-store-postgres-lib/USAGE.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Utilisation de ksp-store-postgres-lib
|
||||||
|
|
||||||
|
## 1. Quand utiliser cette crate directement
|
||||||
|
|
||||||
|
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 ou testent le bridge physique PostgreSQL. La crate backend ne doit pas devenir une façade parallèle.
|
||||||
|
|
||||||
|
Un tel composant doit déclarer explicitement le backend et `ksp-store-api`, car `PostgresBackendSettings::new` reçoit le `RawNetworkId` backend-neutral sans le réexporter :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
ksp-store-api = { path = "../ksp-store-api" }
|
||||||
|
ksp-store-postgres-lib = { path = "../ksp-store-postgres-lib" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Construire le bridge physique
|
||||||
|
|
||||||
|
`PostgresBackendSettings` reçoit des valeurs déjà possédées et validées par la couche appelante. L'URI est sensible et son `Debug` est redacted.
|
||||||
|
|
||||||
|
```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::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),
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||||
|
true,
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend reçoit un seul `RawNetworkId`. Une instance physique n'est pas un routeur multi-réseau.
|
||||||
|
|
||||||
|
## 3. Ouvrir, sonder et fermer
|
||||||
|
|
||||||
|
```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.ready();
|
||||||
|
let _migration_version = health.migration_version();
|
||||||
|
let _pending = health.pending_migration_count();
|
||||||
|
let _safe_error_kind = health.last_error_kind();
|
||||||
|
|
||||||
|
return backend.close(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`open` prouve la connexion et le bootstrap avant de retourner. `close` ferme le pool puis attend son drain dans la deadline fournie.
|
||||||
|
|
||||||
|
## 4. Choisir le mode TLS
|
||||||
|
|
||||||
|
### `VerifyFull`
|
||||||
|
|
||||||
|
À utiliser pour les connexions PostgreSQL protégées :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend charge les roots système et vérifie certificat + identité serveur. Il rejette une configuration ne fournissant pas d'identité vérifiable.
|
||||||
|
|
||||||
|
### `Disabled`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce mode désactive explicitement TLS. Il ne doit être utilisé que lorsque la topologie de déploiement justifie clairement une connexion non chiffrée.
|
||||||
|
|
||||||
|
La valeur typée choisie par KSP prime sur les paramètres SSL de l'URI.
|
||||||
|
|
||||||
|
## 5. Bootstrap et migrations
|
||||||
|
|
||||||
|
Le backend embarque son propre moteur de migrations. Le seul artefact initial est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
migrations/V000__bootstrap.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bootstrap maintient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_store_schema_migrations
|
||||||
|
version
|
||||||
|
name
|
||||||
|
checksum SHA-256
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runner est transactionnel et sérialisé par advisory transaction lock. Une divergence de checksum/nom/version ou une history plus récente est terminale ; aucun down migration automatique n'est exécuté.
|
||||||
|
|
||||||
|
`auto_migrate = false` permet de vérifier l'état sans appliquer de migration pending.
|
||||||
|
|
||||||
|
## 6. Classifier les erreurs sans fuite
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match error.kind() {
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => {}
|
||||||
|
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::SchemaNewer => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => {}
|
||||||
|
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _safe_phase = error.phase();
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas reconstruire un diagnostic utilisateur à partir de l'erreur brute PostgreSQL : cette erreur n'est volontairement pas conservée par le bridge.
|
||||||
|
|
||||||
|
## 7. Ce que cette crate ne permet pas encore
|
||||||
|
|
||||||
|
La fondation physique n'implémente pas les traits `RawTransaction*` ou `RawAccount*` de `ksp-store-api`.
|
||||||
|
|
||||||
|
Un backend ouvert et healthy prouve uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
connexion/pool
|
||||||
|
TLS selon policy
|
||||||
|
bootstrap/history
|
||||||
|
health/readiness
|
||||||
|
close borné
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne prouve aucune persistence métier RAW.
|
||||||
@@ -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
|
||||||
|
);
|
||||||
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";
|
||||||
53
crates/ksp-store-postgres-lib/src/error.rs
Normal file
53
crates/ksp-store-postgres-lib/src/error.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/error.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
/// PostgreSQL migration/bootstrap execution failed without exposing server text or SQL.
|
||||||
|
MigrationFailed,
|
||||||
|
/// Applied PostgreSQL migration history diverges from the embedded immutable KSP history.
|
||||||
|
MigrationMismatch,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
53
crates/ksp-store-postgres-lib/src/lib.rs
Normal file
53
crates/ksp-store-postgres-lib/src/lib.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/lib.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Official PostgreSQL backend implementation for KSP Store.
|
||||||
|
//!
|
||||||
|
//! `0.3.2-pre.007` owns the physical `tokio-postgres` connection, bounded
|
||||||
|
//! Deadpool pool, explicit Rustls TLS policy, private KSP migration/bootstrap
|
||||||
|
//! engine and safe lightweight health/readiness probe. Business persistence
|
||||||
|
//! remains absent from this foundation release.
|
||||||
|
//!
|
||||||
|
//! 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 runtime;
|
||||||
|
|
||||||
|
/// 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 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;
|
||||||
|
|
||||||
|
const _: &str = crate::TRACING_TARGET;
|
||||||
318
crates/ksp-store-postgres-lib/src/migration.rs
Normal file
318
crates/ksp-store-postgres-lib/src/migration.rs
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
// file: crates/ksp-store-postgres-lib/src/migration.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
use sha2::Digest; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const ADVISORY_LOCK_KEY: i64 = 0x4b53_5053_544f_5245;
|
||||||
|
const BOOTSTRAP_MIGRATION_NAME: &str = "bootstrap";
|
||||||
|
const BOOTSTRAP_MIGRATION_SQL: &str = include_str!("../migrations/V000__bootstrap.sql");
|
||||||
|
const BOOTSTRAP_MIGRATION_VERSION: i64 = 0;
|
||||||
|
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 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 METADATA_PRIMARY_KEY_SQL: &str = r#"SELECT COUNT(*)::BIGINT,
|
||||||
|
COUNT(*) FILTER (WHERE kcu.column_name = 'version')::BIGINT
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_catalog = kcu.constraint_catalog
|
||||||
|
AND tc.constraint_schema = kcu.constraint_schema
|
||||||
|
AND tc.constraint_name = kcu.constraint_name
|
||||||
|
WHERE tc.table_schema = current_schema()
|
||||||
|
AND tc.table_name = 'ksp_store_schema_migrations'
|
||||||
|
AND tc.constraint_type = 'PRIMARY KEY'"#;
|
||||||
|
const METADATA_SHAPE_SQL: &str = r#"SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'ksp_store_schema_migrations'
|
||||||
|
ORDER BY ordinal_position"#;
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest migration version embedded by this backend runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn current_migration_version() -> i64 {
|
||||||
|
return BOOTSTRAP_MIGRATION_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the private bounded PostgreSQL schema bootstrap on one dedicated pooled client.
|
||||||
|
pub(crate) async fn bootstrap(
|
||||||
|
client: &mut deadpool_postgres::Client,
|
||||||
|
auto_migrate: bool,
|
||||||
|
migration_timeout: std::time::Duration,
|
||||||
|
migration_lock_timeout: std::time::Duration,
|
||||||
|
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let bounded = tokio::time::timeout(migration_timeout, bootstrap_inner(client, auto_migrate, 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,
|
||||||
|
auto_migrate: 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 checksum = bootstrap_checksum();
|
||||||
|
if !metadata_exists {
|
||||||
|
if !auto_migrate {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_pending"));
|
||||||
|
}
|
||||||
|
let create_result = transaction.batch_execute(BOOTSTRAP_MIGRATION_SQL).await;
|
||||||
|
if create_result.is_err() {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_create"));
|
||||||
|
}
|
||||||
|
let shape_result = verify_metadata_shape(&transaction).await;
|
||||||
|
if let std::result::Result::Err(error) = shape_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let insert_result = transaction.execute(HISTORY_INSERT_SQL, &[&BOOTSTRAP_MIGRATION_VERSION, &BOOTSTRAP_MIGRATION_NAME, &checksum]).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, "history_insert"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let shape_result = verify_metadata_shape(&transaction).await;
|
||||||
|
if let std::result::Result::Err(error) = shape_result {
|
||||||
|
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(), checksum.as_str());
|
||||||
|
if let std::result::Result::Err(error) = validation_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 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 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"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let result = transaction.query(METADATA_SHAPE_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, "metadata_shape"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let expected = [("version", "bigint", "NO"), ("name", "text", "NO"), ("checksum", "text", "NO"), ("applied_at", "timestamp with time zone", "NO")];
|
||||||
|
let mut found = [false; 4];
|
||||||
|
for row in rows {
|
||||||
|
let column_result = row.try_get::<usize, std::string::String>(0);
|
||||||
|
let data_type_result = row.try_get::<usize, std::string::String>(1);
|
||||||
|
let nullable_result = row.try_get::<usize, std::string::String>(2);
|
||||||
|
let (column, data_type, nullable) = match (column_result, data_type_result, nullable_result) {
|
||||||
|
(std::result::Result::Ok(column), std::result::Result::Ok(data_type), std::result::Result::Ok(nullable)) => (column, data_type, nullable),
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape_decode"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (index, expected_row) in expected.iter().enumerate() {
|
||||||
|
if column == expected_row.0 {
|
||||||
|
if found[index] || data_type != expected_row.1 || nullable != expected_row.2 {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape"));
|
||||||
|
}
|
||||||
|
found[index] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for required in found {
|
||||||
|
if !required {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key_result = transaction.query_one(METADATA_PRIMARY_KEY_SQL, &[]).await;
|
||||||
|
let key_row = match key_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_primary_key"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let key_count = key_row.try_get::<usize, i64>(0);
|
||||||
|
let version_count = key_row.try_get::<usize, i64>(1);
|
||||||
|
return match (key_count, version_count) {
|
||||||
|
(std::result::Result::Ok(1), std::result::Result::Ok(1)) => std::result::Result::Ok(()),
|
||||||
|
(std::result::Result::Ok(_), std::result::Result::Ok(_)) => {
|
||||||
|
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_primary_key"))
|
||||||
|
},
|
||||||
|
_ => std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_primary_key_decode")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_history(history: &[AppliedMigration], expected_checksum: &str) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||||
|
let mut sentinel_found = false;
|
||||||
|
for applied in history {
|
||||||
|
if applied.version > BOOTSTRAP_MIGRATION_VERSION {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
|
||||||
|
}
|
||||||
|
if applied.version < BOOTSTRAP_MIGRATION_VERSION {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_unknown"));
|
||||||
|
}
|
||||||
|
if applied.name != BOOTSTRAP_MIGRATION_NAME || applied.checksum != expected_checksum {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_diverged"));
|
||||||
|
}
|
||||||
|
sentinel_found = true;
|
||||||
|
}
|
||||||
|
if !sentinel_found {
|
||||||
|
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_missing"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bootstrap_checksum() -> std::string::String {
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(BOOTSTRAP_MIGRATION_SQL.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let bytes = digest.as_slice();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/migration.rs"]
|
||||||
|
mod tests;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user