Compare commits
45 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 |
11
.env.example
11
.env.example
@@ -1,10 +1,19 @@
|
||||
# file: .env.example
|
||||
# version: 10
|
||||
# version: 13
|
||||
|
||||
# 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.
|
||||
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.
|
||||
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
||||
KSP_WALLETS_DIRECTORY=wallets
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,8 +1,34 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 17 -->
|
||||
<!-- version: 20 -->
|
||||
|
||||
# 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`.
|
||||
|
||||
16
Cargo.toml
16
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 312
|
||||
# version: 347
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-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]
|
||||
version = "0.2.13"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -14,20 +14,24 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
argon2 = { version = "^0.5", default-features = false }
|
||||
argon2 = { version = "^0.6", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
deadpool-postgres = { version = "^0.14", default-features = false }
|
||||
directories = { version = "^6.0" }
|
||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||
fs2 = { version = "^0.4" }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
getrandom = { version = "^0.4", 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 }
|
||||
rustls = { version = "^0.23", default-features = false }
|
||||
rustls-native-certs = { version = "^0.8", default-features = false }
|
||||
serde = { version = "^1.0" }
|
||||
serde_json = { version = "^1.0" }
|
||||
sha2 = { version = "^0.11", default-features = false }
|
||||
solana-keypair = { version = "^3.1", default-features = false }
|
||||
solana-pubkey = { version = "^4.3", default-features = false }
|
||||
tauri = { version = "^2.11" }
|
||||
@@ -39,6 +43,8 @@ tracing = { version = "^0.1", default-features = false }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||
tracing-appender = { version = "^0.2", 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 }
|
||||
tonic = { version = "^0.14", default-features = false }
|
||||
tonic-prost = { version = "^0.14", default-features = false }
|
||||
|
||||
61
ROADMAP.md
61
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 91 -->
|
||||
<!-- version: 95 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -58,7 +58,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
- [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.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 ; `pre.001` doit auditer la base stable `v0.2.13` et l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip`, puis classer reconnaissance/decode/outcomes/proofs/préparation sous `REPRENDRE / REDESSINER / REPORTER / REJETER` avant de figer la surface.
|
||||
- [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
|
||||
|
||||
@@ -80,33 +80,52 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
|
||||
## Architecture de données — progression canonique
|
||||
|
||||
La chaîne durable cible est :
|
||||
La progression durable cible est :
|
||||
|
||||
```text
|
||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||
```
|
||||
|
||||
- **RAW** et **CORE** 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.
|
||||
- À 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.
|
||||
- **RAW** et **STRUCTURAL** ne nécessitent aucun décodage Program.
|
||||
- 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.
|
||||
- À 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.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
|
||||
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
|
||||
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
|
||||
- [ ] `0.3.4` — 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 à CORE.
|
||||
- [X] `0.3.1` — `ksp-store-api` stable : modèles N1 RAW backend-agnostic `RawTransaction` et `RawAccountState` avec observations, provenance, payload/hash/timestamps bornés, 10 capabilities object-safe, queries cursorisées sans plafond métier arbitraire, outcomes idempotence/conflit et lifecycle logique rétention/tombstone/force-rehydrate ; aucun backend physique, Config, runtime Store, notification dédiée ni surface STRUCTURAL/DECODED/DOMAIN.
|
||||
- [X] `0.3.2` — `ksp-store-lib` + `ksp-store-postgres-lib` stables comme fondation runtime/backend PostgreSQL : feature `postgres` par défaut, `Store` lié à un unique `RawNetworkId`, Config `std.store` avec targets/bases `devnet`/`mainnet`/`testnet`, pool Deadpool borné, `tokio-postgres`, TLS Rustls `Disabled`/`VerifyFull`, moteur de migrations privé `V000` + SHA-256/advisory lock, health/readiness portable et close borné. Gate complet + PostgreSQL réel major 17 verts ; aucune table/capability `RawTransaction`/`RawAccountState` métier n'est encore ajoutée.
|
||||
- [ ] `0.3.3` — Étendre le même couple 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` — Étendre le même couple avec `RawAccountState` + observation, puis fermer la complétude/conformance RAW cross-family, les indexes/migrations physiques nécessaires et le hardening PostgreSQL final.
|
||||
- [ ] `0.3.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.
|
||||
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
|
||||
- [ ] Ajouter replay/backfill RAW -> CORE.
|
||||
- [ ] Ajouter worker/service CORE.
|
||||
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
|
||||
- [ ] **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.
|
||||
- [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`.
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
- [ ] **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
|
||||
|
||||
@@ -119,7 +138,7 @@ RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
- [ ] SPL Token.
|
||||
- [ ] Associated Token Account.
|
||||
- [ ] 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
|
||||
|
||||
@@ -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.
|
||||
- [ ] 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
|
||||
|
||||
|
||||
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/std.logging.json": "config/std.logging.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.wallet.json": "config/std.wallet.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.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.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
// version: 10
|
||||
// version: 11
|
||||
|
||||
//! 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);
|
||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 11);
|
||||
assert_eq!(resources.len(), 13);
|
||||
assert_eq!(
|
||||
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"),
|
||||
@@ -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),
|
||||
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!(
|
||||
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"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
#[test]
|
||||
fn profile_inventory_exposes_registered_profile_documents() {
|
||||
@@ -15,6 +15,9 @@ fn profile_inventory_exposes_registered_profile_documents() {
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
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 {
|
||||
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/std.logging.json": "config/std.logging.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.wallet.json": "config/std.wallet.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.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.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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`.
|
||||
|
||||
@@ -85,19 +85,21 @@ fn pre_002_package_is_mixed_lib_bin_and_frontend_is_scaffold_only() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_packaging_contains_current_eleven_config_resources() {
|
||||
fn pre_004_packaging_contains_current_thirteen_config_resources() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some());
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 11);
|
||||
assert_eq!(resources.len(), 13);
|
||||
assert_eq!(
|
||||
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"),
|
||||
);
|
||||
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/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());
|
||||
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/std.logging.json": "config/std.logging.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.wallet.json": "config/std.wallet.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.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.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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.
|
||||
|
||||
@@ -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);
|
||||
assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 11);
|
||||
assert_eq!(resources.len(), 13);
|
||||
assert_eq!(
|
||||
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"),
|
||||
@@ -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),
|
||||
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!(
|
||||
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"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-wallet-desk/tests/release_compliance.rs
|
||||
// version: 9
|
||||
// version: 10
|
||||
|
||||
//! 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::None => return,
|
||||
};
|
||||
assert_eq!(resources.len(), 11);
|
||||
assert_eq!(resources.len(), 13);
|
||||
for (source, destination) in resources {
|
||||
let destination = destination.as_str();
|
||||
assert!(destination.is_some(), "resource destination must be textual");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-config-lib/Cargo.toml
|
||||
# version: 9
|
||||
# version: 10
|
||||
|
||||
[package]
|
||||
name = "ksp-config-lib"
|
||||
@@ -14,6 +14,7 @@ ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-offchain-transport-lib = { path = "../ksp-offchain-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_json.workspace = true
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/README.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# 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 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.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` ;
|
||||
- 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`.
|
||||
@@ -34,14 +35,17 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
||||
Le registre par défaut connaît :
|
||||
|
||||
```text
|
||||
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.offchain_transport -> config/std.offchain_transport.json
|
||||
cfg.std.store -> config/std.store.json
|
||||
cfg.std.transport -> config/std.transport.json
|
||||
cfg.std.wallet -> config/std.wallet.json
|
||||
schema.composite -> config/schemas/composite.schema.json
|
||||
schema.std.logging -> config/schemas/std.logging.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.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é.
|
||||
|
||||
`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 :
|
||||
|
||||
@@ -68,11 +72,11 @@ Les autres crates et applications KSP ne doivent pas :
|
||||
- parser ou écrire directement `.env` ;
|
||||
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||
- 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.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.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/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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||
<!-- version: 13 -->
|
||||
<!-- version: 14 -->
|
||||
|
||||
# Utilisation de ksp-config-lib
|
||||
|
||||
@@ -28,8 +28,11 @@ Les arguments compris par Config sont :
|
||||
```text
|
||||
--cfgpath=/path/to/config
|
||||
--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.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.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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -237,7 +289,7 @@ let component = match composite.component("wallet") {
|
||||
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`
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/lib.rs
|
||||
// version: 20
|
||||
// version: 21
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![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
|
||||
//! 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
|
||||
//! JSON/`.env` persistence.
|
||||
|
||||
@@ -27,6 +27,7 @@ mod persistence;
|
||||
mod profile;
|
||||
mod registry;
|
||||
mod sensitivity;
|
||||
mod store;
|
||||
mod transport;
|
||||
mod wallet;
|
||||
|
||||
@@ -164,6 +165,10 @@ pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||
pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME;
|
||||
/// Default physical filename for the standard Off-chain Transport JSON Schema document.
|
||||
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.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||
/// 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;
|
||||
/// Logical file identifier for the standard Off-chain Transport JSON Schema document.
|
||||
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.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||
/// 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;
|
||||
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||
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.
|
||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||
/// 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;
|
||||
/// One resolved Config string preserving real/safe representations and provenance.
|
||||
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.
|
||||
pub use self::transport::ResolvedTransportConfig;
|
||||
/// Effective standard Wallet configuration resolved to validated filesystem roots.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/registry.rs
|
||||
// version: 12
|
||||
// version: 13
|
||||
|
||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||
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";
|
||||
/// 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";
|
||||
/// 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.
|
||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||
/// 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";
|
||||
/// 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";
|
||||
/// 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.
|
||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||
/// 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";
|
||||
/// Logical file identifier for the standard Off-chain Transport configuration document.
|
||||
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.
|
||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||
/// Logical file identifier for the standard Wallet configuration document.
|
||||
@@ -214,6 +222,22 @@ impl ConfigFileRegistry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
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(
|
||||
FILE_ID_STD_TRANSPORT,
|
||||
ConfigFileKind::Config,
|
||||
@@ -254,6 +278,8 @@ impl ConfigFileRegistry {
|
||||
logging_schema,
|
||||
offchain_transport,
|
||||
offchain_transport_schema,
|
||||
store,
|
||||
store_schema,
|
||||
transport,
|
||||
transport_schema,
|
||||
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
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! 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]
|
||||
fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() {
|
||||
let source = r#"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 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,
|
||||
//! Logging/Transport adapters and management contracts.
|
||||
//! Logging/Transport/Store adapters and management contracts.
|
||||
|
||||
#[test]
|
||||
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:?}");
|
||||
if let std::result::Result::Ok(registry) = registry {
|
||||
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[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[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[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET);
|
||||
assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||
assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||
assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||
assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET);
|
||||
let schema_file_id = descriptors[5].schema_file_id();
|
||||
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_TRANSPORT);
|
||||
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_COMPOSITE);
|
||||
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_OFFCHAIN_TRANSPORT);
|
||||
assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_STORE);
|
||||
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");
|
||||
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);
|
||||
@@ -211,6 +213,18 @@ fn logging_adapter_contract_is_available_from_crate_root() {
|
||||
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]
|
||||
fn management_contracts_are_available_from_crate_root() {
|
||||
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
|
||||
// version: 10
|
||||
// version: 11
|
||||
|
||||
#[test]
|
||||
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:?}");
|
||||
if let std::result::Result::Ok(registry) = registry {
|
||||
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].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));
|
||||
@@ -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[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[4].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_STD_WALLET);
|
||||
assert_eq!(descriptors[5].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
|
||||
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].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE);
|
||||
assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING);
|
||||
assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT);
|
||||
assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET);
|
||||
assert!(descriptors[0..6].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config));
|
||||
assert!(descriptors[6..11].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema));
|
||||
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_TRANSPORT);
|
||||
assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_STD_WALLET);
|
||||
assert_eq!(descriptors[6].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME));
|
||||
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_COMPOSITE);
|
||||
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_OFFCHAIN_TRANSPORT);
|
||||
assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_STORE);
|
||||
assert_eq!(descriptors[11].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
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]
|
||||
fn defaults_register_transport_document_and_schema_with_distinct_roots() {
|
||||
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("../..");
|
||||
}
|
||||
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;
|
||||
467
crates/ksp-store-postgres-lib/src/runtime.rs
Normal file
467
crates/ksp-store-postgres-lib/src/runtime.rs
Normal file
@@ -0,0 +1,467 @@
|
||||
// file: crates/ksp-store-postgres-lib/src/runtime.rs
|
||||
// version: 3
|
||||
|
||||
const APPLICATION_NAME: &str = "ksp-store";
|
||||
const MAX_CONNECTION_URI_BYTES: usize = 4_096;
|
||||
const SHUTDOWN_POLL_INTERVAL_MS: u64 = 10;
|
||||
|
||||
/// Safe PostgreSQL runtime counters exported only through the narrow backend bridge.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresBackendRuntimeSnapshot {
|
||||
pool_available: u32,
|
||||
pool_capacity: u32,
|
||||
pool_size: u32,
|
||||
pool_waiting: u32,
|
||||
}
|
||||
|
||||
impl PostgresBackendRuntimeSnapshot {
|
||||
/// Creates a safe pool-counter projection from already bounded values.
|
||||
#[must_use]
|
||||
pub(crate) const fn new(pool_capacity: u32, pool_size: u32, pool_available: u32, pool_waiting: u32) -> Self {
|
||||
return Self { pool_available, pool_capacity, pool_size, pool_waiting };
|
||||
}
|
||||
|
||||
/// Returns the number of currently available pooled PostgreSQL clients.
|
||||
#[must_use]
|
||||
pub const fn pool_available(&self) -> u32 {
|
||||
return self.pool_available;
|
||||
}
|
||||
|
||||
/// Returns the configured maximum pooled PostgreSQL client count.
|
||||
#[must_use]
|
||||
pub const fn pool_capacity(&self) -> u32 {
|
||||
return self.pool_capacity;
|
||||
}
|
||||
|
||||
/// Returns the current pooled PostgreSQL client count.
|
||||
#[must_use]
|
||||
pub const fn pool_size(&self) -> u32 {
|
||||
return self.pool_size;
|
||||
}
|
||||
|
||||
/// Returns the number of tasks currently waiting for a pooled PostgreSQL client.
|
||||
#[must_use]
|
||||
pub const fn pool_waiting(&self) -> u32 {
|
||||
return self.pool_waiting;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe PostgreSQL readiness projection returned to the common Store facade.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresBackendHealthSnapshot {
|
||||
error_kind: std::option::Option<crate::PostgresBackendErrorKind>,
|
||||
migration_version: std::option::Option<u64>,
|
||||
pending_migration_count: u32,
|
||||
ready: bool,
|
||||
runtime: PostgresBackendRuntimeSnapshot,
|
||||
}
|
||||
|
||||
impl PostgresBackendHealthSnapshot {
|
||||
/// Creates one successful safe readiness projection.
|
||||
#[must_use]
|
||||
pub(crate) const fn ready(runtime: PostgresBackendRuntimeSnapshot, migration_version: u64, pending_migration_count: u32) -> Self {
|
||||
return Self {
|
||||
error_kind: std::option::Option::None,
|
||||
migration_version: std::option::Option::Some(migration_version),
|
||||
pending_migration_count,
|
||||
ready: true,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates one failed safe readiness projection from a classified backend error.
|
||||
#[must_use]
|
||||
pub(crate) const fn not_ready(
|
||||
runtime: PostgresBackendRuntimeSnapshot,
|
||||
migration_version: std::option::Option<u64>,
|
||||
pending_migration_count: u32,
|
||||
error_kind: crate::PostgresBackendErrorKind,
|
||||
) -> Self {
|
||||
return Self {
|
||||
error_kind: std::option::Option::Some(error_kind),
|
||||
migration_version,
|
||||
pending_migration_count,
|
||||
ready: false,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the safe backend error classification when readiness could not be proven.
|
||||
#[must_use]
|
||||
pub const fn error_kind(&self) -> std::option::Option<crate::PostgresBackendErrorKind> {
|
||||
return self.error_kind;
|
||||
}
|
||||
|
||||
/// Returns whether the latest bounded PostgreSQL probe proved readiness.
|
||||
#[must_use]
|
||||
pub const fn is_ready(&self) -> bool {
|
||||
return self.ready;
|
||||
}
|
||||
|
||||
/// Returns the migration version observed by the readiness probe when available.
|
||||
#[must_use]
|
||||
pub const fn migration_version(&self) -> std::option::Option<u64> {
|
||||
return self.migration_version;
|
||||
}
|
||||
|
||||
/// Returns the number of embedded migrations newer than the observed applied version.
|
||||
#[must_use]
|
||||
pub const fn pending_migration_count(&self) -> u32 {
|
||||
return self.pending_migration_count;
|
||||
}
|
||||
|
||||
/// Returns the safe PostgreSQL pool counters captured for this probe.
|
||||
#[must_use]
|
||||
pub const fn runtime(&self) -> &PostgresBackendRuntimeSnapshot {
|
||||
return &self.runtime;
|
||||
}
|
||||
}
|
||||
|
||||
/// TLS mode accepted by the physical PostgreSQL backend bridge.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum PostgresBackendTlsMode {
|
||||
/// Disable TLS for the selected PostgreSQL target.
|
||||
Disabled,
|
||||
/// Require TLS with system-root trust and server-identity verification.
|
||||
VerifyFull,
|
||||
}
|
||||
|
||||
impl PostgresBackendTlsMode {
|
||||
/// Returns the stable safe TLS mode code used only in diagnostics.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Disabled => "disabled",
|
||||
Self::VerifyFull => "verify_full",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical settings consumed only by the PostgreSQL backend crate.
|
||||
pub struct PostgresBackendSettings {
|
||||
connect_timeout: std::time::Duration,
|
||||
connection_uri: std::string::String,
|
||||
create_timeout: std::time::Duration,
|
||||
auto_migrate: bool,
|
||||
max_connections: u32,
|
||||
migration_lock_timeout: std::time::Duration,
|
||||
migration_timeout: std::time::Duration,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
recycle_timeout: std::time::Duration,
|
||||
tls_mode: PostgresBackendTlsMode,
|
||||
wait_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl PostgresBackendSettings {
|
||||
/// Creates the physical PostgreSQL settings bridge from already validated facade-owned values.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
connection_uri: impl std::convert::Into<std::string::String>,
|
||||
max_connections: u32,
|
||||
connect_timeout: std::time::Duration,
|
||||
wait_timeout: std::time::Duration,
|
||||
create_timeout: std::time::Duration,
|
||||
recycle_timeout: std::time::Duration,
|
||||
tls_mode: PostgresBackendTlsMode,
|
||||
auto_migrate: bool,
|
||||
migration_timeout: std::time::Duration,
|
||||
migration_lock_timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
return Self {
|
||||
auto_migrate,
|
||||
connect_timeout,
|
||||
connection_uri: connection_uri.into(),
|
||||
create_timeout,
|
||||
max_connections,
|
||||
migration_lock_timeout,
|
||||
migration_timeout,
|
||||
network,
|
||||
recycle_timeout,
|
||||
tls_mode,
|
||||
wait_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the logical network bound to this one backend instance.
|
||||
#[must_use]
|
||||
pub const fn network(&self) -> &ksp_store_api::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns the selected safe TLS mode.
|
||||
#[must_use]
|
||||
pub const fn tls_mode(&self) -> PostgresBackendTlsMode {
|
||||
return self.tls_mode;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PostgresBackendSettings {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("PostgresBackendSettings")
|
||||
.field("network", &self.network)
|
||||
.field("connection_uri", &"<redacted>")
|
||||
.field("auto_migrate", &self.auto_migrate)
|
||||
.field("max_connections", &self.max_connections)
|
||||
.field("migration_timeout", &self.migration_timeout)
|
||||
.field("migration_lock_timeout", &self.migration_lock_timeout)
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("wait_timeout", &self.wait_timeout)
|
||||
.field("create_timeout", &self.create_timeout)
|
||||
.field("recycle_timeout", &self.recycle_timeout)
|
||||
.field("tls_mode", &self.tls_mode)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque physical PostgreSQL backend owning the bounded Deadpool connection pool.
|
||||
pub struct PostgresBackend {
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
pool: deadpool_postgres::Pool,
|
||||
}
|
||||
|
||||
impl PostgresBackend {
|
||||
/// Parses and normalizes one supplied URI, builds a bounded pool and proves one physical connection before returning readiness.
|
||||
pub async fn open(settings: PostgresBackendSettings) -> std::result::Result<Self, crate::PostgresBackendError> {
|
||||
let normalized = normalized_config(&settings);
|
||||
let pg_config = match normalized {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
network = settings.network().as_str(),
|
||||
tls_mode = settings.tls_mode().code(),
|
||||
max_connections = settings.max_connections,
|
||||
"opening PostgreSQL Store backend pool"
|
||||
);
|
||||
let pool_result = match settings.tls_mode {
|
||||
PostgresBackendTlsMode::Disabled => build_pool(pg_config, tokio_postgres::NoTls, &settings),
|
||||
PostgresBackendTlsMode::VerifyFull => {
|
||||
let tls_result = build_verified_tls();
|
||||
let tls = match tls_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
build_pool(pg_config, tls, &settings)
|
||||
},
|
||||
};
|
||||
let pool = match pool_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let probe = pool.get().await;
|
||||
let mut client = match probe {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_pool_error(error)),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
network = settings.network().as_str(),
|
||||
tls_mode = settings.tls_mode().code(),
|
||||
"PostgreSQL Store backend established initial physical connection"
|
||||
);
|
||||
let bootstrap_result = crate::bootstrap(&mut client, settings.auto_migrate, settings.migration_timeout, settings.migration_lock_timeout).await;
|
||||
if let std::result::Result::Err(error) = bootstrap_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
drop(client);
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
network = settings.network().as_str(),
|
||||
auto_migrate = settings.auto_migrate,
|
||||
"PostgreSQL Store migration/bootstrap foundation verified"
|
||||
);
|
||||
return std::result::Result::Ok(Self { network: settings.network, pool });
|
||||
}
|
||||
|
||||
/// Returns a safe synchronous snapshot of bounded pool counters without performing PostgreSQL I/O.
|
||||
#[must_use]
|
||||
pub fn runtime_snapshot(&self) -> PostgresBackendRuntimeSnapshot {
|
||||
return runtime_snapshot_from_status(self.pool.status());
|
||||
}
|
||||
|
||||
/// Runs a bounded lightweight PostgreSQL readiness probe and returns only safe classified diagnostics.
|
||||
pub async fn health(&self) -> PostgresBackendHealthSnapshot {
|
||||
return crate::probe_health(&self.pool).await;
|
||||
}
|
||||
|
||||
/// Explicitly closes the pool and waits for all owned pooled objects to drain inside the supplied bound.
|
||||
pub async fn close(self, timeout: std::time::Duration) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
self.pool.close();
|
||||
let drain = async {
|
||||
loop {
|
||||
if self.pool.status().size == 0 {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(SHUTDOWN_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
};
|
||||
let result = tokio::time::timeout(timeout, drain).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => {
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, network = self.network.as_str(), "PostgreSQL Store backend pool closed");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(_) => {
|
||||
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ShutdownTimeout, "pool_drain"))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PostgresBackend {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("PostgresBackend").field("network", &self.network).field("state", &"open").finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Drop for PostgresBackend {
|
||||
fn drop(&mut self) {
|
||||
self.pool.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_config(settings: &PostgresBackendSettings) -> std::result::Result<tokio_postgres::Config, crate::PostgresBackendError> {
|
||||
if settings.connection_uri.is_empty() || settings.connection_uri.len() > MAX_CONNECTION_URI_BYTES {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "connection_uri"));
|
||||
}
|
||||
let parsed = settings.connection_uri.parse::<tokio_postgres::Config>();
|
||||
let mut config = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "connection_uri"));
|
||||
},
|
||||
};
|
||||
if config.get_options().is_some() {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "server_options"));
|
||||
}
|
||||
if config.get_hosts().is_empty() && config.get_hostaddrs().is_empty() {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "host"));
|
||||
}
|
||||
if settings.tls_mode == PostgresBackendTlsMode::VerifyFull {
|
||||
if config.get_hosts().is_empty() {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "tls_server_identity"));
|
||||
}
|
||||
for host in config.get_hosts() {
|
||||
if !is_tcp_host(host) {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "tls_server_identity"));
|
||||
}
|
||||
}
|
||||
}
|
||||
config.application_name(APPLICATION_NAME);
|
||||
config.connect_timeout(settings.connect_timeout);
|
||||
config.ssl_negotiation(tokio_postgres::config::SslNegotiation::Postgres);
|
||||
match settings.tls_mode {
|
||||
PostgresBackendTlsMode::Disabled => {
|
||||
config.ssl_mode(tokio_postgres::config::SslMode::Disable);
|
||||
},
|
||||
PostgresBackendTlsMode::VerifyFull => {
|
||||
config.ssl_mode(tokio_postgres::config::SslMode::Require);
|
||||
},
|
||||
}
|
||||
return std::result::Result::Ok(config);
|
||||
}
|
||||
|
||||
fn is_tcp_host(host: &tokio_postgres::config::Host) -> bool {
|
||||
return match host {
|
||||
tokio_postgres::config::Host::Tcp(_) => true,
|
||||
#[cfg(unix)]
|
||||
tokio_postgres::config::Host::Unix(_) => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn build_pool<T>(
|
||||
pg_config: tokio_postgres::Config,
|
||||
tls: T,
|
||||
settings: &PostgresBackendSettings,
|
||||
) -> std::result::Result<deadpool_postgres::Pool, crate::PostgresBackendError>
|
||||
where
|
||||
T: tokio_postgres::tls::MakeTlsConnect<tokio_postgres::Socket> + std::clone::Clone + std::marker::Send + std::marker::Sync + 'static,
|
||||
T::Stream: std::marker::Send + std::marker::Sync,
|
||||
T::TlsConnect: std::marker::Send + std::marker::Sync,
|
||||
<T::TlsConnect as tokio_postgres::tls::TlsConnect<tokio_postgres::Socket>>::Future: std::marker::Send,
|
||||
{
|
||||
let manager_config = deadpool_postgres::ManagerConfig { recycling_method: deadpool_postgres::RecyclingMethod::Verified };
|
||||
let manager = deadpool_postgres::Manager::from_config(pg_config, tls, manager_config);
|
||||
let built = deadpool_postgres::Pool::builder(manager)
|
||||
.max_size(settings.max_connections as usize)
|
||||
.wait_timeout(std::option::Option::Some(settings.wait_timeout))
|
||||
.create_timeout(std::option::Option::Some(settings.create_timeout))
|
||||
.recycle_timeout(std::option::Option::Some(settings.recycle_timeout))
|
||||
.runtime(deadpool_postgres::Runtime::Tokio1)
|
||||
.build();
|
||||
return match built {
|
||||
std::result::Result::Ok(pool) => std::result::Result::Ok(pool),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "pool_build")),
|
||||
};
|
||||
}
|
||||
|
||||
fn build_verified_tls() -> std::result::Result<tokio_postgres_rustls::MakeRustlsConnect, crate::PostgresBackendError> {
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let native_error_count = native.errors.len();
|
||||
if native.certs.is_empty() {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, native_error_count, "no system TLS roots available for PostgreSQL verify_full");
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::TlsFailed, "native_roots"));
|
||||
}
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
let (added, ignored) = roots.add_parsable_certificates(native.certs);
|
||||
if added == 0 {
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, native_error_count, ignored, "system TLS roots could not be admitted for PostgreSQL verify_full");
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::TlsFailed, "native_roots"));
|
||||
}
|
||||
if native_error_count > 0 || ignored > 0 {
|
||||
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, added, ignored, native_error_count, "loaded PostgreSQL system TLS roots with partial diagnostics");
|
||||
}
|
||||
let provider = std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let builder_result = rustls::ClientConfig::builder_with_provider(provider).with_safe_default_protocol_versions();
|
||||
let builder = match builder_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::TlsFailed, "protocol_versions"));
|
||||
},
|
||||
};
|
||||
let client_config = builder.with_root_certificates(roots).with_no_client_auth();
|
||||
return std::result::Result::Ok(tokio_postgres_rustls::MakeRustlsConnect::new(client_config));
|
||||
}
|
||||
|
||||
/// Maps one Deadpool acquisition error into a redacted backend classification.
|
||||
pub(crate) fn map_pool_error(error: deadpool_postgres::PoolError) -> crate::PostgresBackendError {
|
||||
return match error {
|
||||
deadpool_postgres::PoolError::Timeout(timeout_type) => crate::PostgresBackendError::new(
|
||||
crate::PostgresBackendErrorKind::PoolTimeout,
|
||||
match timeout_type {
|
||||
deadpool_postgres::TimeoutType::Wait => "pool_wait",
|
||||
deadpool_postgres::TimeoutType::Create => "pool_create",
|
||||
deadpool_postgres::TimeoutType::Recycle => "pool_recycle",
|
||||
},
|
||||
),
|
||||
deadpool_postgres::PoolError::Backend(_) => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConnectFailed, "physical_connect"),
|
||||
deadpool_postgres::PoolError::Closed => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConnectFailed, "pool_closed"),
|
||||
deadpool_postgres::PoolError::NoRuntimeSpecified => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConfigInvalid, "pool_runtime"),
|
||||
deadpool_postgres::PoolError::PostCreateHook(_) => crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ConnectFailed, "pool_post_create"),
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts Deadpool status into a bounded safe backend runtime projection.
|
||||
pub(crate) fn runtime_snapshot_from_status(status: deadpool_postgres::Status) -> PostgresBackendRuntimeSnapshot {
|
||||
return PostgresBackendRuntimeSnapshot::new(
|
||||
bounded_count(status.max_size),
|
||||
bounded_count(status.size),
|
||||
bounded_count(status.available),
|
||||
bounded_count(status.waiting),
|
||||
);
|
||||
}
|
||||
|
||||
fn bounded_count(value: usize) -> u32 {
|
||||
if value > u32::MAX as usize {
|
||||
return u32::MAX;
|
||||
}
|
||||
return value as u32;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/runtime.rs"]
|
||||
mod tests;
|
||||
84
crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
Normal file
84
crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
// file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
|
||||
// version: 5
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Dependency and ownership canaries for the physical PostgreSQL Store backend.
|
||||
|
||||
#[test]
|
||||
fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_facade_edge() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
for required in
|
||||
["deadpool-postgres", "ksp-logging-lib", "ksp-store-api", "rustls", "rustls-native-certs", "sha2", "tokio-postgres", "tokio-postgres-rustls"]
|
||||
{
|
||||
assert!(manifest.contains(required), "missing PostgreSQL backend dependency: {required}");
|
||||
}
|
||||
for forbidden in ["ksp-store-lib", "ksp-config-lib", "ksp-materializer", "ksp-program", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib", "sqlx"] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden PostgreSQL backend dependency detected: {forbidden}");
|
||||
}
|
||||
let migration = include_str!("../src/migration.rs");
|
||||
let bootstrap_sql = include_str!("../migrations/V000__bootstrap.sql");
|
||||
assert!(migration.contains("include_str!(\"../migrations/V000__bootstrap.sql\")"));
|
||||
assert!(bootstrap_sql.contains("ksp_store_schema_migrations"));
|
||||
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
||||
assert!(!migration.contains(forbidden), "business migration implementation leaked into foundation: {forbidden}");
|
||||
assert!(!bootstrap_sql.contains(forbidden), "business schema leaked into foundation SQL: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(crate_root.contains("mod error;"));
|
||||
assert!(crate_root.contains("mod health;"));
|
||||
assert!(crate_root.contains("mod migration;"));
|
||||
assert!(crate_root.contains("mod runtime;"));
|
||||
assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;"));
|
||||
for forbidden in [
|
||||
"pub mod ",
|
||||
"ksp_store_lib",
|
||||
"ksp_config_lib",
|
||||
"tokio_postgres::Client",
|
||||
"tokio_postgres::Row",
|
||||
"tokio_postgres::Statement",
|
||||
"deadpool_postgres::Pool;",
|
||||
] {
|
||||
assert!(!crate_root.contains(forbidden), "forbidden PostgreSQL crate-root surface detected: {forbidden}");
|
||||
}
|
||||
let runtime = include_str!("../src/runtime.rs");
|
||||
for forbidden in [
|
||||
"std::env",
|
||||
"dotenv",
|
||||
"KSP_",
|
||||
"KSPB_",
|
||||
"PGHOST",
|
||||
"PGPORT",
|
||||
"PGUSER",
|
||||
"PGPASSWORD",
|
||||
".pgpass",
|
||||
"CREATE TABLE",
|
||||
"INSERT INTO",
|
||||
"UPDATE ",
|
||||
"DELETE FROM",
|
||||
"SELECT ",
|
||||
] {
|
||||
assert!(!runtime.contains(forbidden), "forbidden PostgreSQL backend ownership/scope content detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_health_probe_remains_foundation_only_and_private_sql() {
|
||||
let health = include_str!("../src/health.rs");
|
||||
assert!(health.contains("SELECT 1::BIGINT"));
|
||||
assert!(health.contains("ksp_store_schema_migrations"));
|
||||
for forbidden in
|
||||
["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED", "std::env", "dotenv", "KSP_SECRET_"]
|
||||
{
|
||||
assert!(!health.contains(forbidden), "forbidden health ownership/scope content detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
220
crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
Normal file
220
crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
// file: crates/ksp-store-postgres-lib/tests/hardening_completeness.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Hardening, exact-surface and scope canaries for the physical PostgreSQL Store backend.
|
||||
|
||||
const SECRET_CANARY: &str = "KSP-POSTGRES-SECRET-CANARY-PRE009";
|
||||
|
||||
fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||
let mut future = std::boxed::Box::pin(future);
|
||||
let waker = std::task::Waker::noop();
|
||||
let mut context = std::task::Context::from_waker(waker);
|
||||
return match std::future::Future::poll(future.as_mut(), &mut context) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => panic!("PostgreSQL hardening pre-I/O rejection unexpectedly became pending"),
|
||||
};
|
||||
}
|
||||
|
||||
fn network() -> ksp_store_api::RawNetworkId {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid backend hardening network rejected: {error:?}"),
|
||||
};
|
||||
}
|
||||
|
||||
fn settings(connection_uri: &str, tls_mode: ksp_store_postgres_lib::PostgresBackendTlsMode) -> ksp_store_postgres_lib::PostgresBackendSettings {
|
||||
return ksp_store_postgres_lib::PostgresBackendSettings::new(
|
||||
network(),
|
||||
connection_uri,
|
||||
8,
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
tls_mode,
|
||||
true,
|
||||
std::time::Duration::from_secs(30),
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
}
|
||||
|
||||
fn public_reexport_names(source: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("pub use ") || !trimmed.ends_with(';') {
|
||||
continue;
|
||||
}
|
||||
let without_semicolon = trimmed.trim_end_matches(';');
|
||||
let name = match without_semicolon.rsplit("::").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
names.push(name);
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(source: &str) -> std::vec::Vec<&str> {
|
||||
let dependencies_tail = match source.split("[dependencies]").nth(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::vec::Vec::new(),
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::vec::Vec::new(),
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in dependencies.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !key.is_empty() {
|
||||
names.push(key);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
|
||||
fn assert_pre_io_rejection(connection_uri: &str, tls_mode: ksp_store_postgres_lib::PostgresBackendTlsMode, expected_phase: &str) {
|
||||
let settings = settings(connection_uri, tls_mode);
|
||||
let rendered_settings = std::format!("{settings:?}");
|
||||
assert!(!rendered_settings.contains(SECRET_CANARY));
|
||||
assert!(rendered_settings.contains("<redacted>"));
|
||||
let result = poll_ready(ksp_store_postgres_lib::PostgresBackend::open(settings));
|
||||
let error = match result {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => panic!("hostile PostgreSQL settings unexpectedly opened"),
|
||||
};
|
||||
assert_eq!(error.kind(), ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid);
|
||||
assert_eq!(error.phase(), expected_phase);
|
||||
assert!(!std::format!("{error:?}").contains(SECRET_CANARY));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_backend_modules_exports_and_manifest_dependencies_are_exact() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in ["mod constants;", "mod error;", "mod health;", "mod migration;", "mod runtime;"] {
|
||||
assert!(crate_root.contains(required), "missing PostgreSQL backend module: {required}");
|
||||
}
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
let actual_exports = public_reexport_names(crate_root);
|
||||
let mut expected_exports = [
|
||||
"PostgresBackend",
|
||||
"PostgresBackendError",
|
||||
"PostgresBackendErrorKind",
|
||||
"PostgresBackendHealthSnapshot",
|
||||
"PostgresBackendRuntimeSnapshot",
|
||||
"PostgresBackendSettings",
|
||||
"PostgresBackendTlsMode",
|
||||
];
|
||||
expected_exports.sort_unstable();
|
||||
assert_eq!(actual_exports.as_slice(), expected_exports.as_slice());
|
||||
assert_eq!(actual_exports.len(), 7);
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let actual_dependencies = manifest_dependency_names(manifest);
|
||||
let expected_dependencies = [
|
||||
"deadpool-postgres",
|
||||
"ksp-logging-lib",
|
||||
"ksp-store-api",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
];
|
||||
assert_eq!(actual_dependencies.as_slice(), expected_dependencies.as_slice());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_hostile_uri_matrix_is_rejected_before_io_without_secret_echo() {
|
||||
let malformed = std::format!("not-a-postgresql-uri-{SECRET_CANARY}");
|
||||
assert_pre_io_rejection(malformed.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, "connection_uri");
|
||||
let oversized = std::format!("{}{SECRET_CANARY}", "x".repeat(4_097));
|
||||
assert_pre_io_rejection(oversized.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, "connection_uri");
|
||||
let hostaddr_only = std::format!("hostaddr=127.0.0.1 user=operator password={SECRET_CANARY} dbname=ksp");
|
||||
assert_pre_io_rejection(hostaddr_only.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull, "tls_server_identity");
|
||||
let server_options = std::format!("host=localhost user=operator password={SECRET_CANARY} dbname=ksp options='-c application_name={SECRET_CANARY}'");
|
||||
assert_pre_io_rejection(server_options.as_str(), ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, "server_options");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_backend_error_bridge_cannot_retain_external_error_or_secret_text() {
|
||||
let error_source = include_str!("../src/error.rs");
|
||||
assert!(error_source.contains("kind: PostgresBackendErrorKind"));
|
||||
assert!(error_source.contains("phase: &'static str"));
|
||||
for forbidden in ["String", "source:", "message:", "tokio_postgres::Error", "deadpool_postgres::PoolError"] {
|
||||
assert!(!error_source.contains(forbidden), "backend error type can retain forbidden external material: {forbidden}");
|
||||
}
|
||||
let runtime = include_str!("../src/runtime.rs");
|
||||
assert!(runtime.contains("deadpool_postgres::PoolError::Backend(_)"));
|
||||
assert!(!runtime.contains("deadpool_postgres::PoolError::Backend(error)"));
|
||||
for source in [runtime, include_str!("../src/migration.rs"), include_str!("../src/health.rs")] {
|
||||
for forbidden in ["format!(\"{error", "format!(\"{error:?", "error = ?", "error = %"] {
|
||||
assert!(!source.contains(forbidden), "backend source renders external error material: {forbidden}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_backend_has_no_env_bypass_or_business_persistence_capability() {
|
||||
let production = std::format!(
|
||||
"{}
|
||||
{}
|
||||
{}
|
||||
{}
|
||||
{}",
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/health.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/migration.rs"),
|
||||
include_str!("../src/runtime.rs")
|
||||
);
|
||||
for forbidden in [
|
||||
"std::env",
|
||||
"dotenv",
|
||||
"KSP_SECRET_",
|
||||
"KSPB_",
|
||||
"PGHOST",
|
||||
"PGPORT",
|
||||
"PGUSER",
|
||||
"PGPASSWORD",
|
||||
".pgpass",
|
||||
".postgresql/",
|
||||
"sslrootcert",
|
||||
"sslcert",
|
||||
"sslkey",
|
||||
"ksp_store_lib",
|
||||
"ksp_config_lib",
|
||||
"sqlx::",
|
||||
"impl ksp_store_api::RawTransaction",
|
||||
"impl ksp_store_api::RawAccount",
|
||||
] {
|
||||
assert!(!production.contains(forbidden), "forbidden backend ownership/capability material detected: {forbidden}");
|
||||
}
|
||||
let bootstrap_sql = include_str!("../migrations/V000__bootstrap.sql");
|
||||
assert!(bootstrap_sql.contains("ksp_store_schema_migrations"));
|
||||
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
||||
assert!(!bootstrap_sql.contains(forbidden), "business schema leaked into foundation migration: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
416
crates/ksp-store-postgres-lib/tests/postgres_foundation_live.rs
Normal file
416
crates/ksp-store-postgres-lib/tests/postgres_foundation_live.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
// file: crates/ksp-store-postgres-lib/tests/postgres_foundation_live.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Opt-in real PostgreSQL proof for the Store foundation runtime.
|
||||
//!
|
||||
//! The test reads one dedicated PostgreSQL URI from stdin, refuses to start
|
||||
//! when the KSP migration metadata table already exists, never prints the URI,
|
||||
//! creates no business table and cleans up only metadata it proved it created.
|
||||
|
||||
const LIVE_BOOTSTRAP_SQL: &str = include_str!("../migrations/V000__bootstrap.sql");
|
||||
const LIVE_BROKEN_CHECKSUM_A: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const LIVE_BROKEN_CHECKSUM_B: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||
const LIVE_MAX_URI_BYTES: usize = 4_096;
|
||||
const LIVE_METADATA_EXISTS_SQL: &str = r#"SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'ksp_store_schema_migrations'
|
||||
AND table_type = 'BASE TABLE'
|
||||
)"#;
|
||||
const LIVE_METADATA_DROP_SQL: &str = "DROP TABLE IF EXISTS ksp_store_schema_migrations";
|
||||
const LIVE_SENTINEL_CHECKSUM_SQL: &str = "SELECT checksum FROM ksp_store_schema_migrations WHERE version = 0";
|
||||
const LIVE_SENTINEL_INSERT_SQL: &str =
|
||||
"INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES (0, 'bootstrap', 'pre008_rollback_injected', CURRENT_TIMESTAMP)";
|
||||
const LIVE_SENTINEL_UPDATE_SQL: &str = "UPDATE ksp_store_schema_migrations SET checksum = $1 WHERE version = 0";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct LiveFailure {
|
||||
phase: &'static str,
|
||||
}
|
||||
|
||||
impl LiveFailure {
|
||||
const fn new(phase: &'static str) -> Self {
|
||||
return Self { phase };
|
||||
}
|
||||
|
||||
const fn phase(&self) -> &'static str {
|
||||
return self.phase;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "opt-in real PostgreSQL foundation proof; reads one dedicated URI from stdin"]
|
||||
fn pre_008_real_postgres_foundation_is_safe_idempotent_concurrent_and_recoverable() {
|
||||
eprintln!("KSP Store PostgreSQL live proof: reading one dedicated URI from stdin without echoing it from the test.");
|
||||
let uri_result = read_uri_from_stdin();
|
||||
let uri = match uri_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PostgreSQL live input rejected at phase {}", error.phase()),
|
||||
};
|
||||
let runtime_result = tokio::runtime::Builder::new_current_thread().enable_all().build();
|
||||
let runtime = match runtime_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => panic!("PostgreSQL live runtime could not be constructed"),
|
||||
};
|
||||
let outcome = runtime.block_on(run_live_test(uri.as_str()));
|
||||
if let std::result::Result::Err(error) = outcome {
|
||||
panic!("PostgreSQL live foundation proof failed at safe phase {}", error.phase());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn read_uri_from_stdin() -> std::result::Result<std::string::String, LiveFailure> {
|
||||
let mut input = std::string::String::new();
|
||||
let read_result = std::io::stdin().read_line(&mut input);
|
||||
match read_result {
|
||||
std::result::Result::Ok(0) | std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("stdin_read")),
|
||||
std::result::Result::Ok(_) => {},
|
||||
}
|
||||
let uri = input.trim().to_owned();
|
||||
if uri.is_empty() || uri.len() > LIVE_MAX_URI_BYTES {
|
||||
return std::result::Result::Err(LiveFailure::new("stdin_uri"));
|
||||
}
|
||||
return std::result::Result::Ok(uri);
|
||||
}
|
||||
|
||||
async fn run_live_test(uri: &str) -> std::result::Result<(), LiveFailure> {
|
||||
let admin_result = connect_admin(uri).await;
|
||||
let mut admin = match admin_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let preexisting_result = metadata_exists(&admin).await;
|
||||
let preexisting = match preexisting_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if preexisting {
|
||||
return std::result::Result::Err(LiveFailure::new("metadata_preexisting_refusal"));
|
||||
}
|
||||
let major_result = postgres_major(&admin).await;
|
||||
let major = match major_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if major < 15 {
|
||||
return std::result::Result::Err(LiveFailure::new("postgres_major_unsupported"));
|
||||
}
|
||||
eprintln!("KSP Store PostgreSQL live proof: server major {major}");
|
||||
let mut owns_metadata = false;
|
||||
let scenario = run_foundation_scenario(&mut admin, uri, &mut owns_metadata).await;
|
||||
let cleanup = if owns_metadata { drop_metadata(&admin).await } else { std::result::Result::Ok(()) };
|
||||
if let std::result::Result::Err(error) = cleanup {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = scenario {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let remains_result = metadata_exists(&admin).await;
|
||||
let remains = match remains_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if remains {
|
||||
return std::result::Result::Err(LiveFailure::new("cleanup_verification"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn run_foundation_scenario(admin: &mut tokio_postgres::Client, uri: &str, owns_metadata: &mut bool) -> std::result::Result<(), LiveFailure> {
|
||||
let initial_result = open_backend(uri).await;
|
||||
let initial = match initial_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let created_result = metadata_exists(admin).await;
|
||||
let created = match created_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !created {
|
||||
return std::result::Result::Err(LiveFailure::new("initial_bootstrap_metadata"));
|
||||
}
|
||||
*owns_metadata = true;
|
||||
let initial_health = initial.health().await;
|
||||
if !initial_health.is_ready() || initial_health.migration_version() != std::option::Option::Some(0) || initial_health.pending_migration_count() != 0 {
|
||||
return std::result::Result::Err(LiveFailure::new("initial_health"));
|
||||
}
|
||||
let initial_close = close_backend(initial).await;
|
||||
if let std::result::Result::Err(error) = initial_close {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let idempotent_result = open_backend(uri).await;
|
||||
let idempotent = match idempotent_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let idempotent_health = idempotent.health().await;
|
||||
if !idempotent_health.is_ready()
|
||||
|| idempotent_health.migration_version() != std::option::Option::Some(0)
|
||||
|| idempotent_health.pending_migration_count() != 0
|
||||
{
|
||||
return std::result::Result::Err(LiveFailure::new("idempotent_health"));
|
||||
}
|
||||
let idempotent_close = close_backend(idempotent).await;
|
||||
if let std::result::Result::Err(error) = idempotent_close {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let reset_result = drop_metadata(admin).await;
|
||||
if let std::result::Result::Err(error) = reset_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let concurrent_result = concurrent_bootstrap(uri).await;
|
||||
if let std::result::Result::Err(error) = concurrent_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let after_concurrent_result = metadata_exists(admin).await;
|
||||
match after_concurrent_result {
|
||||
std::result::Result::Ok(true) => {},
|
||||
std::result::Result::Ok(false) => return std::result::Result::Err(LiveFailure::new("concurrent_bootstrap_metadata")),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let checksum_result = sentinel_checksum(admin).await;
|
||||
let checksum = match checksum_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let broken_checksum = if checksum == LIVE_BROKEN_CHECKSUM_A { LIVE_BROKEN_CHECKSUM_B } else { LIVE_BROKEN_CHECKSUM_A };
|
||||
let corrupt_result = set_sentinel_checksum(admin, broken_checksum).await;
|
||||
if let std::result::Result::Err(error) = corrupt_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mismatch_settings = match settings(uri) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mismatch = ksp_store_postgres_lib::PostgresBackend::open(mismatch_settings).await;
|
||||
match mismatch {
|
||||
std::result::Result::Err(error) if error.kind() == ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => {},
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("checksum_mismatch_classification")),
|
||||
std::result::Result::Ok(backend) => {
|
||||
let _ = close_backend(backend).await;
|
||||
return std::result::Result::Err(LiveFailure::new("checksum_mismatch_accepted"));
|
||||
},
|
||||
}
|
||||
let restore_result = set_sentinel_checksum(admin, checksum.as_str()).await;
|
||||
if let std::result::Result::Err(error) = restore_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let recovered_result = open_backend(uri).await;
|
||||
let recovered = match recovered_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let recovered_health = recovered.health().await;
|
||||
if !recovered_health.is_ready() {
|
||||
return std::result::Result::Err(LiveFailure::new("checksum_recovery_health"));
|
||||
}
|
||||
let recovered_close = close_backend(recovered).await;
|
||||
if let std::result::Result::Err(error) = recovered_close {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let rollback_reset = drop_metadata(admin).await;
|
||||
if let std::result::Result::Err(error) = rollback_reset {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let rollback_result = prove_transaction_rollback(admin).await;
|
||||
if let std::result::Result::Err(error) = rollback_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let absent_after_rollback = metadata_exists(admin).await;
|
||||
match absent_after_rollback {
|
||||
std::result::Result::Ok(false) => {},
|
||||
std::result::Result::Ok(true) => return std::result::Result::Err(LiveFailure::new("rollback_left_metadata")),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
let final_result = open_backend(uri).await;
|
||||
let final_backend = match final_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let final_health = final_backend.health().await;
|
||||
if !final_health.is_ready() || final_health.migration_version() != std::option::Option::Some(0) || final_health.pending_migration_count() != 0 {
|
||||
return std::result::Result::Err(LiveFailure::new("final_health"));
|
||||
}
|
||||
return close_backend(final_backend).await;
|
||||
}
|
||||
|
||||
fn settings(uri: &str) -> std::result::Result<ksp_store_postgres_lib::PostgresBackendSettings, LiveFailure> {
|
||||
let network_result = ksp_store_api::RawNetworkId::new("devnet");
|
||||
let network = match network_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("network")),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_store_postgres_lib::PostgresBackendSettings::new(
|
||||
network,
|
||||
uri,
|
||||
4,
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled,
|
||||
true,
|
||||
std::time::Duration::from_secs(30),
|
||||
std::time::Duration::from_secs(10),
|
||||
));
|
||||
}
|
||||
|
||||
async fn open_backend(uri: &str) -> std::result::Result<ksp_store_postgres_lib::PostgresBackend, LiveFailure> {
|
||||
let settings_result = settings(uri);
|
||||
let value = match settings_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return match ksp_store_postgres_lib::PostgresBackend::open(value).await {
|
||||
std::result::Result::Ok(backend) => std::result::Result::Ok(backend),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("backend_open")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn close_backend(backend: ksp_store_postgres_lib::PostgresBackend) -> std::result::Result<(), LiveFailure> {
|
||||
return match backend.close(std::time::Duration::from_secs(5)).await {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("backend_close")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn concurrent_bootstrap(uri: &str) -> std::result::Result<(), LiveFailure> {
|
||||
let first_settings = match settings(uri) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let second_settings = match settings(uri) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let first = tokio::spawn(async move {
|
||||
return ksp_store_postgres_lib::PostgresBackend::open(first_settings).await;
|
||||
});
|
||||
let second = tokio::spawn(async move {
|
||||
return ksp_store_postgres_lib::PostgresBackend::open(second_settings).await;
|
||||
});
|
||||
let first_joined = first.await;
|
||||
let second_joined = second.await;
|
||||
let first_backend = match first_joined {
|
||||
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
|
||||
_ => return std::result::Result::Err(LiveFailure::new("concurrent_first")),
|
||||
};
|
||||
let second_backend = match second_joined {
|
||||
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
|
||||
_ => {
|
||||
let _ = close_backend(first_backend).await;
|
||||
return std::result::Result::Err(LiveFailure::new("concurrent_second"));
|
||||
},
|
||||
};
|
||||
let first_close = close_backend(first_backend).await;
|
||||
let second_close = close_backend(second_backend).await;
|
||||
if first_close.is_err() || second_close.is_err() {
|
||||
return std::result::Result::Err(LiveFailure::new("concurrent_close"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn connect_admin(uri: &str) -> std::result::Result<tokio_postgres::Client, LiveFailure> {
|
||||
let parsed = uri.parse::<tokio_postgres::Config>();
|
||||
let mut config = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("admin_config")),
|
||||
};
|
||||
config.ssl_mode(tokio_postgres::config::SslMode::Disable);
|
||||
config.ssl_negotiation(tokio_postgres::config::SslNegotiation::Postgres);
|
||||
let connected = config.connect(tokio_postgres::NoTls).await;
|
||||
let (client, connection) = match connected {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("admin_connect")),
|
||||
};
|
||||
let _connection_task = tokio::spawn(async move {
|
||||
let _result = connection.await;
|
||||
return;
|
||||
});
|
||||
return std::result::Result::Ok(client);
|
||||
}
|
||||
|
||||
async fn postgres_major(client: &tokio_postgres::Client) -> std::result::Result<u32, LiveFailure> {
|
||||
let row_result = client.query_one("SHOW server_version_num", &[]).await;
|
||||
let row = match row_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("server_version")),
|
||||
};
|
||||
let value_result = row.try_get::<usize, std::string::String>(0);
|
||||
let value = match value_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("server_version_decode")),
|
||||
};
|
||||
let parsed = value.parse::<u32>();
|
||||
let version_num = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("server_version_parse")),
|
||||
};
|
||||
return std::result::Result::Ok(version_num / 10_000);
|
||||
}
|
||||
|
||||
async fn metadata_exists(client: &tokio_postgres::Client) -> std::result::Result<bool, LiveFailure> {
|
||||
let row_result = client.query_one(LIVE_METADATA_EXISTS_SQL, &[]).await;
|
||||
let row = match row_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("metadata_probe")),
|
||||
};
|
||||
return match row.try_get::<usize, bool>(0) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("metadata_probe_decode")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn drop_metadata(client: &tokio_postgres::Client) -> std::result::Result<(), LiveFailure> {
|
||||
return match client.batch_execute(LIVE_METADATA_DROP_SQL).await {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("metadata_cleanup")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn sentinel_checksum(client: &tokio_postgres::Client) -> std::result::Result<std::string::String, LiveFailure> {
|
||||
let row_result = client.query_one(LIVE_SENTINEL_CHECKSUM_SQL, &[]).await;
|
||||
let row = match row_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("sentinel_read")),
|
||||
};
|
||||
return match row.try_get::<usize, std::string::String>(0) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("sentinel_decode")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn set_sentinel_checksum(client: &tokio_postgres::Client, checksum: &str) -> std::result::Result<(), LiveFailure> {
|
||||
let update = client.execute(LIVE_SENTINEL_UPDATE_SQL, &[&checksum]).await;
|
||||
return match update {
|
||||
std::result::Result::Ok(1) => std::result::Result::Ok(()),
|
||||
_ => std::result::Result::Err(LiveFailure::new("sentinel_update")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn prove_transaction_rollback(client: &mut tokio_postgres::Client) -> std::result::Result<(), LiveFailure> {
|
||||
let transaction_result = client.transaction().await;
|
||||
let transaction = match transaction_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("rollback_begin")),
|
||||
};
|
||||
let create = transaction.batch_execute(LIVE_BOOTSTRAP_SQL).await;
|
||||
if create.is_err() {
|
||||
return std::result::Result::Err(LiveFailure::new("rollback_create"));
|
||||
}
|
||||
let insert = transaction.batch_execute(LIVE_SENTINEL_INSERT_SQL).await;
|
||||
if insert.is_err() {
|
||||
return std::result::Result::Err(LiveFailure::new("rollback_insert"));
|
||||
}
|
||||
let injected = transaction.batch_execute("SELECT 1 / 0").await;
|
||||
if injected.is_ok() {
|
||||
return std::result::Result::Err(LiveFailure::new("rollback_injection_missing"));
|
||||
}
|
||||
drop(transaction);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
60
crates/ksp-store-postgres-lib/tests/public_api.rs
Normal file
60
crates/ksp-store-postgres-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
// file: crates/ksp-store-postgres-lib/tests/public_api.rs
|
||||
// version: 3
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Narrow physical bridge canaries consumed by `ksp-store-lib` without exposing driver or pool types.
|
||||
|
||||
#[test]
|
||||
fn pre_005_backend_bridge_is_constructible_without_io() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid backend bridge network rejected: {error:?}"),
|
||||
};
|
||||
let settings = ksp_store_postgres_lib::PostgresBackendSettings::new(
|
||||
network,
|
||||
"postgresql://operator:secret@localhost/ksp",
|
||||
8,
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||
true,
|
||||
std::time::Duration::from_secs(30),
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
assert_eq!(settings.network().as_str(), "devnet");
|
||||
assert_eq!(settings.tls_mode().code(), "verify_full");
|
||||
let _open = ksp_store_postgres_lib::PostgresBackend::open;
|
||||
let _close = ksp_store_postgres_lib::PostgresBackend::close;
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_backend_error_projection_is_safe_and_static() {
|
||||
let kinds = [
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::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,
|
||||
];
|
||||
assert_eq!(kinds.len(), 9);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_backend_health_bridge_exposes_only_safe_snapshot_types() {
|
||||
let _runtime = std::mem::size_of::<std::option::Option<ksp_store_postgres_lib::PostgresBackendRuntimeSnapshot>>();
|
||||
let _health = std::mem::size_of::<std::option::Option<ksp_store_postgres_lib::PostgresBackendHealthSnapshot>>();
|
||||
let _runtime_snapshot = ksp_store_postgres_lib::PostgresBackend::runtime_snapshot;
|
||||
let _health_probe = ksp_store_postgres_lib::PostgresBackend::health;
|
||||
return;
|
||||
}
|
||||
42
crates/ksp-store-postgres-lib/unit_tests/health.rs
Normal file
42
crates/ksp-store-postgres-lib/unit_tests/health.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
// file: crates/ksp-store-postgres-lib/unit_tests/health.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn pool_status_projection_is_bounded_and_contains_no_physical_handle() {
|
||||
let status = deadpool_postgres::Status { max_size: 8, size: 3, available: 2, waiting: 1 };
|
||||
let snapshot = crate::runtime_snapshot_from_status(status);
|
||||
assert_eq!(snapshot.pool_capacity(), 8);
|
||||
assert_eq!(snapshot.pool_size(), 3);
|
||||
assert_eq!(snapshot.pool_available(), 2);
|
||||
assert_eq!(snapshot.pool_waiting(), 1);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_snapshot_distinguishes_ready_and_safe_failure_without_server_text() {
|
||||
let status = deadpool_postgres::Status { max_size: 8, size: 1, available: 1, waiting: 0 };
|
||||
let runtime = crate::runtime_snapshot_from_status(status);
|
||||
let ready = crate::PostgresBackendHealthSnapshot::ready(runtime, 0, 0);
|
||||
assert!(ready.is_ready());
|
||||
assert_eq!(ready.migration_version(), std::option::Option::Some(0));
|
||||
assert_eq!(ready.error_kind(), std::option::Option::None);
|
||||
let failed =
|
||||
crate::PostgresBackendHealthSnapshot::not_ready(ready.runtime().clone(), std::option::Option::None, 0, crate::PostgresBackendErrorKind::HealthFailed);
|
||||
assert!(!failed.is_ready());
|
||||
assert_eq!(failed.error_kind(), std::option::Option::Some(crate::PostgresBackendErrorKind::HealthFailed));
|
||||
let rendered = format!("{failed:?}");
|
||||
for forbidden in ["postgresql://", "SELECT ", "ksp_store_schema_migrations", "password", "server error"] {
|
||||
assert!(!rendered.contains(forbidden), "unsafe backend health material detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_health_helpers_keep_unknown_and_pending_counts_safe() {
|
||||
assert_eq!(super::nonnegative_version(-1), std::option::Option::None);
|
||||
assert_eq!(super::nonnegative_version(0), std::option::Option::Some(0));
|
||||
assert_eq!(super::pending_migration_count(-1, 0), 1);
|
||||
assert_eq!(super::pending_migration_count(0, 0), 0);
|
||||
assert_eq!(super::pending_migration_count(1, 0), 0);
|
||||
return;
|
||||
}
|
||||
50
crates/ksp-store-postgres-lib/unit_tests/migration.rs
Normal file
50
crates/ksp-store-postgres-lib/unit_tests/migration.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
// file: crates/ksp-store-postgres-lib/unit_tests/migration.rs
|
||||
// version: 1
|
||||
|
||||
fn applied(version: i64, name: &str, checksum: &str) -> super::AppliedMigration {
|
||||
return super::AppliedMigration { checksum: checksum.to_owned(), name: name.to_owned(), version };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_migration_is_static_metadata_only_and_checksum_is_stable_sha256() {
|
||||
assert_eq!(super::BOOTSTRAP_MIGRATION_VERSION, 0);
|
||||
assert_eq!(super::BOOTSTRAP_MIGRATION_NAME, "bootstrap");
|
||||
assert!(super::BOOTSTRAP_MIGRATION_SQL.contains("CREATE TABLE ksp_store_schema_migrations"));
|
||||
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
|
||||
assert!(!super::BOOTSTRAP_MIGRATION_SQL.contains(forbidden), "business schema leaked into bootstrap SQL: {forbidden}");
|
||||
}
|
||||
let checksum = super::bootstrap_checksum();
|
||||
assert_eq!(checksum.len(), 64);
|
||||
assert_eq!(checksum, "d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_sentinel_history_is_accepted() {
|
||||
let checksum = super::bootstrap_checksum();
|
||||
let history = [applied(0, "bootstrap", checksum.as_str())];
|
||||
assert!(super::validate_history(&history, checksum.as_str()).is_ok());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_or_missing_sentinel_is_terminal_mismatch() {
|
||||
let checksum = super::bootstrap_checksum();
|
||||
let wrong_name = [applied(0, "changed", checksum.as_str())];
|
||||
let wrong_checksum = [applied(0, "bootstrap", "00")];
|
||||
let missing: [super::AppliedMigration; 0] = [];
|
||||
for history in [&wrong_name[..], &wrong_checksum[..], &missing[..]] {
|
||||
let result = super::validate_history(history, checksum.as_str());
|
||||
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::MigrationMismatch));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_history_is_rejected_without_down_migration() {
|
||||
let checksum = super::bootstrap_checksum();
|
||||
let history = [applied(0, "bootstrap", checksum.as_str()), applied(1, "future", "future-checksum")];
|
||||
let result = super::validate_history(&history, checksum.as_str());
|
||||
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::SchemaNewer));
|
||||
return;
|
||||
}
|
||||
102
crates/ksp-store-postgres-lib/unit_tests/runtime.rs
Normal file
102
crates/ksp-store-postgres-lib/unit_tests/runtime.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
// file: crates/ksp-store-postgres-lib/unit_tests/runtime.rs
|
||||
// version: 3
|
||||
|
||||
fn network() -> ksp_store_api::RawNetworkId {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid backend test network rejected: {error:?}"),
|
||||
};
|
||||
}
|
||||
|
||||
fn settings(connection_uri: &str, tls_mode: crate::PostgresBackendTlsMode) -> crate::PostgresBackendSettings {
|
||||
return crate::PostgresBackendSettings::new(
|
||||
network(),
|
||||
connection_uri,
|
||||
8,
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
tls_mode,
|
||||
true,
|
||||
std::time::Duration::from_secs(30),
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_settings_debug_redacts_connection_uri() {
|
||||
let secret = "postgresql://secret-user:secret-password@localhost/ksp";
|
||||
let value = settings(secret, crate::PostgresBackendTlsMode::VerifyFull);
|
||||
let rendered = format!("{value:?}");
|
||||
assert!(!rendered.contains("secret-user"));
|
||||
assert!(!rendered.contains("secret-password"));
|
||||
assert!(rendered.contains("<redacted>"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_uri_is_rejected_without_retaining_input() {
|
||||
let malformed = "not-a-postgresql-uri-secret-canary";
|
||||
let malformed_result = super::normalized_config(&settings(malformed, crate::PostgresBackendTlsMode::Disabled));
|
||||
let malformed_error = malformed_result.err();
|
||||
assert_eq!(malformed_error.as_ref().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::ConfigInvalid));
|
||||
assert!(!format!("{malformed_error:?}").contains("secret-canary"));
|
||||
let oversized = "x".repeat(super::MAX_CONNECTION_URI_BYTES + 1);
|
||||
let oversized_result = super::normalized_config(&settings(oversized.as_str(), crate::PostgresBackendTlsMode::Disabled));
|
||||
assert_eq!(oversized_result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::ConfigInvalid));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_disabled_policy_overrides_uri_tls_and_connection_controls() {
|
||||
let value = settings(
|
||||
"postgresql://operator:secret@localhost/ksp?sslmode=require&application_name=hostile&connect_timeout=1&sslnegotiation=direct",
|
||||
crate::PostgresBackendTlsMode::Disabled,
|
||||
);
|
||||
let config = match super::normalized_config(&value) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => panic!("valid disabled config rejected: {error:?}"),
|
||||
};
|
||||
assert_eq!(config.get_ssl_mode(), tokio_postgres::config::SslMode::Disable);
|
||||
assert_eq!(config.get_ssl_negotiation(), tokio_postgres::config::SslNegotiation::Postgres);
|
||||
assert_eq!(config.get_application_name(), std::option::Option::Some(super::APPLICATION_NAME));
|
||||
assert_eq!(config.get_connect_timeout(), std::option::Option::Some(&std::time::Duration::from_secs(10)));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_verify_full_policy_forces_tls_and_rejects_hostaddr_only_identity() {
|
||||
let value = settings(
|
||||
"postgresql://operator:secret@localhost/ksp?sslmode=disable&application_name=hostile&connect_timeout=1",
|
||||
crate::PostgresBackendTlsMode::VerifyFull,
|
||||
);
|
||||
let config = match super::normalized_config(&value) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => panic!("valid verify_full config rejected: {error:?}"),
|
||||
};
|
||||
assert_eq!(config.get_ssl_mode(), tokio_postgres::config::SslMode::Require);
|
||||
assert_eq!(config.get_ssl_negotiation(), tokio_postgres::config::SslNegotiation::Postgres);
|
||||
assert_eq!(config.get_application_name(), std::option::Option::Some(super::APPLICATION_NAME));
|
||||
assert_eq!(config.get_connect_timeout(), std::option::Option::Some(&std::time::Duration::from_secs(10)));
|
||||
let hostaddr_only = settings("hostaddr=127.0.0.1 user=operator dbname=ksp", crate::PostgresBackendTlsMode::VerifyFull);
|
||||
let rejected = super::normalized_config(&hostaddr_only);
|
||||
assert_eq!(rejected.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::ConfigInvalid));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let unix_socket = settings("host=/var/run/postgresql user=operator dbname=ksp", crate::PostgresBackendTlsMode::VerifyFull);
|
||||
let unix_rejected = super::normalized_config(&unix_socket);
|
||||
assert_eq!(unix_rejected.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::ConfigInvalid));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libpq_server_options_are_rejected_in_foundation_runtime() {
|
||||
let value = settings("host=localhost user=operator dbname=ksp options='-c statement_timeout=0'", crate::PostgresBackendTlsMode::Disabled);
|
||||
let rejected = super::normalized_config(&value);
|
||||
let error = rejected.err();
|
||||
assert_eq!(error.as_ref().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::ConfigInvalid));
|
||||
assert_eq!(error.map(|value| return value.phase()), std::option::Option::Some("server_options"));
|
||||
return;
|
||||
}
|
||||
201
deltas/0.2.14/pre.001.md
Normal file
201
deltas/0.2.14/pre.001.md
Normal file
@@ -0,0 +1,201 @@
|
||||
<!-- file: deltas/0.2.14/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.14-pre.001` — audit Program API, héritage kbot3 et sizing
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Base directe attendue :
|
||||
|
||||
```text
|
||||
v0.2.13
|
||||
workspace.package.version = 0.2.13
|
||||
```
|
||||
|
||||
Sources obligatoires réellement disponibles à l'ouverture :
|
||||
|
||||
```text
|
||||
archive opérateur khadhroony-solana-project-v0.2.13.zip
|
||||
archive historique khadhroony-bot3_v0.5.3-pre.005-fix010.zip
|
||||
```
|
||||
|
||||
La metadata Git n'est pas incluse dans l'archive opérateur; le tag `v0.2.13` ne peut donc pas être interrogé localement. La version Cargo, `deltas/0.2.13/rel.001.md`, le prompt 019 et la surface Interface publiée concordent avec la base stable attendue.
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.14-pre.001
|
||||
```
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Ouvrir `0.2.14 — Program API foundation` uniquement par le gate prévu :
|
||||
|
||||
```text
|
||||
lecture règles + architecture
|
||||
audit Core + Interface stables
|
||||
audit historique kbot3 réel
|
||||
matrice d'héritage
|
||||
ownership
|
||||
API candidate
|
||||
threat/API model
|
||||
dependency graph
|
||||
stratégie de tests
|
||||
sizing et prévision souple recalibrée
|
||||
```
|
||||
|
||||
Aucun scaffold `ksp-program-api`, decoder concret, registry runtime ou execution preparer n'est ajouté dans cette livraison.
|
||||
|
||||
## 3. Décisions principales
|
||||
|
||||
Le scope est réduit à une foundation instruction-only :
|
||||
|
||||
```text
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome<Decoded>
|
||||
ProgramInstructionDecoder avec associated output
|
||||
```
|
||||
|
||||
Le candidat trait utilise :
|
||||
|
||||
```text
|
||||
&ProgramInstruction
|
||||
&[Pubkey]
|
||||
ksp_core_lib::Result
|
||||
Send + Sync
|
||||
```
|
||||
|
||||
L'output concret appartient à l'implémentation externe ou au futur `ksp-program-lib`.
|
||||
|
||||
Sont explicitement reportés :
|
||||
|
||||
```text
|
||||
payload canonique D3
|
||||
registry runtime hétérogène
|
||||
identity/version/coverage génériques
|
||||
diagnostics/proofs Program dédiés
|
||||
ProgramAccountDecoder / Event / ReturnData
|
||||
ProgramExecutionPreparer
|
||||
```
|
||||
|
||||
Le design n'ajoute aucune dépendance externe et ne requiert donc aucun audit web/Solana supplémentaire en `pre.001`.
|
||||
|
||||
## 4. Héritage kbot3
|
||||
|
||||
L'archive historique a été réellement extraite et les contrats prioritaires ont été relus.
|
||||
|
||||
Conclusions :
|
||||
|
||||
```text
|
||||
REPRENDRE séparation recognition/decode, trait instruction, Send+Sync, préparation pure conceptuelle
|
||||
REDESSINER recognition, outcomes, execution capability/prepared plan
|
||||
REPORTER identity/version, coverage, diagnostics, proofs, replay context, decoded event, registry, preparer
|
||||
REJETER Program IDs/Pubkeys String, generic JSON payload, generic protocol monolith, executor, policy dans Program, ks-lib monolithique
|
||||
```
|
||||
|
||||
Les decoders Solana Core, SPL Token et SPL Token-2022 confirment que l'ancien trait contextualisé dépendait réellement de signature/slot/path/transaction status/hash et JSON. Ces champs appartiennent aux futures couches CORE/DECODE et ne sont pas reconstruits dans Program API.
|
||||
|
||||
## 5. Sizing recalibré
|
||||
|
||||
Prévision active :
|
||||
|
||||
```text
|
||||
pre.001 audit/design/sizing
|
||||
pre.002 scaffold + facade + dependency firewall
|
||||
pre.003 recognition + outcome
|
||||
pre.004 decoder trait + external implementation
|
||||
pre.005 adversarial/API hardening + completeness
|
||||
pre.006 gate technique final
|
||||
pre.007 réconciliation documentaire
|
||||
pre.008 préparation de publication minimale
|
||||
rel.001 publication stable
|
||||
```
|
||||
|
||||
Registry, canonical payload et execution preparation ne sont plus des tranches de `0.2.14`.
|
||||
|
||||
## 6. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
docs/plans/021-V0_2_14_PROGRAM_API_PLAN.md
|
||||
docs/validation/017-V0_2_14_PROGRAM_API.md
|
||||
deltas/0.2.14/pre.001.md
|
||||
```
|
||||
|
||||
## 7. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/plans/000-README.md
|
||||
docs/validation/000-README.md
|
||||
```
|
||||
|
||||
## 8. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 9. Version Cargo
|
||||
|
||||
La prerelease non-fix synchronise :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.14-pre.1
|
||||
```
|
||||
|
||||
Aucune crate Program n'existe encore; le changement Cargo sert uniquement à identifier la tranche `pre.001` conformément au workflow.
|
||||
|
||||
## 10. Validations exécutées pendant la préparation
|
||||
|
||||
Le journal opérateur fourni à l'ouverture sur la base stable `v0.2.13` montre un baseline complet vert avant application de ce delta, notamment audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés des crates et `cargo test --workspace`. Cette preuve de base ne remplace pas le gate après application.
|
||||
|
||||
Dans l'environnement de génération du présent overlay, les contrôles statiques suivants ont été réellement exécutés après modification :
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.14
|
||||
Markdown table audit: clean (170 tables, 116 files)
|
||||
```
|
||||
|
||||
`cargo` n'est pas installé dans l'environnement de génération utilisé pour préparer l'archive. Les commandes Cargo de validation après application n'ont donc pas été rejouées ici et ne sont pas déclarées PASS.
|
||||
|
||||
## 11. Validations non requises dans cette tranche
|
||||
|
||||
```text
|
||||
cargo test -p ksp-program-api crate encore absente
|
||||
cargo tree -p ksp-program-api crate encore absente
|
||||
smokes réseau/live hors scope d'une API déclarative
|
||||
```
|
||||
|
||||
## 12. Questions ouvertes
|
||||
|
||||
Aucune question ne bloque le passage à `pre.002`.
|
||||
|
||||
Les sujets suivants restent ouverts volontairement pour des releases ultérieures, pas pour compléter `0.2.14` :
|
||||
|
||||
```text
|
||||
canonical DECODE/D3 payload
|
||||
runtime registry / conflict policy / erased composition
|
||||
processor identity/version durable
|
||||
coverage descriptors
|
||||
contextual proofs/diagnostics
|
||||
ProgramExecutionPreparer exact
|
||||
```
|
||||
|
||||
## 13. Application et validation opérateur
|
||||
|
||||
Après application de l'overlay :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.14
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Aucun développement fonctionnel Program ne doit être ajouté à ce delta.
|
||||
235
deltas/0.2.14/pre.002.md
Normal file
235
deltas/0.2.14/pre.002.md
Normal file
@@ -0,0 +1,235 @@
|
||||
<!-- file: deltas/0.2.14/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.14-pre.002` — scaffold `ksp-program-api` + façade + firewall
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Cette tranche s'applique exclusivement sur :
|
||||
|
||||
```text
|
||||
v0.2.13
|
||||
+ 0.2.14-pre.001
|
||||
```
|
||||
|
||||
Le gate opérateur fourni pour `pre.001` est vert :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
python3 scripts/audit_markdown_tables.py ... PASS — 170 tables / 116 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS sans warning
|
||||
```
|
||||
|
||||
La version workspace passe de :
|
||||
|
||||
```text
|
||||
0.2.14-pre.1
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.2.14-pre.2
|
||||
```
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.14-pre.002
|
||||
```
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Matérialiser uniquement le scaffold de `ksp-program-api` et verrouiller sa façade/dependency firewall avant toute capability de décodage.
|
||||
|
||||
La tranche ajoute :
|
||||
|
||||
```text
|
||||
nouveau membre workspace ksp-program-api
|
||||
manifest Core + Interface uniquement
|
||||
façade crate-root explicite
|
||||
réexports Core retenus
|
||||
réexports Interface retenus
|
||||
README/USAGE initiaux
|
||||
canari public API
|
||||
canari dependency firewall
|
||||
```
|
||||
|
||||
Elle n'ajoute pas :
|
||||
|
||||
```text
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome
|
||||
ProgramInstructionDecoder
|
||||
ProgramExecutionPreparer
|
||||
registry runtime
|
||||
identity/version/coverage
|
||||
payload canonique D3
|
||||
serde / JSON / Any
|
||||
codec
|
||||
runtime logging
|
||||
network / async runtime
|
||||
```
|
||||
|
||||
## 3. Façade publique de scaffold
|
||||
|
||||
Le crate-root réexporte explicitement :
|
||||
|
||||
```text
|
||||
ksp-core-lib
|
||||
Error
|
||||
ErrorCode
|
||||
ErrorContext
|
||||
Result
|
||||
Pubkey
|
||||
|
||||
ksp-interface-lib
|
||||
ProgramAccountMeta
|
||||
ProgramInstruction
|
||||
```
|
||||
|
||||
Ces réexports ne changent pas l'ownership des types. Ils fournissent la façade retenue par le plan afin qu'une future implémentation externe puisse dépendre du contrat Program sans reconstruire les types Core/Interface ni accéder à leurs modules privés.
|
||||
|
||||
Aucun `pub mod` n'est exposé.
|
||||
|
||||
## 4. Dependency firewall
|
||||
|
||||
Le manifest membre possède exactement :
|
||||
|
||||
```text
|
||||
[dependencies]
|
||||
ksp-core-lib
|
||||
ksp-interface-lib
|
||||
```
|
||||
|
||||
Le canari `tests/dependency_boundary.rs` verrouille notamment l'absence de :
|
||||
|
||||
```text
|
||||
ksp-program-lib
|
||||
ksp-logging-lib
|
||||
Transport / Config / Wallet / Store / Materializer
|
||||
serde / serde_json
|
||||
borsh / bincode / wincode
|
||||
solana-instruction
|
||||
reqwest / tokio / tonic / tauri / tracing
|
||||
```
|
||||
|
||||
Il verrouille aussi l'absence de `src/constants.rs`, `TRACING_TARGET`, recognition/outcome/decoder/preparer et de module public pendant `pre.002`.
|
||||
|
||||
## 5. Logging
|
||||
|
||||
La crate reste une API déclarative sans comportement runtime. Aucun span/event n'est émis et aucun logging n'est nécessaire.
|
||||
|
||||
Restent donc volontairement absents :
|
||||
|
||||
```text
|
||||
ksp-logging-lib
|
||||
src/constants.rs
|
||||
TRACING_TARGET
|
||||
tracing direct
|
||||
```
|
||||
|
||||
Si une future surface Program comportementale nécessite réellement une instrumentation, elle devra respecter les règles Logging KSP dans la crate qui possède ce comportement. Le scaffold API ne doit pas tirer un runtime logging par anticipation.
|
||||
|
||||
## 6. Tests ajoutés
|
||||
|
||||
`tests/public_api.rs` vérifie que la façade crate-root permet de construire `Pubkey`, `ProgramAccountMeta` et `ProgramInstruction`, et que les types d'erreur Core sont adressables sans module privé.
|
||||
|
||||
`tests/dependency_boundary.rs` vérifie :
|
||||
|
||||
```text
|
||||
dépendances normales exactes = Core + Interface
|
||||
absence des dépendances interdites
|
||||
absence de pub mod
|
||||
absence des contrats réservés à pre.003/pre.004
|
||||
absence de logging/runtime/codec
|
||||
```
|
||||
|
||||
Aucun unit test métier n'est ajouté : la crate ne possède encore aucun comportement propre.
|
||||
|
||||
## 7. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/Cargo.toml
|
||||
crates/ksp-program-api/README.md
|
||||
crates/ksp-program-api/USAGE.md
|
||||
crates/ksp-program-api/src/lib.rs
|
||||
crates/ksp-program-api/tests/dependency_boundary.rs
|
||||
crates/ksp-program-api/tests/public_api.rs
|
||||
deltas/0.2.14/pre.002.md
|
||||
```
|
||||
|
||||
## 8. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/plans/021-V0_2_14_PROGRAM_API_PLAN.md
|
||||
docs/validation/017-V0_2_14_PROGRAM_API.md
|
||||
```
|
||||
|
||||
## 9. Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
README.md
|
||||
RULES.md
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
docs/architecture/**
|
||||
docs/rules/**
|
||||
crates/ksp-core-lib/**
|
||||
crates/ksp-interface-lib/**
|
||||
crates/ksp-logging-lib/**
|
||||
crates/ksp-*-transport-lib/**
|
||||
crates/ksp-config-lib/**
|
||||
crates/ksp-wallet-lib/**
|
||||
crates/ksp-app-*/**
|
||||
prompts/**
|
||||
```
|
||||
|
||||
Aucune architecture durable n'est rouverte : la direction `Program API -> Core + Interface` est déjà normative.
|
||||
|
||||
## 10. Validations de génération
|
||||
|
||||
Les audits Python sont rejoués sur l'arbre matérialisé avant livraison. L'environnement de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`; aucun PASS Cargo nouveau n'est revendiqué localement.
|
||||
|
||||
## 11. Gate opérateur attendu
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.14
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-program-api
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-program-api --edges normal
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Le graphe normal doit confirmer :
|
||||
|
||||
```text
|
||||
ksp-program-api
|
||||
├── ksp-core-lib
|
||||
└── ksp-interface-lib
|
||||
└── ksp-core-lib
|
||||
```
|
||||
|
||||
Une correction découverte par ce gate reste un `0.2.14-pre.002-fix.NNN` et n'avance pas `pre.003`.
|
||||
|
||||
## 12. Suite
|
||||
|
||||
Après gate vert, `pre.003` pourra matérialiser uniquement :
|
||||
|
||||
```text
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome<Decoded>
|
||||
invariants / Debug sûr associés
|
||||
unit/public canaries correspondants
|
||||
```
|
||||
|
||||
`ProgramInstructionDecoder` et l'implémentation externe restent réservés à `pre.004`.
|
||||
285
deltas/0.2.14/pre.003.md
Normal file
285
deltas/0.2.14/pre.003.md
Normal file
@@ -0,0 +1,285 @@
|
||||
<!-- file: deltas/0.2.14/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.14-pre.003` — recognition + outcome minimal
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Cette tranche s'applique exclusivement sur :
|
||||
|
||||
```text
|
||||
v0.2.13
|
||||
+ 0.2.14-pre.001
|
||||
+ 0.2.14-pre.002
|
||||
```
|
||||
|
||||
Le gate opérateur fourni pour `pre.002` est intégralement vert :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
python3 scripts/audit_markdown_tables.py ... PASS — 170 tables / 119 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-program-api PASS
|
||||
cargo test --workspace PASS
|
||||
cargo tree -p ksp-program-api --edges normal PASS — Core + Interface uniquement
|
||||
cargo tree --duplicates exécuté
|
||||
```
|
||||
|
||||
La version workspace passe de :
|
||||
|
||||
```text
|
||||
0.2.14-pre.2
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.2.14-pre.3
|
||||
```
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.14-pre.003
|
||||
```
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Matérialiser uniquement le vocabulaire minimal nécessaire au futur decoder d'instruction :
|
||||
|
||||
```text
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome<Decoded>
|
||||
```
|
||||
|
||||
La tranche ne crée toujours aucun comportement de décodage, registry ou contrat d'exécution.
|
||||
|
||||
## 3. Recognition
|
||||
|
||||
`ProgramInstructionRecognition` est public depuis le crate-root, `#[non_exhaustive]` et possède exactement :
|
||||
|
||||
```text
|
||||
NoMatch
|
||||
ProgramMatch
|
||||
ExactMatch
|
||||
```
|
||||
|
||||
Sémantique :
|
||||
|
||||
```text
|
||||
NoMatch l'implémentation ne revendique pas l'instruction
|
||||
ProgramMatch le Program ou la famille correspond sans preuve instruction-locale exacte
|
||||
ExactMatch l'implémentation affirme un match instruction-local exact
|
||||
```
|
||||
|
||||
Aucun score, priorité, confidence, proof, surface code ou discriminator textuel n'est introduit.
|
||||
|
||||
## 4. Decode outcome
|
||||
|
||||
`ProgramInstructionDecodeOutcome<Decoded>` est public depuis le crate-root, `#[non_exhaustive]` et possède exactement :
|
||||
|
||||
```text
|
||||
Decoded(Decoded)
|
||||
Unsupported
|
||||
```
|
||||
|
||||
La valeur `Decoded` reste possédée par l'implémentation future. Il n'existe aucun `Any`, JSON, payload D3 ou enum centrale pour l'effacer.
|
||||
|
||||
`Failed` est volontairement absent : le futur `ProgramInstructionDecoder::decode` retournera le `Result` Core. Une erreur réelle sera donc `Err`, sans deuxième canal de failure.
|
||||
|
||||
`Ignored` reste absent : une capability de décodage doit produire une valeur, déclarer l'instruction connue mais unsupported, ou échouer.
|
||||
|
||||
## 5. Debug sûr
|
||||
|
||||
`ProgramInstructionRecognition` ne transporte aucun payload.
|
||||
|
||||
`ProgramInstructionDecodeOutcome<Decoded>` possède une implémentation `Debug` manuelle :
|
||||
|
||||
```text
|
||||
Decoded(_) -> "Decoded"
|
||||
Unsupported -> "Unsupported"
|
||||
```
|
||||
|
||||
Cette implémentation :
|
||||
|
||||
```text
|
||||
n'impose pas Decoded: Debug
|
||||
ne formate jamais la valeur Decoded
|
||||
ne copie aucun payload externe
|
||||
reste bornée à un nom de variante fixe
|
||||
```
|
||||
|
||||
Le test unitaire utilise volontairement un type externe sans implémentation `Debug` pour prouver cette propriété à la compilation.
|
||||
|
||||
## 6. Structure et façade
|
||||
|
||||
Un seul module privé est ajouté :
|
||||
|
||||
```text
|
||||
src/program_instruction_decode.rs
|
||||
```
|
||||
|
||||
Le crate-root réexporte :
|
||||
|
||||
```text
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome
|
||||
```
|
||||
|
||||
Aucun `pub mod` n'est introduit. Les réexports Core/Interface de `pre.002` restent inchangés.
|
||||
|
||||
## 7. Dependency firewall
|
||||
|
||||
Le manifest de `ksp-program-api` est inchangé :
|
||||
|
||||
```text
|
||||
ksp-core-lib
|
||||
ksp-interface-lib
|
||||
```
|
||||
|
||||
Restent interdits et absents :
|
||||
|
||||
```text
|
||||
ksp-program-lib
|
||||
ksp-logging-lib
|
||||
Transport / Config / Wallet / Store / Materializer
|
||||
serde / serde_json
|
||||
borsh / bincode / wincode
|
||||
solana-instruction
|
||||
reqwest / tokio / tonic / tauri / tracing
|
||||
```
|
||||
|
||||
Aucun `constants.rs` ou `TRACING_TARGET` n'est justifié pour ces types déclaratifs.
|
||||
|
||||
## 8. Tests
|
||||
|
||||
### Unit
|
||||
|
||||
`unit_tests/program_instruction_decode.rs` vérifie :
|
||||
|
||||
```text
|
||||
variants Recognition distincts
|
||||
Debug Recognition exact et payload-free
|
||||
Decoded conserve sa valeur
|
||||
Unsupported reste distinct
|
||||
Debug outcome sans Decoded: Debug
|
||||
Debug outcome ne rend pas la valeur externe
|
||||
```
|
||||
|
||||
### Public API
|
||||
|
||||
`tests/public_api.rs` ajoute un canari `pre.003` consommant uniquement :
|
||||
|
||||
```text
|
||||
ksp_program_api::ProgramInstructionRecognition
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome
|
||||
```
|
||||
|
||||
### Boundary
|
||||
|
||||
`tests/dependency_boundary.rs` est avancé pour autoriser uniquement recognition/outcome tout en maintenant l'absence de trait decoder, preparer, logging, serde et module public.
|
||||
|
||||
## 9. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/src/program_instruction_decode.rs
|
||||
crates/ksp-program-api/unit_tests/program_instruction_decode.rs
|
||||
deltas/0.2.14/pre.003.md
|
||||
```
|
||||
|
||||
## 10. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-program-api/README.md
|
||||
crates/ksp-program-api/USAGE.md
|
||||
crates/ksp-program-api/src/lib.rs
|
||||
crates/ksp-program-api/tests/dependency_boundary.rs
|
||||
crates/ksp-program-api/tests/public_api.rs
|
||||
docs/plans/021-V0_2_14_PROGRAM_API_PLAN.md
|
||||
docs/validation/017-V0_2_14_PROGRAM_API.md
|
||||
```
|
||||
|
||||
## 11. Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/Cargo.toml
|
||||
README.md
|
||||
RULES.md
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
docs/architecture/**
|
||||
docs/rules/**
|
||||
crates/ksp-core-lib/**
|
||||
crates/ksp-interface-lib/**
|
||||
crates/ksp-logging-lib/**
|
||||
crates/ksp-*-transport-lib/**
|
||||
crates/ksp-config-lib/**
|
||||
crates/ksp-wallet-lib/**
|
||||
crates/ksp-app-*/**
|
||||
prompts/**
|
||||
```
|
||||
|
||||
## 12. Scope négatif maintenu
|
||||
|
||||
Cette tranche n'introduit pas :
|
||||
|
||||
```text
|
||||
ProgramInstructionDecoder
|
||||
program_ids(...)
|
||||
recognize(...) sur un trait
|
||||
decode(...) sur un trait
|
||||
associated output contract du trait
|
||||
external implementation fixture
|
||||
registry runtime
|
||||
identity/version/coverage
|
||||
ProgramAccountDecoder / Event / ReturnData
|
||||
payload canonique D3
|
||||
ProgramExecutionPreparer
|
||||
```
|
||||
|
||||
Ces éléments ne doivent pas être anticipés dans un fix de `pre.003`.
|
||||
|
||||
## 13. Gate opérateur attendu
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.14
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-program-api
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-program-api --edges normal
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Le graphe normal doit rester :
|
||||
|
||||
```text
|
||||
ksp-program-api
|
||||
├── ksp-core-lib
|
||||
└── ksp-interface-lib
|
||||
└── ksp-core-lib
|
||||
```
|
||||
|
||||
Une correction découverte par ce gate reste un `0.2.14-pre.003-fix.NNN` et n'avance pas `pre.004`.
|
||||
|
||||
## 14. Suite
|
||||
|
||||
Après gate vert, `pre.004` pourra introduire uniquement :
|
||||
|
||||
```text
|
||||
ProgramInstructionDecoder: Send + Sync
|
||||
associated type Decoded
|
||||
program_ids(&self) -> &[Pubkey]
|
||||
recognize(&self, &ProgramInstruction)
|
||||
decode(&self, &ProgramInstruction) -> Result<ProgramInstructionDecodeOutcome<Self::Decoded>>
|
||||
external implementation canary avec Pubkey non enregistré
|
||||
```
|
||||
|
||||
Registry runtime, payload canonique D3 et `ProgramExecutionPreparer` resteront hors scope.
|
||||
264
deltas/0.2.14/pre.004.md
Normal file
264
deltas/0.2.14/pre.004.md
Normal file
@@ -0,0 +1,264 @@
|
||||
<!-- file: deltas/0.2.14/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.14-pre.004` — decoder instruction-only + implémentation externe
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Cette tranche s'applique exclusivement sur :
|
||||
|
||||
```text
|
||||
v0.2.13
|
||||
+ 0.2.14-pre.001
|
||||
+ 0.2.14-pre.002
|
||||
+ 0.2.14-pre.003
|
||||
```
|
||||
|
||||
Le gate opérateur fourni pour `pre.003` est intégralement vert :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
python3 scripts/audit_markdown_tables.py ... PASS — 171 tables / 120 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-program-api PASS — 8 tests Rust
|
||||
cargo test --workspace PASS
|
||||
cargo tree -p ksp-program-api --edges normal PASS — Core + Interface uniquement
|
||||
cargo tree --duplicates exécuté
|
||||
```
|
||||
|
||||
La version workspace passe de :
|
||||
|
||||
```text
|
||||
0.2.14-pre.3
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.2.14-pre.4
|
||||
```
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.2.14-pre.004
|
||||
```
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Matérialiser le contrat instruction-only ouvert retenu par `pre.001` :
|
||||
|
||||
```text
|
||||
ProgramInstructionDecoder: Send + Sync
|
||||
associated type Decoded
|
||||
program_ids(&self) -> &[Pubkey]
|
||||
recognize(&self, &ProgramInstruction) -> ProgramInstructionRecognition
|
||||
decode(&self, &ProgramInstruction) -> Result<ProgramInstructionDecodeOutcome<Self::Decoded>>
|
||||
```
|
||||
|
||||
Puis prouver qu'une crate consommatrice séparée peut l'implémenter avec son propre type décodé et un Program `Pubkey` absent du registry Core.
|
||||
|
||||
## 3. Trait `ProgramInstructionDecoder`
|
||||
|
||||
Le trait est défini dans un module privé et réexporté depuis le crate-root.
|
||||
|
||||
Propriétés :
|
||||
|
||||
```text
|
||||
Send + Sync requis sur l'implémentation
|
||||
Decoded associated type sans bound imposé
|
||||
program_ids slice de Pubkey opaques
|
||||
recognize sélection instruction-local explicite
|
||||
decode Result Core + outcome générique
|
||||
méthodes par défaut aucune
|
||||
registry / object composition aucune promesse
|
||||
```
|
||||
|
||||
`decode` est destiné à une instruction déjà sélectionnée via `program_ids` / `recognize`; il ne remplace pas le signal de reconnaissance.
|
||||
|
||||
## 4. Open-world Program IDs
|
||||
|
||||
Aucun `ProgramKind`, enum centrale ou validation contre le registry Core n'est introduit.
|
||||
|
||||
Le canari externe utilise :
|
||||
|
||||
```text
|
||||
Pubkey::new_from_array([0xE7; 32])
|
||||
```
|
||||
|
||||
et vérifie explicitement :
|
||||
|
||||
```text
|
||||
ksp_core_lib::find_program_pubkey(&external_program_id) == None
|
||||
```
|
||||
|
||||
Cette consultation du registry est limitée au test négatif. `ksp-program-api` ne réexporte pas `find_program_pubkey` et le decoder externe n'a besoin que de la façade Program API pour son implémentation.
|
||||
|
||||
## 5. Associated output externe
|
||||
|
||||
Le test d'intégration définit hors du code de production :
|
||||
|
||||
```text
|
||||
ExternalDecodedInstruction
|
||||
ExternalProgramDecoder
|
||||
```
|
||||
|
||||
`ExternalProgramDecoder` implémente le trait et produit :
|
||||
|
||||
```text
|
||||
ExactMatch pour l'opcode supporté
|
||||
ProgramMatch pour le Program connu avec opcode non supporté
|
||||
NoMatch pour un autre Program
|
||||
Decoded(...) pour l'opcode supporté
|
||||
Unsupported pour le Program connu mais non supporté
|
||||
```
|
||||
|
||||
Aucun `Any`, JSON, serde, codec ou enum centrale n'intervient dans le transport du type décodé.
|
||||
|
||||
## 6. Dependency firewall
|
||||
|
||||
Le manifest de `ksp-program-api` reste inchangé :
|
||||
|
||||
```text
|
||||
ksp-core-lib
|
||||
ksp-interface-lib
|
||||
```
|
||||
|
||||
Toujours absents :
|
||||
|
||||
```text
|
||||
ksp-program-lib
|
||||
ksp-logging-lib
|
||||
Transport / Config / Wallet / Store / Materializer
|
||||
serde / serde_json
|
||||
borsh / bincode / wincode
|
||||
solana-instruction
|
||||
reqwest / tokio / tonic / tauri / tracing
|
||||
```
|
||||
|
||||
## 7. Tests
|
||||
|
||||
### Public API
|
||||
|
||||
`tests/public_api.rs` prouve que le trait est implémentable depuis la façade crate-root sans module privé.
|
||||
|
||||
### External implementation
|
||||
|
||||
`tests/external_implementation.rs` est compilé par Cargo comme crate d'intégration séparée et vérifie :
|
||||
|
||||
```text
|
||||
Send + Sync de l'implémentation
|
||||
associated output tiers
|
||||
Program Pubkey non enregistré
|
||||
program_ids
|
||||
NoMatch / ProgramMatch / ExactMatch
|
||||
Decoded / Unsupported
|
||||
absence de ksp-program-lib
|
||||
```
|
||||
|
||||
### Boundary
|
||||
|
||||
`tests/dependency_boundary.rs` avance l'inventaire autorisé jusqu'au trait et maintient l'absence de preparer, logging, serde, `Any` et module public.
|
||||
|
||||
## 8. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/src/program_instruction_decoder.rs
|
||||
crates/ksp-program-api/tests/external_implementation.rs
|
||||
deltas/0.2.14/pre.004.md
|
||||
```
|
||||
|
||||
## 9. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-program-api/README.md
|
||||
crates/ksp-program-api/USAGE.md
|
||||
crates/ksp-program-api/src/lib.rs
|
||||
crates/ksp-program-api/tests/dependency_boundary.rs
|
||||
crates/ksp-program-api/tests/public_api.rs
|
||||
docs/plans/021-V0_2_14_PROGRAM_API_PLAN.md
|
||||
docs/validation/017-V0_2_14_PROGRAM_API.md
|
||||
```
|
||||
|
||||
## 10. Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/Cargo.toml
|
||||
README.md
|
||||
RULES.md
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
docs/architecture/**
|
||||
docs/rules/**
|
||||
crates/ksp-core-lib/**
|
||||
crates/ksp-interface-lib/**
|
||||
crates/ksp-logging-lib/**
|
||||
crates/ksp-*-transport-lib/**
|
||||
crates/ksp-config-lib/**
|
||||
crates/ksp-wallet-lib/**
|
||||
crates/ksp-app-*/**
|
||||
prompts/**
|
||||
```
|
||||
|
||||
## 11. Scope négatif maintenu
|
||||
|
||||
Cette tranche n'introduit pas :
|
||||
|
||||
```text
|
||||
runtime decoder registry
|
||||
Vec<Box<dyn ProgramInstructionDecoder>>
|
||||
object-safety promise
|
||||
identity/version/coverage descriptor
|
||||
priority/conflict policy
|
||||
ProgramAccountDecoder / Event / ReturnData
|
||||
payload canonique D3
|
||||
serde / JSON / Any
|
||||
proof/confidence contextuels
|
||||
ProgramExecutionPreparer
|
||||
ExecutionPolicy / Execution
|
||||
```
|
||||
|
||||
## 12. Gate opérateur attendu
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.14
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-program-api
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-program-api --edges normal
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Le graphe normal doit rester :
|
||||
|
||||
```text
|
||||
ksp-program-api
|
||||
├── ksp-core-lib
|
||||
└── ksp-interface-lib
|
||||
└── ksp-core-lib
|
||||
```
|
||||
|
||||
Une correction découverte par ce gate reste un `0.2.14-pre.004-fix.NNN` et n'avance pas `pre.005`.
|
||||
|
||||
## 13. Suite
|
||||
|
||||
Après gate vert, `pre.005` doit rester une tranche de hardening/API completeness :
|
||||
|
||||
```text
|
||||
bounds et sécurité Debug/error
|
||||
inventaire exact des exports publics
|
||||
absence de closed-world Program enum
|
||||
absence de serde/codec/logging/runtime
|
||||
scope négatif registry/preparer/payload D3
|
||||
test de complétude release
|
||||
```
|
||||
|
||||
Aucun nouveau contrat fonctionnel n'est prévu dans `pre.005`.
|
||||
128
deltas/0.2.14/pre.005-fix.001.md
Normal file
128
deltas/0.2.14/pre.005-fix.001.md
Normal file
@@ -0,0 +1,128 @@
|
||||
<!-- file: deltas/0.2.14/pre.005-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.14-pre.005-fix.001` — faux positif du canari logging
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Ce correctif s'applique exclusivement après :
|
||||
|
||||
```text
|
||||
0.2.14-pre.005
|
||||
```
|
||||
|
||||
Le gate opérateur du 28 août 2026 confirme :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
audit Rust général / exports / workspace PASS
|
||||
audit Markdown PASS — 173 tables / 122 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-program-api PASS — 18 tests Rust
|
||||
cargo test --workspace FAIL — ksp-logging-lib/tests/ownership.rs
|
||||
```
|
||||
|
||||
L'unique échec observé est :
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/tests/release_completeness.rs bypasses ksp-logging-lib via tracing
|
||||
```
|
||||
|
||||
## 2. Diagnostic
|
||||
|
||||
`release_completeness.rs` doit vérifier que les sources de production Program API ne contiennent aucun chemin direct vers tracing. Le canari écrivait cependant lui-même ce chemin sous forme de chaîne littérale dans sa liste de motifs interdits.
|
||||
|
||||
Le scanner workspace de `ksp-logging-lib/tests/ownership.rs` inspecte tous les fichiers Rust des autres crates, tests d'intégration compris, et recherche textuellement ce chemin. Il classait donc le motif de test lui-même comme un bypass alors qu'aucune dépendance, importation ou utilisation de tracing n'existe dans `ksp-program-api`.
|
||||
|
||||
Le graphe normal observé reste :
|
||||
|
||||
```text
|
||||
ksp-program-api
|
||||
├── ksp-core-lib
|
||||
└── ksp-interface-lib
|
||||
└── ksp-core-lib
|
||||
```
|
||||
|
||||
## 3. Correction
|
||||
|
||||
Le canari conserve exactement la même interdiction sur les sources de production, mais construit désormais le motif recherché à partir de deux fragments. Son propre fichier ne contient donc plus le chemin direct littéral que le scanner logging doit légitimement refuser.
|
||||
|
||||
Aucune règle logging n'est contournée ou affaiblie. Le scanner de `ksp-logging-lib` reste inchangé.
|
||||
|
||||
Comme un fichier `.rs` est modifié, `VER-ID-007` et `VER-ID-010` imposent :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.14-pre.5.fix.1
|
||||
```
|
||||
|
||||
La livraison est :
|
||||
|
||||
```text
|
||||
0.2.14-pre.005-fix.001
|
||||
```
|
||||
|
||||
## 4. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-program-api/tests/release_completeness.rs
|
||||
```
|
||||
|
||||
## 5. Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.2.14/pre.005-fix.001.md
|
||||
```
|
||||
|
||||
## 6. Fichiers volontairement inchangés
|
||||
|
||||
```text
|
||||
crates/ksp-program-api/src/**
|
||||
crates/ksp-program-api/Cargo.toml
|
||||
crates/ksp-program-api/tests/security_hardening.rs
|
||||
crates/ksp-logging-lib/**
|
||||
docs/plans/**
|
||||
docs/validation/**
|
||||
README.md
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
prompts/**
|
||||
```
|
||||
|
||||
## 7. Scope négatif
|
||||
|
||||
Ce fix n'ajoute aucun :
|
||||
|
||||
```text
|
||||
contrat Program
|
||||
trait ou type public
|
||||
dependency
|
||||
logging runtime
|
||||
registry
|
||||
descriptor
|
||||
codec
|
||||
payload D3
|
||||
preparer
|
||||
```
|
||||
|
||||
## 8. Validations de génération
|
||||
|
||||
Les audits statiques disponibles dans l'environnement de génération doivent rester propres. Cargo n'y est pas disponible ; aucun PASS Cargo n'est revendiqué par ce delta.
|
||||
|
||||
## 9. Gate opérateur attendu
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.14
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-program-api
|
||||
cargo test -p ksp-logging-lib --test ownership
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-program-api --edges normal
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Si ce gate est vert, `pre.005` est considéré corrigé et `pre.006` peut ouvrir le gate technique final sans développement fonctionnel.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user