Compare commits
35 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 |
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
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,8 +1,26 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 18 -->
|
||||
<!-- 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.
|
||||
|
||||
16
Cargo.toml
16
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 322
|
||||
# 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-program-api", "crates/ksp-wallet-lib"]
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.14"
|
||||
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 }
|
||||
|
||||
59
ROADMAP.md
59
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 92 -->
|
||||
<!-- version: 95 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -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("../..");
|
||||
}
|
||||
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;
|
||||
}
|
||||
190
deltas/0.3.1/pre.001-fix.001.md
Normal file
190
deltas/0.3.1/pre.001-fix.001.md
Normal file
@@ -0,0 +1,190 @@
|
||||
<!-- file: deltas/0.3.1/pre.001-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.001-fix.001` — split façade Store / backend PostgreSQL et roadmap
|
||||
|
||||
## 1. Base
|
||||
|
||||
```text
|
||||
livraison : 0.3.1-pre.001
|
||||
Cargo : 0.3.1-pre.1
|
||||
```
|
||||
|
||||
Ce correctif reste dans le couloir de conception/audit de `pre.001`. Il ne crée aucune crate Store, aucun code runtime, aucune Config exécutable, aucune migration et aucune dependency PostgreSQL.
|
||||
|
||||
## 2. Motif opérateur
|
||||
|
||||
Le brainstorming postérieur à `pre.001` affine le split Store :
|
||||
|
||||
```text
|
||||
0.3.1
|
||||
ksp-store-api uniquement
|
||||
|
||||
0.3.2
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
```
|
||||
|
||||
`ksp-store-api` reste propriétaire du modèle objet/struct commun RAW/observations et des contrats/capabilities backend. `ksp-store-lib` devient la façade/runtime commune consommée par les jobs/workers/apps. Les backends sont des crates séparées importées optionnellement par `ksp-store-lib` selon les features compilées.
|
||||
|
||||
## 3. Graphe corrigé
|
||||
|
||||
Cible durable :
|
||||
|
||||
```text
|
||||
ksp-store-api
|
||||
^ ^
|
||||
| |
|
||||
ksp-store-lib ksp-store-postgres-lib
|
||||
| ^
|
||||
| feature postgres|
|
||||
+-----------------+
|
||||
|
||||
futur ksp-store-mysql-lib -> ksp-store-api
|
||||
ksp-store-lib[mysql] -> futur ksp-store-mysql-lib
|
||||
```
|
||||
|
||||
Règles :
|
||||
|
||||
```text
|
||||
ksp-store-postgres-lib -X-> ksp-store-lib
|
||||
backend alternatif -X-> ksp-store-lib
|
||||
consumer métier -X-> crate backend directe
|
||||
consumer métier -> ksp-store-lib uniquement côté Store
|
||||
```
|
||||
|
||||
`ksp-store-lib` réexportera la surface commune de `ksp-store-api` nécessaire aux consumers afin qu'un worker/job n'ait pas à dépendre séparément de l'API et du backend.
|
||||
|
||||
## 4. Features et sélection runtime
|
||||
|
||||
`0.3.2` introduira :
|
||||
|
||||
```text
|
||||
default = [postgres]
|
||||
postgres -> dep:ksp-store-postgres-lib
|
||||
```
|
||||
|
||||
Les futures features backend pourront être compilées simultanément. La feature contrôle la **disponibilité dans le binaire** ; Config contrôle le **backend actif**.
|
||||
|
||||
Un appel avec une Config demandant un backend KSP connu mais non présent dans les features compilées doit être rejeté explicitement, avec un code stable de type :
|
||||
|
||||
```text
|
||||
STORE_BACKEND_NOT_COMPILED
|
||||
```
|
||||
|
||||
Aucun fallback silencieux vers PostgreSQL n'est permis.
|
||||
|
||||
## 5. Config, URI, secrets et `.env`
|
||||
|
||||
La responsabilité reste conforme aux règles Config KSP :
|
||||
|
||||
```text
|
||||
ksp-config-lib
|
||||
= documents + schemas + placeholders + .env + provenance/sensitivity
|
||||
|
||||
ksp-store-lib
|
||||
= settings runtime + backend dispatch
|
||||
|
||||
ksp-store-*-lib
|
||||
= connexion/persistence backend
|
||||
```
|
||||
|
||||
Pour un backend disposant d'une URI/DSN naturelle, la Config utilisera une URI plutôt qu'une décomposition artificielle, sauf besoin backend réellement justifié. Des options backend-specific peuvent être ajoutées sous forme typée lorsque nécessaire.
|
||||
|
||||
Invariants :
|
||||
|
||||
- une URI pouvant contenir user/password/token est `Secret` dans son ensemble ;
|
||||
- les credentials proviennent de placeholders `KSP_SECRET_*` résolus par `ksp-config-lib` ;
|
||||
- toute nouvelle variable apparaît commentée dans `.env.example` lors de sa première utilisation réelle ;
|
||||
- le vrai `.env` et les vrais URI/credentials ne sont jamais versionnés ;
|
||||
- Store et backend ne lisent jamais directement `.env` ni les variables KSP/KSPB ;
|
||||
- URI/DSN/password/token restent absents des `Debug`, logs, erreurs et health ;
|
||||
- l'absence de feature backend est détectée avant toute connexion ou fallback driver implicite.
|
||||
|
||||
La forme exacte de `std.store`, du nom des variables et des settings runtime est reportée à `0.3.2`.
|
||||
|
||||
## 6. ROADMAP corrigé
|
||||
|
||||
La séquence devient :
|
||||
|
||||
```text
|
||||
0.3.1 ksp-store-api RAW/observations
|
||||
0.3.2 ksp-store-lib + ksp-store-postgres-lib
|
||||
0.3.3 wires Interface nécessaires aux acquisitions/normalisation future
|
||||
0.3.4 ksp-job-api + premier backfill historique
|
||||
0.3.5 application backfill/inspection RAW
|
||||
```
|
||||
|
||||
La ligne `0.3.1` passe en cours et n'annonce plus de persistence PostgreSQL dans cette release.
|
||||
|
||||
## 7. Version Cargo
|
||||
|
||||
Ce correctif est strictement documentaire. Conformément à `VER-ID-008`, `workspace.package.version` reste :
|
||||
|
||||
```text
|
||||
0.3.1-pre.1
|
||||
```
|
||||
|
||||
## 8. Fichiers modifiés
|
||||
|
||||
```text
|
||||
ROADMAP.md
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## 9. Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.3.1/pre.001-fix.001.md
|
||||
```
|
||||
|
||||
## 10. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 11. Validations exécutées
|
||||
|
||||
Dans l'environnement de génération du fix :
|
||||
|
||||
```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.3.1
|
||||
Markdown table audit: clean (184 table(s), 122 file(s))
|
||||
```
|
||||
|
||||
`cargo` n'est pas disponible dans cet environnement. Comme le fix ne modifie aucun fichier de code/build/runtime/config exécutable, aucune version Cargo n'est changée ; le gate opérateur reste néanmoins :
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Aucun test Store ciblé n'existe encore et aucun gate PostgreSQL n'est requis dans ce fix documentaire.
|
||||
|
||||
## 12. Questions ouvertes
|
||||
|
||||
Aucune question ne bloque `0.3.1-pre.002`.
|
||||
|
||||
Restent volontairement reportés à `0.3.2` :
|
||||
|
||||
```text
|
||||
forme exacte de StoreSettings
|
||||
shape du document std.store
|
||||
nom exact des variables KSP_SECRET_* Store
|
||||
URI/DSN PostgreSQL exact accepté
|
||||
pool/TLS/timeouts
|
||||
migrations/schema/indexes
|
||||
mapping PostgreSQL des modèles API
|
||||
```
|
||||
|
||||
Ces détails ne changent pas les frontières déjà fixées par le présent correctif.
|
||||
194
deltas/0.3.1/pre.001-fix.002.md
Normal file
194
deltas/0.3.1/pre.001-fix.002.md
Normal file
@@ -0,0 +1,194 @@
|
||||
<!-- file: deltas/0.3.1/pre.001-fix.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.001-fix.002` — taxonomie N1, STRUCTURAL et lifecycle RAW
|
||||
|
||||
## 1. Base
|
||||
|
||||
```text
|
||||
livraison : 0.3.1-pre.001-fix.001
|
||||
Cargo : 0.3.1-pre.1
|
||||
```
|
||||
|
||||
Le gate opérateur post-`fix.001` fourni est vert pour `cargo fmt --all`, audits Rust/Markdown, `cargo check --workspace` et `cargo clippy --workspace --all-targets`. L'opérateur indique également que la validation des tests est OK.
|
||||
|
||||
Ce correctif reste strictement documentaire : aucune crate, API Rust, Config, dependency, migration ou runtime Store n'est créé.
|
||||
|
||||
## 2. Motif
|
||||
|
||||
Le brainstorming avant `pre.002` révèle que la taxonomie précédente était encore trop proche d'un pipeline linéaire et traitait à tort les logs transactionnels et `logsSubscribe` comme une même famille persistante.
|
||||
|
||||
Le correctif doit aussi préserver deux besoins futurs avant qu'une API publique ne les rende difficiles à ajouter :
|
||||
|
||||
```text
|
||||
frontière claire ksp-interface-lib / ksp-store-api
|
||||
processing versionné + retention/compaction/archive/purge du RAW
|
||||
```
|
||||
|
||||
## 3. Taxonomie N1 corrigée
|
||||
|
||||
Règle d'admission : deux réponses HTTP/WS/gRPC/provider convergent vers le même modèle N1 seulement si elles satisfont intégralement la même sémantique KSP sans perte.
|
||||
|
||||
Classification courante :
|
||||
|
||||
```text
|
||||
RawTransaction + observation
|
||||
= N1 persistant certain
|
||||
|
||||
transaction logMessages
|
||||
= partie de RawTransaction ; extraction future en N2 STRUCTURAL
|
||||
|
||||
RawAccountState + observation
|
||||
= modèle N1 à prévoir après matrice de compatibilité
|
||||
|
||||
TransactionStatusObservation
|
||||
= modèle/observation à explorer et prévoir si la sémantique converge
|
||||
|
||||
logsSubscribe / slot / vote
|
||||
= event-only candidats ; pas de persistence Store par défaut
|
||||
|
||||
RawBlock
|
||||
= IDEA seulement ; getBlock sert d'abord de conteneur d'acquisition de transactions
|
||||
|
||||
Yellowstone Entry
|
||||
= examiné et non retenu actuellement
|
||||
```
|
||||
|
||||
## 4. N2 renommé STRUCTURAL
|
||||
|
||||
La chaîne historique :
|
||||
|
||||
```text
|
||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
```
|
||||
|
||||
est remplacée comme vocabulaire cible par :
|
||||
|
||||
```text
|
||||
RAW -> STRUCTURAL -> DECODED -> DOMAIN
|
||||
```
|
||||
|
||||
N2 décrit une décomposition structurelle, pas un « Core » métier. Le premier cas certain est `RawTransaction -> STRUCTURAL` avec transaction/message, account refs, instructions top-level, CPI/inner instructions, logs/meta/balances/return data.
|
||||
|
||||
Toutes les familles N1 ne sont pas obligées de passer par N2.
|
||||
|
||||
## 5. Frontière Interface / Store
|
||||
|
||||
La règle fixée est :
|
||||
|
||||
```text
|
||||
persistant / replayable / queryable
|
||||
-> ksp-store-api
|
||||
|
||||
observation durable d'un fait Store
|
||||
-> ksp-store-api
|
||||
|
||||
event-only passif inter-composants
|
||||
-> ksp-interface-lib de préférence
|
||||
|
||||
format canonique « donnée persistée disponible »
|
||||
-> ksp-store-api, publication par worker/runtime après commit
|
||||
|
||||
shape HTTP/WS/gRPC/provider
|
||||
-> Transport seulement
|
||||
```
|
||||
|
||||
Un modèle event-only n'implique jamais automatiquement une capability Store. Les conversions restent explicites lorsque les sémantiques diffèrent.
|
||||
|
||||
## 6. Processing et lifecycle RAW
|
||||
|
||||
L'audit des documents kbot2 embarqués dans l'archive kbot3 retrouve :
|
||||
|
||||
```text
|
||||
full -> compacted -> archived -> purged
|
||||
```
|
||||
|
||||
et un replay piloté par une identité de processing incluant stage, processor/version et input hash.
|
||||
|
||||
KSP reprend les concepts en les redessinant :
|
||||
|
||||
- aucun `processed: bool` ne constitue la preuve durable unique ;
|
||||
- le futur processing ledger doit être version-aware et input-hash-aware ;
|
||||
- la policy d'éligibilité à la rétention appartient à un worker/job/maintenance layer, pas au Store ;
|
||||
- le Store applique uniquement une transition logique/atomique demandée ;
|
||||
- un tombstone minimal reste après purge afin qu'un backfill normal n'acquière pas de nouveau la même transaction ;
|
||||
- une réhydratation après purge exige un mode explicitement forcé ;
|
||||
- les policies de rétention peuvent différer selon la famille RAW.
|
||||
|
||||
Le payload RAW n'a donc pas vocation à rester éternellement en stockage chaud lorsque les couches dérivées et la policy active permettent sa compaction/archive/purge.
|
||||
|
||||
## 7. ROADMAP
|
||||
|
||||
`ROADMAP.md` est corrigé pour :
|
||||
|
||||
- remplacer CORE par STRUCTURAL dans la progression future ;
|
||||
- rappeler que les niveaux ne sont pas obligatoires pour toutes les familles ;
|
||||
- préciser `0.3.1` avec admission cross-source, events non persistés et lifecycle/tombstone ;
|
||||
- ajouter un bloc TODO/IDEAS N1/processing/rétention ;
|
||||
- conserver `0.3.2 = ksp-store-lib + ksp-store-postgres-lib` inchangé.
|
||||
|
||||
## 8. Version Cargo
|
||||
|
||||
Fix documentaire uniquement. Conformément au workflow, `workspace.package.version` reste :
|
||||
|
||||
```text
|
||||
0.3.1-pre.1
|
||||
```
|
||||
|
||||
## 9. Fichiers modifiés
|
||||
|
||||
```text
|
||||
ROADMAP.md
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## 10. Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.3.1/pre.001-fix.002.md
|
||||
```
|
||||
|
||||
## 11. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 12. Validations exécutées
|
||||
|
||||
Dans l'environnement de génération du fix :
|
||||
|
||||
```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.3.1
|
||||
Markdown table audit: clean (184 table(s), 123 file(s))
|
||||
```
|
||||
|
||||
`cargo` n'est pas disponible dans cet environnement. Le fix est strictement documentaire et la base opérateur post-`fix.001` a déjà passé `cargo fmt`, `cargo check` et Clippy. Après application du présent fix, le gate opérateur reste :
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Aucun code n'est modifié et aucun test Store ciblé n'existe encore.
|
||||
|
||||
## 13. Suite
|
||||
|
||||
`pre.002` peut ensuite créer le scaffold `ksp-store-api` avec une taxonomie désormais suffisamment contrainte pour éviter de figer :
|
||||
|
||||
```text
|
||||
un faux RawLog persistant
|
||||
un pipeline N1->N2 obligatoire
|
||||
un processed bool irréversible
|
||||
une retention éternelle du RAW
|
||||
une dépendance implicite entre event runtime et Store
|
||||
```
|
||||
296
deltas/0.3.1/pre.001.md
Normal file
296
deltas/0.3.1/pre.001.md
Normal file
@@ -0,0 +1,296 @@
|
||||
<!-- file: deltas/0.3.1/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.001` — audit Store API, N1 RAW et split PostgreSQL
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Base directe attendue :
|
||||
|
||||
```text
|
||||
v0.2.14
|
||||
workspace.package.version = 0.2.14
|
||||
```
|
||||
|
||||
Sources obligatoires réellement disponibles à l'ouverture :
|
||||
|
||||
```text
|
||||
archive opérateur khadhroony-solana-project-v0.2.14.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.14` ne peut donc pas être interrogé localement. La version Cargo, `deltas/0.2.14/rel.001.md`, le prompt 020 et la surface Program API publiée concordent avec la base stable attendue.
|
||||
|
||||
Commit attendu :
|
||||
|
||||
```text
|
||||
v0.3.1-pre.001
|
||||
```
|
||||
|
||||
Archive overlay attendue :
|
||||
|
||||
```text
|
||||
ksp-general-0.3.1-pre.001.zip
|
||||
```
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Ouvrir `0.3.1` uniquement par le gate prévu :
|
||||
|
||||
```text
|
||||
lecture règles + architecture
|
||||
audit base KSP stable
|
||||
audit historique kbot3
|
||||
audit PostgreSQL/driver actuel
|
||||
brainstorming N1 RAW
|
||||
split ksp-store-api / ksp-store-lib
|
||||
ownership
|
||||
API candidate
|
||||
backend extension model
|
||||
threat model
|
||||
stratégie de tests
|
||||
sizing et prévision souple recalibrée
|
||||
```
|
||||
|
||||
Aucune crate Store fonctionnelle n'est ajoutée dans cette livraison.
|
||||
|
||||
## 3. Décision de scope majeure
|
||||
|
||||
Le scope du prompt initial est volontairement réduit et scindé :
|
||||
|
||||
```text
|
||||
0.3.1 = ksp-store-api uniquement
|
||||
0.3.2 = ksp-store-lib PostgreSQL via tokio-postgres
|
||||
```
|
||||
|
||||
`ksp-store-api` possédera le modèle objet/struct commun et les opérations backend-agnostic. Les représentations internes, rows, requêtes, migrations, pools et transactions SQL seront invisibles aux consumers.
|
||||
|
||||
Une future implémentation alternative, par exemple `ksp-store-mysql-lib`, dépendra directement de `ksp-store-api` et non de `ksp-store-lib`.
|
||||
|
||||
La composition/configuration choisira l'implémentation liée au host puis fournira la même façade `Store` aux consumers.
|
||||
|
||||
Le `ROADMAP.md` stable contient encore l'ancienne prévision combinée. Il n'est pas modifié dans cette tranche et sera réconcilié dans la lane de préparation de publication conformément au workflow KSP.
|
||||
|
||||
## 4. N1 RAW retenu
|
||||
|
||||
Le niveau N1/D1 est défini comme acquisition persistée/replayable.
|
||||
|
||||
Surface initiale `0.3.1` :
|
||||
|
||||
```text
|
||||
RawTransaction
|
||||
RawTransactionObservation
|
||||
RawLog
|
||||
RawLogObservation
|
||||
RawPayload/provenance/reference/outcome/page communs
|
||||
```
|
||||
|
||||
Familles prévues mais reportées :
|
||||
|
||||
```text
|
||||
RawAccount
|
||||
RawBlock
|
||||
RawSlot/updates si besoin durable distinct
|
||||
autres acquisitions justifiées ultérieurement
|
||||
```
|
||||
|
||||
HTTP, WebSocket et gRPC sont des moyens d'acquisition/provenance, pas des familles RAW séparées.
|
||||
|
||||
Le RAW transaction devra pouvoir être transformé ultérieurement en normalisation Solana générique (actuellement nommée CORE/D2), puis décomposé en instructions top-level et CPI indépendantes afin qu'un decoder absent/Unsupported n'empêche pas le traitement du reste.
|
||||
|
||||
## 5. API/backend model
|
||||
|
||||
Décisions candidates :
|
||||
|
||||
```text
|
||||
Store = façade consumer commune
|
||||
StoreBackend = contrat d'implémentation externe object-safe
|
||||
capabilities = StoreHealth + RawTransactionStore + RawLogStore
|
||||
async = StoreFuture<'a, T> boxed KSP-owned par défaut
|
||||
atomicité = opérations métier persist_*_acquisition
|
||||
transaction SQL handle public = interdit
|
||||
page = 100 défaut / 500 max / cursor opaque borné
|
||||
```
|
||||
|
||||
Sémantique d'idempotence :
|
||||
|
||||
```text
|
||||
nouvelle clé -> Inserted
|
||||
même clé + même contenu -> AlreadyPresent
|
||||
même clé + contenu divergent -> Error Conflict
|
||||
```
|
||||
|
||||
Aucun `has_*` n'est requis avant write.
|
||||
|
||||
## 6. Héritage kbot3
|
||||
|
||||
L'archive historique a été extraite et les surfaces Store prioritaires réellement relues.
|
||||
|
||||
Le Store historique comporte 16 tables N1-N3, 240 ressources SQL atomiques et 79 index attendus. La séparation façade/PostgreSQL, les observations, la pagination bornée et l'idempotence sont réutilisables conceptuellement, mais les contrats N2/N3 et la représentation physique ne sont pas repris.
|
||||
|
||||
Résumé :
|
||||
|
||||
```text
|
||||
REPRENDRE séparation façade/backend, health portable, repository capabilities, pagination bornée, observations, idempotence
|
||||
REDESSINER StoreOpenOptions, raw DTO/entities, write semantics, diagnostics, Config Store
|
||||
REPORTER schema/migrations/indexes, raw table physique, replay processing, CORE, DECODE, materialization, processing ledger
|
||||
REJETER maintenance destructive publique, JSON backend options opaque, PK SQL comme identité API, monolithe N1/N2/N3
|
||||
```
|
||||
|
||||
La matrice détaillée est dans `docs/plans/022-V0_3_1_STORE_RAW_PLAN.md`.
|
||||
|
||||
## 7. Audit externe
|
||||
|
||||
Audit actuel au 28 août 2026 :
|
||||
|
||||
```text
|
||||
PostgreSQL 18.6 — stable courant, 13 août 2026
|
||||
tokio-postgres 0.7.18 — stable courant, 12 juin 2026
|
||||
Rust 2024, MSRV 1.85
|
||||
prepared statements / transactions / COPY / async pipelined supportés
|
||||
```
|
||||
|
||||
Décision opérateur : `tokio-postgres` sera le driver interne du backend PostgreSQL de référence dans `0.3.2`.
|
||||
|
||||
Aucune dependency PostgreSQL n'est ajoutée dans `0.3.1`.
|
||||
|
||||
## 8. Dependency graph `0.3.1`
|
||||
|
||||
Cible :
|
||||
|
||||
```text
|
||||
ksp-store-api
|
||||
└── ksp-core-lib
|
||||
```
|
||||
|
||||
`ksp-interface-lib`, serde, chrono, tokio et toute crate DB restent absents tant qu'un usage public réel ne les justifie pas.
|
||||
|
||||
## 9. Sizing recalibré
|
||||
|
||||
Prévision active :
|
||||
|
||||
```text
|
||||
pre.001 audit/design/split
|
||||
pre.002 scaffold ksp-store-api
|
||||
pre.003 primitives RAW + transaction
|
||||
pre.004 raw logs + extensibilité N1
|
||||
pre.005 backend contract + Store facade + external canary
|
||||
pre.006 queries/pagination/outcomes/notification reference
|
||||
pre.007 adversarial/API hardening + completeness
|
||||
pre.008 gate technique final
|
||||
pre.009 réconciliation documentaire
|
||||
pre.010 préparation de publication minimale
|
||||
rel.001 publication stable
|
||||
```
|
||||
|
||||
Le futur `0.3.2` ouvre `ksp-store-lib` PostgreSQL/`tokio-postgres`.
|
||||
|
||||
## 10. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
deltas/0.3.1/pre.001.md
|
||||
```
|
||||
|
||||
## 11. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
## 12. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 13. Version Cargo
|
||||
|
||||
La prerelease non-fix synchronise :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.3.1-pre.1
|
||||
```
|
||||
|
||||
Aucune crate Store n'existe encore ; le changement Cargo identifie uniquement le gate `pre.001` conformément au workflow.
|
||||
|
||||
## 14. Validations de baseline
|
||||
|
||||
Le journal opérateur fourni à l'ouverture sur `v0.2.14` montre :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
audit Rust général / exports / workspace PASS
|
||||
audit Markdown PASS — 175 tables / 128 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test --workspace PASS
|
||||
```
|
||||
|
||||
Les smokes live opt-in restent ignorés comme prévu par leurs contrats.
|
||||
|
||||
Cette preuve de base ne remplace pas le gate après application de `pre.001`.
|
||||
|
||||
## 15. Validations 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.3.1
|
||||
Markdown table audit: clean (184 tables, 121 files)
|
||||
```
|
||||
|
||||
`cargo` n'est pas installé dans l'environnement de génération utilisé pour préparer l'archive. `cargo fmt --all`, `cargo check --workspace` et `cargo clippy --workspace --all-targets` n'ont donc pas été rejoués ici et ne sont pas déclarés PASS. Le baseline opérateur `v0.2.14` fourni reste vert, mais ne remplace pas le gate opérateur après application.
|
||||
|
||||
## 16. Validations non requises dans cette tranche
|
||||
|
||||
```text
|
||||
cargo test -p ksp-store-api crate encore absente
|
||||
cargo tree -p ksp-store-api crate encore absente
|
||||
PostgreSQL live backend reporté à 0.3.2
|
||||
migration/schema tests backend reporté à 0.3.2
|
||||
```
|
||||
|
||||
## 17. Questions ouvertes
|
||||
|
||||
Aucune question architecturale ne bloque `pre.002`.
|
||||
|
||||
Restent volontairement à stabiliser par les tranches de code :
|
||||
|
||||
```text
|
||||
nom exact des wrappers d'identité RAW
|
||||
format_id/version concret du premier RawPayload transaction
|
||||
bornes finales payload/provenance
|
||||
identité déterministe exacte d'un RawLog
|
||||
forme finale StoreFuture/backend traits
|
||||
référence notification introduite en pre.006 ou reportée si insuffisamment stable
|
||||
```
|
||||
|
||||
Ces questions ne remettent pas en cause :
|
||||
|
||||
```text
|
||||
Store API seule en 0.3.1
|
||||
PostgreSQL/tokio-postgres en 0.3.2
|
||||
modèle public commun backend-agnostic
|
||||
transaction + logs comme premières familles N1
|
||||
```
|
||||
|
||||
## 18. 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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Aucun scaffold Store, SQL, migration ou changement Config ne doit être ajouté à ce delta.
|
||||
105
deltas/0.3.1/pre.002.md
Normal file
105
deltas/0.3.1/pre.002.md
Normal file
@@ -0,0 +1,105 @@
|
||||
<!-- file: deltas/0.3.1/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.002`
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.001-fix.003
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Créer uniquement le scaffold de `ksp-store-api` et matérialiser la frontière de dépendances validée par `pre.001`, sans introduire encore les primitives RAW de `pre.003` ni aucun backend/runtime Store.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/Cargo.toml
|
||||
crates/ksp-store-api/src/capability.rs
|
||||
crates/ksp-store-api/src/lib.rs
|
||||
crates/ksp-store-api/src/model.rs
|
||||
crates/ksp-store-api/tests/dependency_boundary.rs
|
||||
crates/ksp-store-api/tests/public_api.rs
|
||||
deltas/0.3.1/pre.002.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
Le workspace passe à :
|
||||
|
||||
```text
|
||||
0.3.1-pre.2
|
||||
```
|
||||
|
||||
et enregistre `crates/ksp-store-api` comme membre.
|
||||
|
||||
## Décisions matérialisées
|
||||
|
||||
- `ksp-store-api` dépend uniquement de `ksp-core-lib`.
|
||||
- les modules `model` et `capability` sont privés et distincts ; aucune arborescence de module interne n'est une API publique.
|
||||
- la façade crate-root réexporte seulement les primitives Core réellement communes nécessaires aux futurs contrats Store : `Error`, `ErrorCode`, `ErrorContext`, `Pubkey` et `Result`.
|
||||
- `0.3.1` reste N1 RAW-only ; STRUCTURAL reste une couche ultérieure distincte et aucun type N2 n'est créé dans cette tranche.
|
||||
- aucune dépendance Interface, Transport, Program, Materializer, Config, Logging, async runtime, serde, codec ou backend n'est introduite.
|
||||
- aucun marker trait ou enum Store artificiel n'est créé avant qu'une opération/modèle réel ne le justifie.
|
||||
- `RawTransaction`, provenance, payload, observation et idempotence restent réservés à `pre.003`.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
`dependency_boundary.rs` vérifie :
|
||||
|
||||
- la dépendance runtime exacte Core-only ;
|
||||
- l'absence des crates backend/runtime/transport/program/config ;
|
||||
- l'absence de chemins SQL/PostgreSQL/async/env/filesystem dans la façade.
|
||||
|
||||
`public_api.rs` vérifie :
|
||||
|
||||
- la consommation externe des réexports Core depuis le crate-root ;
|
||||
- l'absence de `pub mod` ;
|
||||
- l'absence de concepts backend et de modèles RAW introduits prématurément.
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
```
|
||||
|
||||
## Validations non exécutées dans l'environnement de génération
|
||||
|
||||
`cargo`/`rustfmt` ne sont pas disponibles dans l'environnement de génération. L'opérateur doit donc exécuter :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Une commande non exécutée n'est pas déclarée PASS.
|
||||
|
||||
## Hors scope confirmé
|
||||
|
||||
```text
|
||||
RawTransaction concret
|
||||
RawAccountState
|
||||
TransactionStatusObservation
|
||||
capabilities read/write
|
||||
queries/outcomes
|
||||
retention/tombstone concret
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
PostgreSQL/tokio-postgres
|
||||
Config std.store
|
||||
N2 STRUCTURAL
|
||||
N3/N4
|
||||
```
|
||||
|
||||
## Suite
|
||||
|
||||
`0.3.1-pre.003` introduit les primitives RAW communes puis `RawTransaction` et son observation, sans faire sortir les logs inclus de la transaction vers un modèle `RawLog` distinct.
|
||||
93
deltas/0.3.1/pre.003-fix.001.md
Normal file
93
deltas/0.3.1/pre.003-fix.001.md
Normal file
@@ -0,0 +1,93 @@
|
||||
<!-- file: deltas/0.3.1/pre.003-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.003-fix.001` — conformité Clippy `implicit_return`
|
||||
|
||||
## Base
|
||||
|
||||
```text
|
||||
0.3.1-pre.003
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.003` confirme :
|
||||
|
||||
- `cargo fmt --all` propre ;
|
||||
- audits Rust/Markdown propres ;
|
||||
- `cargo check --workspace` propre ;
|
||||
- `cargo test -p ksp-store-api` propre : 6 unitaires, 2 dependency-boundary, 3 public-api ;
|
||||
- `cargo clippy --workspace --all-targets` échoue uniquement sur `clippy::implicit_return` dans `valid_raw_code`.
|
||||
|
||||
## Objet
|
||||
|
||||
Corriger exclusivement le lint Clippy signalé dans la closure passée à `Iterator::all` :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/src/model/raw_primitives.rs
|
||||
```
|
||||
|
||||
Le workspace impose `-D clippy::implicit-return`. La closure exprime donc explicitement son `return` sans changer la condition de validation des logical RAW codes.
|
||||
|
||||
## Changement fonctionnel
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
La condition reste exactement :
|
||||
|
||||
```text
|
||||
ASCII alphanumeric
|
||||
ou `_`, `-`, `.`, `:`
|
||||
```
|
||||
|
||||
Les bornes, erreurs, API publique, modèles RAW, provenance, payload, dépendances et tests restent inchangés.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Correctif ciblé sans nouvelle tranche fonctionnelle. Conformément au workflow de versioning KSP, `workspace.package.version` devient :
|
||||
|
||||
```text
|
||||
0.3.1-pre.3-fix.1
|
||||
```
|
||||
|
||||
## Fichiers inclus dans le payload
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-store-api/src/model/raw_primitives.rs
|
||||
deltas/0.3.1/pre.003-fix.001.md
|
||||
```
|
||||
|
||||
`Cargo.toml` est inclus pour rendre le fix directement applicable avec le workflow d'overlay KSP et porte l'identité exacte du fix :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.3.1-pre.3-fix.1"
|
||||
```
|
||||
|
||||
Le seul fichier de code modifié est :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/src/model/raw_primitives.rs
|
||||
```
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
```text
|
||||
deltas/0.3.1/pre.003-fix.001.md
|
||||
```
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
```
|
||||
|
||||
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Le gate opérateur doit donc rejouer :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
204
deltas/0.3.1/pre.003.md
Normal file
204
deltas/0.3.1/pre.003.md
Normal file
@@ -0,0 +1,204 @@
|
||||
<!-- file: deltas/0.3.1/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.003`
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.002
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.002` est fourni vert : audits Rust/Markdown, `cargo check --workspace`, Clippy workspace et `cargo test -p ksp-store-api` passent.
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser les primitives N1 RAW backend-agnostic puis le premier modèle persistant réel `RawTransaction` avec observation d'acquisition séparée, sans introduire encore les matrices HTTP/WS/gRPC de `pre.004`, les capabilities de `pre.005` ni aucun backend/runtime Store.
|
||||
|
||||
## Version
|
||||
|
||||
Le workspace passe à :
|
||||
|
||||
```text
|
||||
0.3.1-pre.3
|
||||
```
|
||||
|
||||
## Surface ajoutée
|
||||
|
||||
Primitives communes :
|
||||
|
||||
```text
|
||||
RawNetworkId
|
||||
RawTransactionSignature
|
||||
RawTransactionReference
|
||||
RawFormatId
|
||||
RawContentHash
|
||||
RawObservationKey
|
||||
RawTimestamp
|
||||
RawAcquisitionOrigin
|
||||
RawProvenanceCode
|
||||
RawAcquisitionProvenance
|
||||
RawPayload
|
||||
```
|
||||
|
||||
Première famille N1 :
|
||||
|
||||
```text
|
||||
RawTransaction
|
||||
RawTransactionObservation
|
||||
```
|
||||
|
||||
Codes d'erreur :
|
||||
|
||||
```text
|
||||
store_api.raw_model_invalid
|
||||
store_api.raw_payload_invalid
|
||||
store_api.raw_provenance_invalid
|
||||
```
|
||||
|
||||
## Invariants RAW
|
||||
|
||||
`RawPayload` contient uniquement un format de persistence KSP déjà source-independent :
|
||||
|
||||
```text
|
||||
format_id
|
||||
format_version > 0
|
||||
bytes non vides
|
||||
content_hash [u8; 32]
|
||||
```
|
||||
|
||||
Le Store API ne choisit ni codec ni algorithme de conversion Transport. Le producer/converter du futur format canonique doit fournir des bytes complets et leur digest déterministe.
|
||||
|
||||
Bornes Store-owned :
|
||||
|
||||
```text
|
||||
code logique <= 128 bytes
|
||||
payload RAW canonique <= 16 MiB
|
||||
source payload size meta <= 64 MiB
|
||||
Unix timestamp <= 9999-12-31T23:59:59.999Z
|
||||
```
|
||||
|
||||
Ces valeurs sont des admission guards internes et ne prétendent pas définir des maxima Solana.
|
||||
|
||||
`RawPayload` et `RawTransaction` ne sont volontairement pas `Clone`, afin d'éviter de rendre triviale la copie de gros documents RAW.
|
||||
|
||||
## Identité transactionnelle
|
||||
|
||||
La référence durable est :
|
||||
|
||||
```text
|
||||
RawTransactionReference
|
||||
network
|
||||
signature [u8; 64]
|
||||
```
|
||||
|
||||
Aucune PK SQL/backend ne traverse l'API. `RawTransaction` ajoute :
|
||||
|
||||
```text
|
||||
slot: u64
|
||||
block_time: Option<RawTimestamp>
|
||||
RawPayload
|
||||
```
|
||||
|
||||
Les logs contenus dans la transaction restent à l'intérieur du payload canonique N1. Aucun modèle `RawLog` persistant ni type STRUCTURAL n'est créé.
|
||||
|
||||
## Observation et provenance
|
||||
|
||||
Une observation réussie reste distincte du RAW :
|
||||
|
||||
```text
|
||||
RawTransactionObservation
|
||||
observation_key [u8; 32]
|
||||
transaction reference
|
||||
provenance
|
||||
```
|
||||
|
||||
La provenance peut représenter avec des logical codes sûrs :
|
||||
|
||||
```text
|
||||
provider
|
||||
protocol
|
||||
acquisition method
|
||||
origin live/backfill/import/replay/repair
|
||||
endpoint id optionnel
|
||||
commitment optionnel
|
||||
capture/session id optionnel
|
||||
filter id optionnel
|
||||
observed_at optionnel
|
||||
received_at
|
||||
source payload size/hash optionnels
|
||||
```
|
||||
|
||||
Elle n'accepte aucun payload source et ses logical codes refusent notamment les espaces, contrôles et formes URL contenant `/`. Le contrat reste explicitement non-secret : un caller ne doit jamais placer une credential dans un logical code.
|
||||
|
||||
`observed_at`, lorsqu'il existe, ne peut pas être postérieur à `received_at`.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests unitaires ajoutés :
|
||||
|
||||
- bornes/validation des codes ;
|
||||
- borne de timestamp ;
|
||||
- payload non vide/versionné/borné ;
|
||||
- `Debug` du payload sans bytes ;
|
||||
- provenance et ordre temporel ;
|
||||
- identité `network + signature` ;
|
||||
- séparation `RawTransaction` / `RawTransactionObservation`.
|
||||
|
||||
Canaris d'intégration mis à jour :
|
||||
|
||||
- surface crate-root des nouveaux modèles ;
|
||||
- dépendance runtime exacte `ksp-core-lib` ;
|
||||
- absence de backend, SQL, serde, codec, async runtime, Transport et Program ;
|
||||
- absence de `RawLog` et de type STRUCTURAL dans la production `pre.003`.
|
||||
|
||||
## Documentation mise à jour
|
||||
|
||||
```text
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
Ils figent les primitives et bornes réellement matérialisées par cette tranche sans avancer les matrices cross-source de `pre.004`.
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
```
|
||||
|
||||
## Validations non exécutées dans l'environnement de génération
|
||||
|
||||
`cargo`, `rustc` et `rustfmt` ne sont pas installés dans l'environnement de génération. L'opérateur doit donc exécuter :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Une commande non exécutée n'est pas déclarée PASS.
|
||||
|
||||
## Hors scope confirmé
|
||||
|
||||
```text
|
||||
conversion HTTP/WS/gRPC -> RawTransaction canonique
|
||||
RawAccountState
|
||||
TransactionStatusObservation
|
||||
events logs/slot/vote
|
||||
capabilities read/write
|
||||
queries/outcomes
|
||||
retention/tombstone concret
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
PostgreSQL/tokio-postgres
|
||||
Config std.store
|
||||
N2 STRUCTURAL
|
||||
N3/N4
|
||||
```
|
||||
|
||||
## Suite
|
||||
|
||||
`0.3.1-pre.004` audite les formes HTTP/WS/gRPC réelles afin de figer la matrice d'admission cross-source et d'introduire seulement les familles N1 supplémentaires dont la sémantique commune est effectivement démontrée, en priorité `RawAccountState`/observation et `TransactionStatusObservation`.
|
||||
252
deltas/0.3.1/pre.004.md
Normal file
252
deltas/0.3.1/pre.004.md
Normal file
@@ -0,0 +1,252 @@
|
||||
<!-- file: deltas/0.3.1/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.004` — admission cross-source + account state N1
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.3-fix.1
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.003-fix.001` est fourni vert : `cargo fmt --all`, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace et `cargo test -p ksp-store-api` passent.
|
||||
|
||||
## Objectif
|
||||
|
||||
Auditer les formes HTTP/WS/gRPC déjà possédées par `ksp-onchain-transport-lib` avant d'ajouter une nouvelle famille N1, puis matérialiser uniquement le modèle dont la sémantique commune est démontrée.
|
||||
|
||||
La tranche :
|
||||
|
||||
- admet `RawAccountState`/`RawAccountObservation` avec bytes complets + slot durable ;
|
||||
- sépare les enrichissements Yellowstone de l'état canonique commun ;
|
||||
- refuse conceptuellement les réponses account partielles/parsées comme états persistants ;
|
||||
- diffère `TransactionStatusObservation` parce que snapshot HTTP, transition WS et update Yellowstone ne représentent pas encore le même fait ;
|
||||
- ferme la classification logs/slot/vote/block/Entry sans créer de modèles Store prématurés.
|
||||
|
||||
## Version
|
||||
|
||||
Le workspace passe à :
|
||||
|
||||
```text
|
||||
0.3.1-pre.4
|
||||
```
|
||||
|
||||
## Audit account cross-source
|
||||
|
||||
Surfaces KSP relues :
|
||||
|
||||
```text
|
||||
HTTP
|
||||
getAccountInfo
|
||||
getMultipleAccounts
|
||||
getProgramAccounts
|
||||
|
||||
WebSocket
|
||||
accountSubscribe
|
||||
programSubscribe
|
||||
Helius standard account/program reuse
|
||||
|
||||
Yellowstone gRPC
|
||||
Account / AccountInfo
|
||||
```
|
||||
|
||||
Champs communs retenus pour un état complet :
|
||||
|
||||
```text
|
||||
network
|
||||
pubkey
|
||||
slot
|
||||
lamports
|
||||
owner
|
||||
executable
|
||||
rent_epoch
|
||||
complete account data bytes
|
||||
canonical state hash
|
||||
```
|
||||
|
||||
Règles d'admission :
|
||||
|
||||
```text
|
||||
getAccountInfo/getMultipleAccounts
|
||||
-> admissibles avec account non-null + bytes complets
|
||||
|
||||
getProgramAccounts/programSubscribe
|
||||
-> slot/context obligatoire
|
||||
|
||||
accountSubscribe
|
||||
-> admissible avec bytes complets
|
||||
|
||||
Yellowstone Account
|
||||
-> admissible seulement sans accounts_data_slice tronquant les bytes
|
||||
|
||||
jsonParsed/dataSlice/bare program notification
|
||||
-X-> RawAccountState persistant
|
||||
```
|
||||
|
||||
`space` n'est pas une vérité stockée séparément lorsque les bytes complets sont présents : `data.len()` est déterministe.
|
||||
|
||||
## Modèles ajoutés
|
||||
|
||||
```text
|
||||
RawAccountStateReference
|
||||
network
|
||||
pubkey
|
||||
slot
|
||||
state_hash
|
||||
|
||||
RawAccountState
|
||||
reference
|
||||
lamports
|
||||
owner
|
||||
executable
|
||||
rent_epoch
|
||||
complete data bytes
|
||||
|
||||
RawAccountObservation
|
||||
observation_key
|
||||
account reference
|
||||
provenance
|
||||
optional write_version
|
||||
optional transaction_signature
|
||||
optional is_startup
|
||||
```
|
||||
|
||||
La référence inclut `state_hash` parce que plusieurs écritures d'une account peuvent survenir dans le même slot alors que HTTP/WS standards ne possèdent pas le `write_version` Yellowstone. Plusieurs sources observant le même état complet peuvent donc converger sans faire de l'ordinal Yellowstone un identifiant commun.
|
||||
|
||||
Le calcul du digest reste producer/converter-owned. `ksp-store-api` n'ajoute aucun codec ni algorithme de hash.
|
||||
|
||||
## Borne account data
|
||||
|
||||
Nouvelle admission guard Store-owned :
|
||||
|
||||
```text
|
||||
MAX_RAW_ACCOUNT_DATA_BYTES = 16 MiB
|
||||
```
|
||||
|
||||
Les bytes vides restent valides pour une account vide. La borne n'est pas présentée comme une limite protocolaire Solana.
|
||||
|
||||
`RawAccountState` n'implémente pas `Clone` et son `Debug` ne rend jamais les bytes.
|
||||
|
||||
## Observation account
|
||||
|
||||
Les informations présentes uniquement sur certaines sources restent observation-only :
|
||||
|
||||
```text
|
||||
Yellowstone write_version
|
||||
Yellowstone transaction signature
|
||||
Yellowstone is_startup
|
||||
```
|
||||
|
||||
HTTP/WS utilisent le même `RawAccountObservation` sans inventer ces valeurs.
|
||||
|
||||
Provider/protocol/method/endpoint/commitment/timing restent dans `RawAcquisitionProvenance` conformément à `pre.003`.
|
||||
|
||||
## Transaction status différé
|
||||
|
||||
Surfaces auditées :
|
||||
|
||||
```text
|
||||
getSignatureStatuses
|
||||
= snapshot interrogé avec confirmations/confirmationStatus
|
||||
|
||||
signatureSubscribe
|
||||
= event one-shot de réception/commitment demandé
|
||||
|
||||
Yellowstone TransactionStatus
|
||||
= update avec slot/signature/is_vote/index/error, sans commitment commun
|
||||
```
|
||||
|
||||
La tranche n'introduit donc aucun `TransactionStatusObservation` générique rempli d'options. La future conception devra distinguer snapshot durable et event realtime et auditer l'ownership `ksp-interface-lib` des événements passifs.
|
||||
|
||||
## Classification fermée
|
||||
|
||||
```text
|
||||
logsSubscribe
|
||||
-> event-only candidat ; pas de RawLog Store
|
||||
|
||||
transaction logMessages
|
||||
-> restent dans RawTransaction jusqu'à N2 STRUCTURAL
|
||||
|
||||
slot/root/slotsUpdates
|
||||
-> event-only candidat
|
||||
|
||||
vote
|
||||
-> event-only candidat après compatibilité utile
|
||||
|
||||
getBlock/Yellowstone Block
|
||||
-> conteneur d'acquisition RawTransaction ; RawBlock reste IDEA
|
||||
|
||||
Yellowstone Entry
|
||||
-> explicitement non retenu
|
||||
```
|
||||
|
||||
Le Store runtime ne publie aucune notification ; workers/analyzers/runtime possèdent les futurs déclenchements.
|
||||
|
||||
## Tests ajoutés/mis à jour
|
||||
|
||||
Unitaires account :
|
||||
|
||||
- état complet et champs communs ;
|
||||
- account data vide accepté ;
|
||||
- account data oversized rejeté ;
|
||||
- `Debug` sans bytes ;
|
||||
- metadata Yellowstone optionnelle sur observation.
|
||||
|
||||
Canaris d'intégration :
|
||||
|
||||
- surface crate-root `RawAccountState*`/`RawAccountObservation` ;
|
||||
- dépendance runtime toujours exactement Core-only ;
|
||||
- absence de Transport/backend/runtime/codec ;
|
||||
- absence de `TransactionStatusObservation`, `RawLogNotification`, `RawSlotEvent`, `RawVoteEvent`, `RawBlock` et `YellowstoneEntry` publics.
|
||||
|
||||
## Documentation mise à jour
|
||||
|
||||
```text
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
La matrice cross-source est désormais explicite et `pre.004` ne prétend pas que toutes les réponses on-chain constituent des modèles Store.
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
```
|
||||
|
||||
## Validations non exécutées dans l'environnement de génération
|
||||
|
||||
`cargo`, `rustc` et `rustfmt` ne sont pas installés dans l'environnement de génération. L'opérateur doit donc exécuter :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Une commande non exécutée n'est pas déclarée PASS.
|
||||
|
||||
## Hors scope confirmé
|
||||
|
||||
```text
|
||||
source converter HTTP/WS/gRPC concret
|
||||
TransactionStatus model commun
|
||||
logs/slot/vote event model
|
||||
RawBlock persistence
|
||||
Yellowstone Entry persistence
|
||||
capabilities read/write
|
||||
queries/outcomes
|
||||
retention/tombstone concret
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
PostgreSQL/tokio-postgres
|
||||
Config std.store
|
||||
N2 STRUCTURAL
|
||||
N3/N4
|
||||
```
|
||||
|
||||
## Suite
|
||||
|
||||
`0.3.1-pre.005` introduit les capabilities backend extensibles et object-safe pour les modèles persistants réellement matérialisés, sans façade runtime `Store`, sans backend PostgreSQL et sans obliger un backend à supporter toutes les familles N1.
|
||||
165
deltas/0.3.1/pre.005.md
Normal file
165
deltas/0.3.1/pre.005.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- file: deltas/0.3.1/pre.005.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.005` — capabilities backend extensibles
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.4
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.004` est vert : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-store-api` passent.
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser les premiers contracts d'opérations backend-agnostic de `ksp-store-api` sans introduire la façade runtime `ksp-store-lib`, un backend concret ou une dépendance async supplémentaire.
|
||||
|
||||
La tranche doit démontrer qu'un backend externe peut implémenter séparément les capabilities réellement supportées et être utilisé derrière des trait objects.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/src/capability/raw_account.rs
|
||||
crates/ksp-store-api/src/capability/raw_transaction.rs
|
||||
crates/ksp-store-api/tests/external_backend.rs
|
||||
deltas/0.3.1/pre.005.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-store-api/src/capability.rs
|
||||
crates/ksp-store-api/src/lib.rs
|
||||
crates/ksp-store-api/tests/dependency_boundary.rs
|
||||
crates/ksp-store-api/tests/public_api.rs
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## Version
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.3.1-pre.5
|
||||
```
|
||||
|
||||
## Surface ajoutée
|
||||
|
||||
```text
|
||||
StoreApiFuture<'a, T>
|
||||
|
||||
RawTransactionRead
|
||||
RawTransactionWrite
|
||||
RawTransactionObservationRead
|
||||
RawTransactionObservationWrite
|
||||
|
||||
RawAccountStateRead
|
||||
RawAccountStateWrite
|
||||
RawAccountObservationRead
|
||||
RawAccountObservationWrite
|
||||
```
|
||||
|
||||
Aucun trait global `StoreBackend` n'est ajouté. Les backends peuvent implémenter une combinaison de capabilities sans prétendre supporter toutes les familles N1.
|
||||
|
||||
## Contrat async/object-safe
|
||||
|
||||
`StoreApiFuture<'a, T>` est un alias KSP utilisant uniquement la bibliothèque standard :
|
||||
|
||||
```text
|
||||
Pin<Box<dyn Future<Output = T> + Send + 'a>>
|
||||
```
|
||||
|
||||
Les capabilities sont `Send + Sync`, dyn-compatible et n'ajoutent aucune dépendance `async-trait`, `tokio` ou `futures-util` à `ksp-store-api`.
|
||||
|
||||
## Contrats d'écriture
|
||||
|
||||
Les opérations suivantes sont atomiques au niveau métier :
|
||||
|
||||
```text
|
||||
persist_raw_transaction_acquisition(transaction, observation)
|
||||
persist_raw_account_acquisition(state, observation)
|
||||
```
|
||||
|
||||
Le backend doit persister le RAW et son observation ensemble ou ne laisser aucun des deux durable.
|
||||
|
||||
Les opérations :
|
||||
|
||||
```text
|
||||
record_raw_transaction_observation(observation)
|
||||
record_raw_account_observation(observation)
|
||||
```
|
||||
|
||||
servent aux acquisitions supplémentaires d'un RAW déjà présent et évitent de retransmettre le payload transactionnel ou les bytes de compte.
|
||||
|
||||
`pre.005` retourne uniquement `Result<()>` sur les écritures. `pre.006` remplace/finalise cette surface avec les outcomes d'idempotence/conflit, les queries et le lifecycle RAW avant stabilisation de la release.
|
||||
|
||||
## Canari backend externe
|
||||
|
||||
`tests/external_backend.rs` définit un backend mémoire externe à l'implémentation Store officielle et implémente les huit capabilities uniquement via la façade crate-root de `ksp-store-api`.
|
||||
|
||||
Le canari vérifie notamment :
|
||||
|
||||
```text
|
||||
impl externe sans ksp-store-lib
|
||||
impl externe sans ksp-store-postgres-lib
|
||||
aucun SQL/PostgreSQL
|
||||
aucun async runtime requis pour implémenter les signatures
|
||||
conversion en &dyn capability possible
|
||||
```
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
```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.3.1
|
||||
Markdown table audit: clean (186 table(s), 129 file(s))
|
||||
```
|
||||
|
||||
## Validations non exécutées dans l'environnement de génération
|
||||
|
||||
`cargo`, `rustc` et `rustfmt` ne sont pas installés dans l'environnement de génération. L'opérateur doit donc exécuter :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Une commande non exécutée n'est pas déclarée PASS.
|
||||
|
||||
## Hors scope confirmé
|
||||
|
||||
```text
|
||||
façade runtime ksp-store-lib
|
||||
trait StoreBackend monolithique
|
||||
ksp-store-postgres-lib
|
||||
PostgreSQL/tokio-postgres
|
||||
Config/std.store
|
||||
queries/list/pagination
|
||||
outcomes d'idempotence/conflit finaux
|
||||
retention/tombstone
|
||||
health runtime
|
||||
TransactionStatusObservation commun
|
||||
models event-only logs/slot/vote
|
||||
RawBlock persistence
|
||||
Yellowstone Entry persistence
|
||||
N2 STRUCTURAL
|
||||
N3 DECODED
|
||||
N4 DOMAIN
|
||||
```
|
||||
|
||||
## Suite
|
||||
|
||||
`0.3.1-pre.006` finalise les queries/pages bornées, outcomes d'écriture/idempotence, contrat de backlog utile, lifecycle `RawRetentionState`, tombstone minimal et sémantiques normal-skip/force-rehydrate sans implémenter de compression/archive physique.
|
||||
95
deltas/0.3.1/pre.006-fix.001.md
Normal file
95
deltas/0.3.1/pre.006-fix.001.md
Normal file
@@ -0,0 +1,95 @@
|
||||
<!-- file: deltas/0.3.1/pre.006-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.006-fix.001` — conformité Clippy pagination
|
||||
|
||||
## Base
|
||||
|
||||
```text
|
||||
0.3.1-pre.006
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.006` confirme :
|
||||
|
||||
- `cargo fmt --all` propre ;
|
||||
- audits Rust/Markdown propres ;
|
||||
- `cargo check --workspace` propre ;
|
||||
- `cargo test -p ksp-store-api` propre : 14 unitaires, 2 dependency-boundary, 1 external-backend, 6 public-api ;
|
||||
- `cargo clippy --workspace --all-targets` termine avec exactement deux warnings ciblés dans `ksp-store-api` : `clippy::collapsible_if` et `clippy::bool_comparison`.
|
||||
|
||||
## Objet
|
||||
|
||||
Corriger exclusivement les deux warnings Clippy remontés par le gate opérateur :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/src/model/raw_pagination.rs
|
||||
crates/ksp-store-api/tests/dependency_boundary.rs
|
||||
```
|
||||
|
||||
Le premier warning concerne la validation d'un `RawSlotRange` inversé ; le second concerne uniquement l'écriture d'une assertion de canari.
|
||||
|
||||
## Changement fonctionnel
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
La validation conserve exactement la même règle : lorsqu'une borne de début et une borne de fin sont toutes deux présentes, `start > end` reste rejeté.
|
||||
|
||||
Le canari conserve exactement la même assertion : la source de pagination ne doit pas contenir le littéral `u64::MAX`, afin de ne pas transformer cette valeur en plafond KSP implicite.
|
||||
|
||||
Aucun contrat de pagination, outcome, rétention, capability, modèle RAW ou export public n'est modifié.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Ce fix modifie du code Rust. Conformément au workflow KSP, l'identité de build devient :
|
||||
|
||||
```text
|
||||
0.3.1-pre.6-fix.1
|
||||
```
|
||||
|
||||
Cette identité distincte garantit notamment que les commandes Cargo exécutées après application du fix ne réutilisent pas ambiguëment l'identité de build de `0.3.1-pre.6`.
|
||||
|
||||
## Fichiers inclus dans le payload
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-store-api/src/model/raw_pagination.rs
|
||||
crates/ksp-store-api/tests/dependency_boundary.rs
|
||||
deltas/0.3.1/pre.006-fix.001.md
|
||||
```
|
||||
|
||||
`Cargo.toml` porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.3.1-pre.6-fix.1"
|
||||
```
|
||||
|
||||
## Hors scope
|
||||
|
||||
```text
|
||||
API publique
|
||||
queries/pagination
|
||||
outcomes d'idempotence
|
||||
rétention/tombstones
|
||||
capabilities Store
|
||||
ksp-store-lib
|
||||
backend PostgreSQL
|
||||
plans/validation/ROADMAP
|
||||
```
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
```
|
||||
|
||||
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Le gate opérateur doit donc rejouer :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
243
deltas/0.3.1/pre.006.md
Normal file
243
deltas/0.3.1/pre.006.md
Normal file
@@ -0,0 +1,243 @@
|
||||
<!-- file: deltas/0.3.1/pre.006.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.006` — queries, outcomes et lifecycle RAW
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.5
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.005` est intégralement vert : audits Rust/Markdown, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test -p ksp-store-api` passent.
|
||||
|
||||
## Recalibrage pagination
|
||||
|
||||
Le brainstorming opérateur précise une règle durable : Store fournit une primitive de query/pagination, **pas une policy d'exécution**.
|
||||
|
||||
Une limitation physique réelle d'un backend peut contraindre ce qu'il sert en une opération. En revanche `ksp-store-api`, `ksp-store-lib` et les crates backend ne doivent pas inventer un plafond métier inférieur (`100`, `500`, etc.) simplement pour gouverner le travail d'un futur executor.
|
||||
|
||||
`pre.006` matérialise donc :
|
||||
|
||||
```text
|
||||
RawPageLimit > 0
|
||||
aucun maximum fonctionnel KSP arbitraire
|
||||
RawPageCursor opaque <= 4 KiB
|
||||
```
|
||||
|
||||
La borne du cursor protège un token d'entrée hostile ; elle ne limite pas le nombre de résultats. Le futur job/worker choisira sa taille de travail et son backlog via ses propres policies/processing contracts.
|
||||
|
||||
## Objectif
|
||||
|
||||
Finaliser les contrats N1 encore nécessaires avant le hardening de release :
|
||||
|
||||
```text
|
||||
queries/list cursorisés
|
||||
outcomes idempotents
|
||||
conflit stable
|
||||
atomic acquisition finalisée
|
||||
rétention logique transactionnelle
|
||||
tombstone anti-rebackfill
|
||||
normal skip / force rehydrate explicite
|
||||
```
|
||||
|
||||
Aucune compression, archive physique, base de données, policy de purge ou executor n'est implémenté.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/src/capability/raw_retention.rs
|
||||
crates/ksp-store-api/src/model/raw_outcome.rs
|
||||
crates/ksp-store-api/src/model/raw_pagination.rs
|
||||
crates/ksp-store-api/src/model/raw_retention.rs
|
||||
crates/ksp-store-api/unit_tests/model/raw_pagination.rs
|
||||
crates/ksp-store-api/unit_tests/model/raw_retention.rs
|
||||
deltas/0.3.1/pre.006.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-store-api/src/capability.rs
|
||||
crates/ksp-store-api/src/capability/raw_account.rs
|
||||
crates/ksp-store-api/src/capability/raw_transaction.rs
|
||||
crates/ksp-store-api/src/error.rs
|
||||
crates/ksp-store-api/src/lib.rs
|
||||
crates/ksp-store-api/src/model.rs
|
||||
crates/ksp-store-api/tests/dependency_boundary.rs
|
||||
crates/ksp-store-api/tests/external_backend.rs
|
||||
crates/ksp-store-api/tests/public_api.rs
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## Version
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.3.1-pre.6
|
||||
```
|
||||
|
||||
## Queries et pagination
|
||||
|
||||
Surface ajoutée :
|
||||
|
||||
```text
|
||||
RawPageCursor
|
||||
RawPageLimit
|
||||
RawPageRequest
|
||||
RawPage<T>
|
||||
RawSlotRange
|
||||
RawSortDirection
|
||||
RawTransactionQuery
|
||||
RawAccountStateQuery
|
||||
```
|
||||
|
||||
`RawPageLimit::new(0)` est invalide. Aucun maximum KSP n'est imposé ; `u64::MAX` reste représentable par le contrat. Les queries sont network-scoped et peuvent borner les slots ; les account queries peuvent en plus sélectionner un pubkey.
|
||||
|
||||
Les capabilities read gagnent :
|
||||
|
||||
```text
|
||||
list_raw_transactions(query)
|
||||
list_raw_account_states(query)
|
||||
```
|
||||
|
||||
Les pages retournent des références compactes et un cursor opaque, pas des rows/PK/SQL backend.
|
||||
|
||||
## Outcomes et conflits
|
||||
|
||||
Surface ajoutée :
|
||||
|
||||
```text
|
||||
RawEntityWriteOutcome
|
||||
Inserted
|
||||
AlreadyPresent
|
||||
Rehydrated
|
||||
SkippedPurged
|
||||
|
||||
RawObservationWriteOutcome
|
||||
Inserted
|
||||
AlreadyPresent
|
||||
NotRecorded
|
||||
|
||||
RawAcquisitionWriteOutcome
|
||||
entity + observation
|
||||
```
|
||||
|
||||
Les opérations `persist_raw_*_acquisition` retournent désormais l'outcome combiné et conservent le contrat all-or-nothing RAW + observation.
|
||||
|
||||
Une même identité avec un contenu divergent doit produire :
|
||||
|
||||
```text
|
||||
ERROR_CODE_RAW_CONFLICT
|
||||
```
|
||||
|
||||
et jamais un overwrite silencieux.
|
||||
|
||||
Les écritures d'observation seules retournent `RawObservationWriteOutcome`.
|
||||
|
||||
## Lifecycle RAW transaction
|
||||
|
||||
Surface ajoutée :
|
||||
|
||||
```text
|
||||
RawRetentionState
|
||||
Full
|
||||
Compacted
|
||||
Archived
|
||||
Purged
|
||||
|
||||
RawTransactionTombstone
|
||||
RawTransactionRetentionTransition
|
||||
RawRetentionWriteOutcome
|
||||
RawTransactionAcquisitionMode
|
||||
Normal
|
||||
ForceRehydrate
|
||||
|
||||
RawTransactionRetentionRead
|
||||
RawTransactionRetentionWrite
|
||||
```
|
||||
|
||||
Transitions génériques admises :
|
||||
|
||||
```text
|
||||
Full -> Compacted
|
||||
Full -> Archived
|
||||
Compacted -> Archived
|
||||
Archived -> Purged
|
||||
```
|
||||
|
||||
Interdits :
|
||||
|
||||
```text
|
||||
Full -> Purged direct
|
||||
Purged -> Full via transition générique
|
||||
```
|
||||
|
||||
La réhydratation est un mode d'acquisition explicitement forcé. En mode normal, un tombstone purgé doit rester connu et produire `SkippedPurged` sans rebackfill automatique.
|
||||
|
||||
Le tombstone conserve uniquement l'identité transactionnelle, slot, format/version et hash canonique nécessaires au contrat anti-rebackfill ; aucun payload RAW n'y subsiste.
|
||||
|
||||
## Ownership rétention
|
||||
|
||||
Le Store applique la transition demandée mais ne décide jamais que les couches supérieures sont terminées. L'éligibilité compression/archive/purge appartient à un futur worker/job/maintenance policy fondé sur les preuves de processing versionnées.
|
||||
|
||||
La policy reste family-specific ; `RawAccountState` ne reçoit donc aucun lifecycle de purge copié artificiellement depuis `RawTransaction` dans cette tranche.
|
||||
|
||||
## Canari backend externe
|
||||
|
||||
Le backend externe de test est mis à jour pour :
|
||||
|
||||
```text
|
||||
queries/pages
|
||||
outcomes détaillés
|
||||
mode Normal/ForceRehydrate
|
||||
retention read/write
|
||||
```
|
||||
|
||||
Il reste implémentable sans `ksp-store-lib`, PostgreSQL, runtime DB ou dépendance async supplémentaire.
|
||||
|
||||
## Hors scope confirmé
|
||||
|
||||
```text
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
PostgreSQL/tokio-postgres
|
||||
Config/std.store
|
||||
policy executor/backlog métier
|
||||
processing ledger concret
|
||||
compression/archive/purge physique
|
||||
health runtime
|
||||
TransactionStatusObservation commun
|
||||
models event-only logs/slot/vote
|
||||
RawBlock persistence
|
||||
Yellowstone Entry persistence
|
||||
N2 STRUCTURAL
|
||||
N3 DECODED
|
||||
N4 DOMAIN
|
||||
```
|
||||
|
||||
## Validations opérateur de la base
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
python3 scripts/audit_markdown_tables.py ... PASS
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-store-api PASS
|
||||
```
|
||||
|
||||
## Validations de génération
|
||||
|
||||
Les audits structurels/Markdown sont rejoués sur l'overlay `pre.006`. `cargo`, `rustc` et `rustfmt` ne sont pas installés dans l'environnement de génération ; le gate Cargo final reste donc opérateur.
|
||||
|
||||
## Suite
|
||||
|
||||
`0.3.1-pre.007` effectue le hardening adversarial/completeness : cursor/provenance hostile, races de rétention, exact exports/modules, frontière Interface/Store, dependency firewall et scope négatif N2/N3/N4.
|
||||
39
deltas/0.3.1/pre.007-fix.001.md
Normal file
39
deltas/0.3.1/pre.007-fix.001.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Delta `0.3.1-pre.007-fix.001`
|
||||
|
||||
## Objet
|
||||
|
||||
Corriger exclusivement les erreurs de compilation et Clippy des nouveaux canaris de hardening/completeness introduits par `0.3.1-pre.007`.
|
||||
|
||||
## Version
|
||||
|
||||
```text
|
||||
0.3.1-pre.7-fix.1
|
||||
```
|
||||
|
||||
Le changement de version est requis car ce fix modifie du code Rust/tests exécutables ; il fournit également une identité Cargo distincte pour le rebuild.
|
||||
|
||||
## Corrections
|
||||
|
||||
- retire quatre emprunts `&ErrorCode` erronés dans `tests/security_hardening.rs` ; `Error::code()` retourne déjà `ErrorCode` par valeur ;
|
||||
- remplace les six usages de `?` interdits par `clippy::question-mark-used` dans `tests/release_completeness.rs` par des branches `Result` explicites ;
|
||||
- ne modifie aucun contrat public, modèle RAW, capability, outcome, règle de rétention ou comportement fonctionnel.
|
||||
|
||||
## Payload
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-store-api/tests/release_completeness.rs
|
||||
crates/ksp-store-api/tests/security_hardening.rs
|
||||
deltas/0.3.1/pre.007-fix.001.md
|
||||
```
|
||||
|
||||
## Gate opérateur
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
166
deltas/0.3.1/pre.007.md
Normal file
166
deltas/0.3.1/pre.007.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<!-- file: deltas/0.3.1/pre.007.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.007` — hardening adversarial et complétude Store API
|
||||
|
||||
## Base
|
||||
|
||||
```text
|
||||
0.3.1-pre.6-fix.1
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.006-fix.001` est intégralement propre :
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Résultat Store API observé : 14 tests unitaires, 2 dependency-boundary, 1 external-backend, 6 public-api et doc-tests propres.
|
||||
|
||||
## Objet
|
||||
|
||||
Fermer la tranche de hardening/completeness de `ksp-store-api` sans ouvrir une nouvelle famille N1 ni une couche supérieure :
|
||||
|
||||
```text
|
||||
payload/cursor/provenance adversariaux
|
||||
retention compare-and-transition race
|
||||
exports/modules/capabilities exacts
|
||||
frontière ksp-interface-lib / ksp-store-api
|
||||
scope négatif event-only + N2/N3/N4
|
||||
backend/dependency firewall
|
||||
```
|
||||
|
||||
## Race de rétention
|
||||
|
||||
`RawTransactionRetentionTransition` transporte déjà :
|
||||
|
||||
```text
|
||||
expected
|
||||
reference
|
||||
target
|
||||
```
|
||||
|
||||
Le résultat manquait toutefois d'un état distinct lorsque l'état durable avait changé concurremment. `RawRetentionWriteOutcome` ajoute donc :
|
||||
|
||||
```text
|
||||
ExpectedStateMismatch
|
||||
```
|
||||
|
||||
Sémantique requise pour un backend :
|
||||
|
||||
```text
|
||||
persisted == expected
|
||||
-> compare-and-transition atomique
|
||||
-> Applied si succès
|
||||
|
||||
persisted == target
|
||||
-> AlreadyAtTarget
|
||||
|
||||
persisted != expected && persisted != target
|
||||
-> ExpectedStateMismatch
|
||||
```
|
||||
|
||||
Cette course n'est ni un input invalide ni une autorisation pour écraser le nouvel état. La décision de retry/reload reste au caller/worker policy.
|
||||
|
||||
## Hardening adversarial
|
||||
|
||||
Nouveau canari :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/tests/security_hardening.rs
|
||||
```
|
||||
|
||||
Il vérifie notamment :
|
||||
|
||||
```text
|
||||
URL/marker hostile rejeté sans echo dans Error Display/Debug
|
||||
RawPayload Debug sans bytes
|
||||
RawContentHash Debug opaque
|
||||
RawTransactionSignature Debug opaque
|
||||
RawTransaction Debug sans payload bytes
|
||||
RawAccountState Debug sans account bytes
|
||||
cursor exact à la borne accepté
|
||||
cursor au-dessus de la borne rejeté
|
||||
page limit 0 rejeté
|
||||
page limit u64::MAX accepté sans policy Store artificielle
|
||||
ExpectedStateMismatch distinct de Applied/AlreadyAtTarget
|
||||
```
|
||||
|
||||
## Complétude et frontières
|
||||
|
||||
Nouveau canari :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/tests/release_completeness.rs
|
||||
```
|
||||
|
||||
Il verrouille :
|
||||
|
||||
```text
|
||||
60 exports crate-root exacts
|
||||
aucun pub mod
|
||||
inventaire exact src/model/capability
|
||||
public enums évolutifs #[non_exhaustive]
|
||||
10 traits capability fins exacts
|
||||
aucun Store/StoreBackend runtime monolithique
|
||||
aucun RawLogNotification/RawSlotEvent/RawVoteEvent public
|
||||
aucun YellowstoneEntry
|
||||
aucun Structural*/Decoded*/Domain* public
|
||||
aucun backend PostgreSQL/MySQL dans l'API
|
||||
aucun modèle persistant Store réexporté par ksp-interface-lib
|
||||
```
|
||||
|
||||
La frontière durable reste :
|
||||
|
||||
```text
|
||||
ksp-interface-lib
|
||||
-> contrats/wires/event-only inter-composants lorsque réellement nécessaires
|
||||
|
||||
ksp-store-api
|
||||
-> modèles persistants/replayables/queryables + capabilities backend-agnostic
|
||||
```
|
||||
|
||||
Aucun modèle event-only nouveau n'est créé par cette tranche.
|
||||
|
||||
## Hors scope
|
||||
|
||||
```text
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
SQL/migrations
|
||||
Config std.store
|
||||
Transport -> RAW adapter
|
||||
RawLogNotification / RawSlotEvent / RawVoteEvent
|
||||
TransactionStatusObservation
|
||||
RawBlock / Yellowstone Entry
|
||||
N2 STRUCTURAL
|
||||
N3 DECODED
|
||||
N4 DOMAIN
|
||||
processing ledger
|
||||
worker/executor policy
|
||||
compression/archive physiques
|
||||
```
|
||||
|
||||
## Version Cargo
|
||||
|
||||
La tranche suivante normale porte :
|
||||
|
||||
```text
|
||||
0.3.1-pre.7
|
||||
```
|
||||
|
||||
## Validations attendues
|
||||
|
||||
```text
|
||||
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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
157
deltas/0.3.1/pre.008.md
Normal file
157
deltas/0.3.1/pre.008.md
Normal file
@@ -0,0 +1,157 @@
|
||||
<!-- file: deltas/0.3.1/pre.008.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.008` — gate technique final Store API
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Cette tranche s'applique exclusivement après :
|
||||
|
||||
```text
|
||||
0.3.1-pre.007-fix.001
|
||||
workspace.package.version = 0.3.1-pre.7-fix.1
|
||||
```
|
||||
|
||||
Le gate opérateur fourni le **29 août 2026** est intégralement vert :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
audits Rust / exports / workspace PASS / clean
|
||||
audit Markdown PASS — 186 tables / 132 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-store-api PASS
|
||||
```
|
||||
|
||||
Le gate Store API comprend :
|
||||
|
||||
```text
|
||||
15 unit tests
|
||||
2 dependency-boundary tests
|
||||
1 external-backend test
|
||||
7 public-api tests
|
||||
5 release-completeness tests
|
||||
4 security-hardening tests
|
||||
doc-tests propres
|
||||
```
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
`pre.008` est exclusivement le gate technique final de `0.3.1`.
|
||||
|
||||
Aucun nouveau modèle, capability, outcome, query, règle de rétention, event-only type, backend, codec, dépendance ou comportement runtime n'est ajouté. Les canaris déjà présents couvrent la surface fonctionnelle de la release.
|
||||
|
||||
La tranche doit seulement :
|
||||
|
||||
```text
|
||||
bump la prerelease Cargo vers pre.8
|
||||
mettre le plan et la validation en état de gate final
|
||||
tracer le présent delta
|
||||
faire rejouer le gate workspace complet + graphes Cargo par l'opérateur
|
||||
```
|
||||
|
||||
## 3. Version
|
||||
|
||||
La prerelease non-fix synchronise l'identité Cargo :
|
||||
|
||||
```text
|
||||
0.3.1-pre.7-fix.1
|
||||
->
|
||||
0.3.1-pre.8
|
||||
```
|
||||
|
||||
Le bump garantit une identité de build distincte après la série `pre.007/fix.001` et évite toute ambiguïté avec les artefacts Cargo précédents.
|
||||
|
||||
## 4. Surface déjà couverte
|
||||
|
||||
Les canaris existants verrouillent notamment :
|
||||
|
||||
```text
|
||||
RawTransaction + RawTransactionObservation
|
||||
RawAccountState + RawAccountObservation
|
||||
provenance / références / payload / hashes bornés
|
||||
queries cursorisées sans plafond métier Store arbitraire
|
||||
outcomes idempotence/conflict
|
||||
atomic acquisition contracts
|
||||
RawRetentionState + tombstone + force-rehydrate
|
||||
ExpectedStateMismatch pour les races de rétention
|
||||
10 capabilities fines object-safe
|
||||
backend externe implémentable sans ksp-store-lib
|
||||
60 exports crate-root exacts
|
||||
frontière ksp-interface-lib / ksp-store-api
|
||||
aucun backend SQL/runtime
|
||||
aucune surface N2 STRUCTURAL / N3 DECODED / N4 DOMAIN
|
||||
```
|
||||
|
||||
Aucun nouveau test n'est ajouté uniquement pour matérialiser `pre.008`.
|
||||
|
||||
## 5. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.3.1/pre.008.md
|
||||
```
|
||||
|
||||
## 6. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## 7. Surface explicitement inchangée
|
||||
|
||||
Cette tranche ne touche pas :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/src/**
|
||||
crates/ksp-store-api/tests/**
|
||||
crates/ksp-store-api/unit_tests/**
|
||||
crates/ksp-store-api/Cargo.toml
|
||||
README.md
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
prompts/**
|
||||
config/**
|
||||
docs/architecture/**
|
||||
docs/rules/**
|
||||
```
|
||||
|
||||
## 8. Gate opérateur final
|
||||
|
||||
Exécuter :
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
cargo test -p ksp-logging-lib --test ownership
|
||||
cargo test --workspace
|
||||
cargo tree -p ksp-store-api --edges normal
|
||||
cargo tree --duplicates
|
||||
```
|
||||
|
||||
Résultat architectural attendu pour le graphe ciblé :
|
||||
|
||||
```text
|
||||
ksp-store-api
|
||||
└── ksp-core-lib
|
||||
└── dépendances low-level Core existantes
|
||||
```
|
||||
|
||||
Aucune dépendance Store -> Interface/Transport/Program/Config/Logging/backend DB ne doit apparaître.
|
||||
|
||||
`cargo tree --duplicates` est un gate d'inspection : les doublons transitifs déjà présents dans le workspace doivent être observés, mais `pre.008` n'introduit aucune dépendance nouvelle susceptible d'en créer.
|
||||
|
||||
## 9. Suite
|
||||
|
||||
Si ce gate complet est vert :
|
||||
|
||||
```text
|
||||
0.3.1-pre.009
|
||||
```
|
||||
|
||||
ouvre uniquement la réconciliation documentaire finale (`ksp-store-api/README.md`, `USAGE.md`, plan, validation et indexes/références durables réellement concernés), sans rouvrir `CHANGELOG.md`, `ROADMAP.md` ni le prompt `0.3.2`.
|
||||
123
deltas/0.3.1/pre.009.md
Normal file
123
deltas/0.3.1/pre.009.md
Normal file
@@ -0,0 +1,123 @@
|
||||
<!-- file: deltas/0.3.1/pre.009.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.009` — Réconciliation documentaire finale Store API RAW
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.008
|
||||
```
|
||||
|
||||
Le gate opérateur de cette base a été exécuté après `cargo clean` et passe les audits Rust/Markdown, `cargo check --workspace`, Clippy, les tests ciblés des crates présentes, `cargo test --workspace`, les trois builds Tauri Linux et les graphes Cargo. La commande ciblée initiale `cargo test -p ksp-program-lib` utilisait un nom de package inexistant ; `cargo test -p ksp-program-api` a ensuite été exécuté séparément et passe intégralement.
|
||||
|
||||
## Objectif
|
||||
|
||||
Fermer la réconciliation documentaire durable de `0.3.1` sans développement fonctionnel nouveau et sans empiéter sur la lane de publication `pre.010`.
|
||||
|
||||
La tranche doit refléter la surface réellement matérialisée de `ksp-store-api` :
|
||||
|
||||
```text
|
||||
RawTransaction / RawTransactionObservation
|
||||
RawAccountState / RawAccountObservation
|
||||
primitives RAW/provenance/format/hash/timestamps
|
||||
queries cursorisées sans plafond métier KSP arbitraire
|
||||
outcomes d'idempotence/conflit
|
||||
RawRetentionState + tombstone transactionnel
|
||||
normal skip après purge + force rehydrate explicite
|
||||
10 capabilities backend fines/object-safe
|
||||
```
|
||||
|
||||
Elle doit également figer explicitement les reports :
|
||||
|
||||
```text
|
||||
TransactionStatusObservation
|
||||
logs/slot/vote event-only contracts
|
||||
StoreHealth/readiness runtime
|
||||
type canonique dédié de wake-up persisté-data
|
||||
processing ledger / backlog métier / executor policy
|
||||
compression/archive physique
|
||||
ksp-store-lib / PostgreSQL / std.store
|
||||
N2 STRUCTURAL / N3 DECODED / N4 DOMAIN
|
||||
```
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.3.1/pre.009.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/000-README.md
|
||||
docs/IDEAS.md
|
||||
docs/architecture/003-COMPONENT_CONTRACTS.md
|
||||
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||
docs/architecture/005-DEPENDENCY_GRAPH.md
|
||||
docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md
|
||||
docs/architecture/010-APPS_SERVICES_SCENARIOS_AND_CONTROL.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/000-README.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Décisions réconciliées
|
||||
|
||||
- `workspace.package.version` passe à `0.3.1-pre.9` conformément à `VER-ID-009` pour une nouvelle prerelease non-fix, même documentaire.
|
||||
- `ksp-store-api` reste la seule crate Store de `0.3.1` et conserve uniquement `ksp-core-lib` comme dépendance runtime normale.
|
||||
- Le health/readiness n'est pas un contrat RAW nécessaire et n'est pas inventé dans `ksp-store-api`; il est reporté au runtime `ksp-store-lib` de `0.3.2` si un consumer réel le justifie.
|
||||
- `0.3.1` n'ajoute aucun type dédié de notification persistée-data. Les références durables existantes pourront alimenter un futur contrat de wake-up lorsqu'un publisher/consumer réel sera introduit.
|
||||
- PostgreSQL `LISTEN/NOTIFY` n'est plus présenté comme mécanisme déjà retenu. Un futur publisher suit `persist -> commit -> publish`; un mécanisme PostgreSQL éventuel reste une optimisation backend/runtime et jamais un event bus possédé par Store.
|
||||
- Les consumers runtime ordinaires — jobs, workers et apps — utilisent `ksp-store-lib` à partir de `0.3.2`; ils ne dépendent pas d'un backend concret. Les crates backend implémentent `ksp-store-api` et ne dépendent pas de `ksp-store-lib`.
|
||||
- La pagination Store exprime navigation/cursorisation. Batch-size, priorité et policy de sélection appartiennent au worker/job/executor. Store ne crée aucun plafond métier global inférieur à une capacité physique disponible du backend.
|
||||
- `RawAccountState` et `RawAccountObservation` sont réconciliés comme surface réellement livrée de `0.3.1`, tandis que `TransactionStatusObservation` reste explicitement reporté.
|
||||
- Les index `docs/`, plans et validations référencent désormais `0.2.14` et la candidate `0.3.1` actuelle.
|
||||
- `README.md` racine ne contient aucune surface Store détaillée devenue fausse et aucun README/USAGE propre à `ksp-store-api` n'existe encore ; aucun fichier artificiel n'est créé uniquement pour satisfaire une prévision ancienne.
|
||||
- `CHANGELOG.md`, `ROADMAP.md` et `prompts/021-V0_3_2_START_PROMPT.md` restent strictement réservés à `pre.010`.
|
||||
- La réconciliation globale du vocabulaire historique `CORE` vers le futur N2 `STRUCTURAL` n'est toujours pas ouverte dans les architectures générales ; elle reste reportée au moment où N2 sera réellement introduit.
|
||||
|
||||
## Validations exécutées pendant la génération
|
||||
|
||||
```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.3.1
|
||||
-> Markdown table audit: clean (186 table(s), 134 file(s))
|
||||
```
|
||||
|
||||
Les tableaux modifiés ont été réalignés selon le format RustRover attendu par l'audit Markdown.
|
||||
|
||||
## Validations non exécutées pendant la génération
|
||||
|
||||
L'environnement de génération ne dispose pas de la toolchain Cargo/Rust utilisée par l'opérateur ; aucun `cargo check`, Clippy ou test Cargo n'est revendiqué pour cette archive.
|
||||
|
||||
Aucun code Rust, manifest de crate, ressource runtime, migration ou configuration exécutable n'est modifié dans cette tranche. Le gate lourd `pre.008` reste la preuve technique de référence ; `pre.009` nécessite seulement un re-gate ciblé après application.
|
||||
|
||||
## Gate opérateur demandé
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Les builds Tauri et le `cargo test --workspace` complet n'ont pas besoin d'être rejoués pour cette tranche purement documentaire/versionnée si ce gate ciblé reste vert.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Aucune question bloquante pour `0.3.1`.
|
||||
|
||||
Les décisions runtime/backend restantes appartiennent au prompt et à la release `0.3.2` : façade `ksp-store-lib`, backend `ksp-store-postgres-lib`, Config `std.store`, migrations et validation PostgreSQL réelle.
|
||||
135
deltas/0.3.1/pre.010.md
Normal file
135
deltas/0.3.1/pre.010.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<!-- file: deltas/0.3.1/pre.010.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.010` — Redécoupage documentaire des slices Store/PostgreSQL
|
||||
|
||||
## Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.009
|
||||
```
|
||||
|
||||
Le gate opérateur ciblé de `pre.009` est propre : audits Rust/Markdown, `cargo check --workspace`, Clippy et `cargo test -p ksp-store-api` passent. Le gate lourd `pre.008`, reconstruit après `cargo clean`, reste la preuve technique de référence pour le workspace et les bundles Tauri.
|
||||
|
||||
## Objectif
|
||||
|
||||
Réduire avant publication le scope de la future implémentation Store/PostgreSQL afin que chaque release concrète reste clôturable dans une session sans sacrifier migrations, concurrence, atomicité, idempotence ou validation PostgreSQL réelle.
|
||||
|
||||
La décision opérateur est :
|
||||
|
||||
```text
|
||||
ksp-store-lib et ksp-store-postgres-lib restent développées ensemble
|
||||
mais l'ancien scope unique 0.3.2 est découpé en trois releases
|
||||
```
|
||||
|
||||
Nouvelle trajectoire :
|
||||
|
||||
```text
|
||||
0.3.2 fondation runtime/backend PostgreSQL
|
||||
0.3.3 vertical slice PostgreSQL RawTransaction complète
|
||||
0.3.4 vertical slice PostgreSQL RawAccountState + complétude Store RAW
|
||||
0.3.5 Interface events/acquisitions partagés si consumer réel
|
||||
0.3.6 ksp-job-api + premier backfill RAW
|
||||
0.3.7 application backfill/inspection RAW
|
||||
```
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.3.1/pre.010.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||
docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Décisions figées
|
||||
|
||||
- `workspace.package.version` passe à `0.3.1-pre.10` : nouvelle prerelease non-fix, même si la tranche est documentaire.
|
||||
- `ksp-store-lib` et `ksp-store-postgres-lib` ne sont **jamais** séparées en deux releases où l'une existerait sans l'autre ; elles avancent de pair sur `0.3.2`, `0.3.3` et `0.3.4`.
|
||||
- `0.3.2` est limité à la fondation runtime/backend : crates, features, Config `std.store`, sélection backend, erreur backend connu non compilé, connexion/TLS/pooling à auditer, migrations/bootstrap privés, health/readiness portable si le gate `pre.001` le confirme.
|
||||
- `0.3.2` n'implémente pas artificiellement les capabilities `RawTransaction` ou `RawAccountState` uniquement pour augmenter le scope.
|
||||
- `0.3.3` implémente la conformance PostgreSQL complète de `RawTransaction` : observation, get/list cursorisé, atomicité RAW + observation, idempotence/conflit, rétention/tombstone/force-rehydrate et races/rollback réels.
|
||||
- `0.3.4` implémente `RawAccountState` + observation, puis la complétude/conformance cross-family, les migrations/indexes finaux et le gate PostgreSQL réel final de la couche RAW.
|
||||
- La pagination Store reste une primitive de navigation/cursorisation ; batch-size, priorité et policy appartiennent aux workers/jobs/executors. Une limitation physique backend peut être reflétée sans devenir un plafond métier global imposé par Store.
|
||||
- Les anciennes étapes Interface/backfill/app sont renumérotées `0.3.5`, `0.3.6`, `0.3.7`.
|
||||
- La préparation de publication minimale est décalée de `pre.010` à `pre.011` afin de ne pas mélanger cette correction de trajectoire avec `CHANGELOG.md`, `ROADMAP.md` et le prompt `0.3.2`.
|
||||
- `ROADMAP.md`, `CHANGELOG.md` et `prompts/021-V0_3_2_START_PROMPT.md` restent donc inchangés dans `pre.010`; ils seront réconciliés uniquement dans `pre.011` conformément à `PROMPT_STRUCTURE.md`.
|
||||
|
||||
## Pourquoi trois slices
|
||||
|
||||
L'ancien `0.3.2` cumulait au minimum :
|
||||
|
||||
```text
|
||||
création de deux crates
|
||||
feature/backend dispatch
|
||||
Config et secrets
|
||||
connexion/pool/TLS PostgreSQL
|
||||
migrations/bootstrap
|
||||
RawTransaction
|
||||
RawAccountState
|
||||
queries/pagination
|
||||
atomicité/idempotence/races
|
||||
rétention/tombstones/rehydration
|
||||
health/readiness
|
||||
validation PostgreSQL réelle
|
||||
```
|
||||
|
||||
Deux slices auraient encore concentré la fondation runtime/backend et une famille RAW lourde dans la même session. Trois slices réduisent le risque de saturation tout en conservant une progression architecturale cohérente et testable.
|
||||
|
||||
## Validations exécutées pendant la génération
|
||||
|
||||
```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.3.1
|
||||
-> Markdown table audit: clean (186 table(s), 136 file(s))
|
||||
```
|
||||
|
||||
Les tableaux touchés ont été réalignés selon le format RustRover attendu par l'audit Markdown.
|
||||
|
||||
## Validations non exécutées pendant la génération
|
||||
|
||||
L'environnement de génération ne dispose pas de la toolchain Cargo/Rust utilisée par l'opérateur ; aucun `cargo check`, Clippy ou test Cargo n'est revendiqué pour cette archive.
|
||||
|
||||
Aucun code Rust, manifest de crate, schema Config, migration ou runtime n'est modifié dans cette tranche.
|
||||
|
||||
## Gate opérateur demandé
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Le gate lourd workspace/Tauri n'a pas besoin d'être rejoué pour cette tranche documentaire si ce gate ciblé reste vert.
|
||||
|
||||
## Suite
|
||||
|
||||
Si `pre.010` est propre, `pre.011` est la préparation de publication minimale :
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
prompts/021-V0_3_2_START_PROMPT.md
|
||||
deltas/0.3.1/pre.011.md
|
||||
```
|
||||
|
||||
Le prompt `0.3.2` devra être strictement dimensionné sur la **fondation runtime/backend PostgreSQL** et annoncer explicitement `0.3.3`/`0.3.4` comme slices suivantes, sans commencer leur implémentation.
|
||||
270
deltas/0.3.1/pre.011.md
Normal file
270
deltas/0.3.1/pre.011.md
Normal file
@@ -0,0 +1,270 @@
|
||||
<!-- file: deltas/0.3.1/pre.011.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-pre.011` — préparation de publication et prompt `0.3.2`
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.3.1-pre.010
|
||||
workspace.package.version = 0.3.1-pre.10
|
||||
```
|
||||
|
||||
Le gate opérateur de `pre.010`, exécuté le **29 août 2026**, est propre :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS / clean
|
||||
python3 scripts/audit_markdown_tables.py PASS / clean — 186 tables / 135 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-store-api PASS
|
||||
unit 15/15
|
||||
dependency_boundary 2/2
|
||||
external_backend 1/1
|
||||
public_api 7/7
|
||||
release_completeness 5/5
|
||||
security_hardening 4/4
|
||||
```
|
||||
|
||||
Le gate technique lourd de référence de `pre.008`, exécuté après `cargo clean`, a également validé le workspace complet, les tests ciblés, `cargo test --workspace`, les trois builds Tauri et les graphes Cargo. Le seul nom de package erroné utilisé dans la première séquence (`ksp-program-lib`) a été immédiatement remplacé par `cargo test -p ksp-program-api`, qui passe intégralement.
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Dernière prerelease avant `rel.001`, strictement limitée à la préparation de publication :
|
||||
|
||||
```text
|
||||
bump workspace vers 0.3.1-pre.11
|
||||
ajout de l'entrée stable candidate 0.3.1 dans CHANGELOG.md
|
||||
fermeture 0.3.1 et trajectoire 0.3.2..0.3.7 dans ROADMAP.md
|
||||
création du prompt 0.3.2
|
||||
delta pre.011
|
||||
```
|
||||
|
||||
Aucun code, test, manifest de crate, README/USAGE, plan, validation, architecture ou règle normative n'est rouvert.
|
||||
|
||||
## 3. Version Cargo
|
||||
|
||||
```text
|
||||
0.3.1-pre.10
|
||||
-> 0.3.1-pre.11
|
||||
```
|
||||
|
||||
## 4. Changelog `0.3.1`
|
||||
|
||||
`CHANGELOG.md` enregistre la surface stable candidate réellement livrée :
|
||||
|
||||
```text
|
||||
ksp-store-api uniquement
|
||||
RawTransaction + observation
|
||||
RawAccountState + observation
|
||||
primitives RAW/provenance/hash/timestamp
|
||||
queries cursorisées sans plafond métier KSP
|
||||
10 capabilities object-safe
|
||||
idempotence/conflit
|
||||
rétention/tombstone/force-rehydrate
|
||||
ExpectedStateMismatch
|
||||
60 exports crate-root
|
||||
backend externe canari
|
||||
aucun PostgreSQL/runtime Config/Transport/Program/Logging
|
||||
aucune surface STRUCTURAL/DECODED/DOMAIN
|
||||
```
|
||||
|
||||
Il enregistre aussi le gate de référence après `cargo clean` et le redécoupage final :
|
||||
|
||||
```text
|
||||
0.3.2 fondation Store/PostgreSQL
|
||||
0.3.3 RawTransaction PostgreSQL
|
||||
0.3.4 RawAccountState + complétude RAW
|
||||
```
|
||||
|
||||
## 5. Roadmap
|
||||
|
||||
`ROADMAP.md` :
|
||||
|
||||
```text
|
||||
passe 0.3.1 à [X]
|
||||
réduit explicitement 0.3.2 à la fondation runtime/backend
|
||||
ajoute 0.3.3 RawTransaction
|
||||
ajoute 0.3.4 RawAccountState + complétude
|
||||
renumérote Interface / Job / application en 0.3.5 / 0.3.6 / 0.3.7
|
||||
reclasse les TODO déjà matérialisés par 0.3.1
|
||||
```
|
||||
|
||||
La règle de pagination reste visible : Store navigue/cursorise ; batch-size, priorité et policy appartiennent aux futurs workers/jobs/executors.
|
||||
|
||||
## 6. Prompt `0.3.2`
|
||||
|
||||
Le nouveau prompt est confronté à :
|
||||
|
||||
```text
|
||||
docs/rules/PROMPT_STRUCTURE.md
|
||||
docs/rules/VERSION_WORKFLOW.md
|
||||
docs/rules/FILE_CONTRACTS.md
|
||||
docs/rules/RULES_KSP.md
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
|
||||
docs/architecture/003-COMPONENT_CONTRACTS.md
|
||||
docs/architecture/004-COMPONENT_INVENTORY.md
|
||||
docs/architecture/005-DEPENDENCY_GRAPH.md
|
||||
docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md
|
||||
docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md
|
||||
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
```
|
||||
|
||||
L'archive historique reste requise :
|
||||
|
||||
```text
|
||||
khadhroony-bot3_v0.5.3-pre.005-fix010.zip
|
||||
```
|
||||
|
||||
mais seulement pour réauditer les éléments physiques Store/PostgreSQL pertinents : runtime, pool, Config, migrations, schema/versioning et health. Elle n'est jamais une base de code.
|
||||
|
||||
Références externes vérifiées lors de la préparation du prompt, le **29 août 2026** :
|
||||
|
||||
```text
|
||||
PostgreSQL stable : 18.6
|
||||
tokio-postgres : 0.7.18
|
||||
```
|
||||
|
||||
Le prompt impose de réauditer ces versions à l'ouverture réelle de `0.3.2`.
|
||||
|
||||
## 7. Scope strict du prompt suivant
|
||||
|
||||
`0.3.2` ouvre ensemble :
|
||||
|
||||
```text
|
||||
ksp-store-lib
|
||||
ksp-store-postgres-lib
|
||||
```
|
||||
|
||||
pour :
|
||||
|
||||
```text
|
||||
features/backend dispatch
|
||||
Store settings/lifecycle
|
||||
Config std.store
|
||||
connexion/pool/TLS PostgreSQL
|
||||
migrations/bootstrap privés
|
||||
health/readiness seulement si justifié
|
||||
PostgreSQL integration réelle
|
||||
```
|
||||
|
||||
Sont explicitement réservés :
|
||||
|
||||
```text
|
||||
0.3.3 -> RawTransaction PostgreSQL complet
|
||||
0.3.4 -> RawAccountState PostgreSQL + complétude RAW
|
||||
```
|
||||
|
||||
Aucune table RAW métier n'est demandée à `0.3.2`.
|
||||
|
||||
## 8. Prévision souple intégrée au prompt
|
||||
|
||||
Le prompt réserve :
|
||||
|
||||
```text
|
||||
pre.001 audit/threat model/dependencies/sizing
|
||||
pre.002 scaffold deux crates + feature graph
|
||||
pre.003 settings/backend selection/lifecycle
|
||||
pre.004 Config std.store
|
||||
pre.005 connexion/pool/TLS
|
||||
pre.006 migrations/bootstrap
|
||||
pre.007 composition facade/backend + diagnostics
|
||||
pre.008 PostgreSQL integration réelle
|
||||
pre.009 hardening/completeness/feature matrix
|
||||
pre.010 gate technique final
|
||||
pre.011 réconciliation documentaire
|
||||
pre.012 préparation de publication
|
||||
rel.001 publication stable
|
||||
```
|
||||
|
||||
La prévision reste souple et doit être recalibrée par `0.3.2-pre.001`.
|
||||
|
||||
## 9. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
prompts/021-V0_3_2_START_PROMPT.md
|
||||
deltas/0.3.1/pre.011.md
|
||||
```
|
||||
|
||||
## 10. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
CHANGELOG.md
|
||||
ROADMAP.md
|
||||
```
|
||||
|
||||
## 11. Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## 12. Surfaces explicitement non rouvertes
|
||||
|
||||
```text
|
||||
README.md
|
||||
RULES.md
|
||||
.env.example
|
||||
config/**
|
||||
crates/**
|
||||
docs/**
|
||||
prompts/001..020
|
||||
```
|
||||
|
||||
En particulier :
|
||||
|
||||
```text
|
||||
crates/ksp-store-api/**
|
||||
docs/plans/022-V0_3_1_STORE_RAW_PLAN.md
|
||||
docs/validation/018-V0_3_1_STORE_RAW.md
|
||||
docs/architecture/**
|
||||
```
|
||||
|
||||
## 13. Validations de préparation
|
||||
|
||||
À exécuter sur l'arbre reconstruit :
|
||||
|
||||
```text
|
||||
audit Rust workspace
|
||||
audit Markdown
|
||||
contrôle exact du payload overlay
|
||||
contrôle versions/file headers
|
||||
contrôle qu'aucun fichier hors lane n'est modifié
|
||||
```
|
||||
|
||||
Aucun résultat Cargo nouveau n'est revendiqué par la génération de cette tranche.
|
||||
|
||||
## 14. Gate opérateur
|
||||
|
||||
La lane est documentaire/de publication ; un gate ciblé suffit :
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-store-api
|
||||
```
|
||||
|
||||
Le gate lourd workspace/Tauri a déjà été exécuté sur `pre.008` et aucune surface code/runtime/dependency n'est rouverte depuis.
|
||||
|
||||
## 15. Étape suivante
|
||||
|
||||
Si le gate reste vert :
|
||||
|
||||
```text
|
||||
0.3.1-rel.001
|
||||
```
|
||||
|
||||
La publication stable doit être strictement mécanique :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.3.1
|
||||
deltas/0.3.1/rel.001.md
|
||||
```
|
||||
|
||||
`rel.001` ne doit rouvrir ni `CHANGELOG.md`, ni `ROADMAP.md`, ni le prompt `0.3.2`.
|
||||
272
deltas/0.3.1/rel.001.md
Normal file
272
deltas/0.3.1/rel.001.md
Normal file
@@ -0,0 +1,272 @@
|
||||
<!-- file: deltas/0.3.1/rel.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.3.1-rel.001` — publication stable Store API RAW foundation
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
Base directe attendue :
|
||||
|
||||
```text
|
||||
0.3.1-pre.011
|
||||
workspace.package.version = 0.3.1-pre.11
|
||||
```
|
||||
|
||||
Commit attendu pour cette livraison :
|
||||
|
||||
```text
|
||||
v0.3.1-rel.001
|
||||
```
|
||||
|
||||
Tag stable attendu après validation :
|
||||
|
||||
```text
|
||||
v0.3.1
|
||||
```
|
||||
|
||||
## 2. Responsabilité de `rel.001`
|
||||
|
||||
Cette livraison effectue uniquement la mécanique de publication stable définie par `VER-LIFECYCLE-012`.
|
||||
|
||||
Elle :
|
||||
|
||||
- passe `workspace.package.version` de `0.3.1-pre.11` à `0.3.1` ;
|
||||
- ajoute le présent delta `rel.001` ;
|
||||
- ne corrige ni code, ni test, ni manifest de crate, ni README/USAGE, ni plan/validation, ni architecture/règle, ni prompt, ni `CHANGELOG.md`, ni `ROADMAP.md`.
|
||||
|
||||
Tout défaut appartenant à un couloir antérieur doit renvoyer vers une prerelease appropriée ; `rel.001` n'est jamais une tranche de rattrapage.
|
||||
|
||||
## 3. Preuves acquises avant publication
|
||||
|
||||
Le gate technique complet de `0.3.1-pre.008`, exécuté le **29 août 2026** après `cargo clean`, a reconstruit et validé la workspace depuis zéro :
|
||||
|
||||
```text
|
||||
cargo clean PASS
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
General Rust rule audit clean
|
||||
Rust export completeness audit 0 candidate(s)
|
||||
KSP workspace Rust rule audit clean
|
||||
python3 scripts/audit_markdown_tables.py PASS
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test --workspace PASS
|
||||
cargo tauri build — SOL Prices Desk PASS
|
||||
cargo tauri build — Wallet Desk PASS
|
||||
cargo tauri build — Config Desk PASS
|
||||
cargo tree -p ksp-store-api --edges normal inspecté
|
||||
cargo tree --duplicates inspecté
|
||||
```
|
||||
|
||||
Le test ciblé `ksp-program-api`, initialement invoqué sous un nom de package erroné dans la séquence opérateur, a ensuite été rejoué explicitement avec le bon package et est passé intégralement.
|
||||
|
||||
Le gate ciblé de la préparation minimale de publication `0.3.1-pre.011`, exécuté le **29 août 2026**, est également vert :
|
||||
|
||||
```text
|
||||
cargo fmt --all PASS
|
||||
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||
General Rust rule audit clean
|
||||
Rust export completeness audit 0 candidate(s)
|
||||
KSP workspace Rust rule audit clean
|
||||
python3 scripts/audit_markdown_tables.py PASS — 186 tables / 137 fichiers
|
||||
cargo check --workspace PASS
|
||||
cargo clippy --workspace --all-targets PASS
|
||||
cargo test -p ksp-store-api PASS
|
||||
unit 15 passed
|
||||
dependency_boundary 2 passed
|
||||
external_backend 1 passed
|
||||
public_api 7 passed
|
||||
release_completeness 5 passed
|
||||
security_hardening 4 passed
|
||||
doc-tests 0 failed
|
||||
```
|
||||
|
||||
Les tranches `pre.009`, `pre.010` et `pre.011` n'ont rouvert aucun code Rust de `ksp-store-api` ni aucune dépendance runtime ; elles ont porté la réconciliation documentaire, le redécoupage de la trajectoire Store/PostgreSQL puis la préparation de publication.
|
||||
|
||||
## 4. Version stable publiée
|
||||
|
||||
La version Cargo devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.3.1
|
||||
```
|
||||
|
||||
La release stable publiée est :
|
||||
|
||||
```text
|
||||
0.3.1 — Store API RAW foundation
|
||||
```
|
||||
|
||||
La surface stable comprend notamment :
|
||||
|
||||
```text
|
||||
ksp-store-api
|
||||
RawTransaction + RawTransactionObservation
|
||||
RawAccountState + RawAccountObservation
|
||||
primitives RAW bornées et redaction-safe
|
||||
10 capabilities backend-agnostic fines et object-safe
|
||||
queries/cursors sans plafond métier arbitraire imposé par Store
|
||||
outcomes d'idempotence et de conflit
|
||||
lifecycle RawTransaction Full -> Compacted -> Archived -> Purged
|
||||
RawTransactionTombstone minimal
|
||||
ForceRehydrate explicite
|
||||
ExpectedStateMismatch pour compare-and-transition concurrent
|
||||
60 exports crate-root exacts
|
||||
runtime dependency normale : ksp-core-lib uniquement
|
||||
```
|
||||
|
||||
Restent volontairement absents de `0.3.1` :
|
||||
|
||||
```text
|
||||
ksp-store-lib runtime
|
||||
ksp-store-postgres-lib
|
||||
PostgreSQL / SQL / migrations runtime
|
||||
Config std.store
|
||||
worker / job / executor policy
|
||||
N2 STRUCTURAL
|
||||
N3 DECODED
|
||||
N4 DOMAIN
|
||||
RawLog persistant
|
||||
TransactionStatusObservation commun
|
||||
backend-specific rows / SQL ids
|
||||
```
|
||||
|
||||
La frontière de responsabilité stable reste :
|
||||
|
||||
```text
|
||||
ksp-store-api
|
||||
= modèles persistants/replayables + capabilities backend-agnostic
|
||||
|
||||
ksp-interface-lib
|
||||
= contrats passifs/event-only inter-composants lorsqu'ils existent
|
||||
|
||||
worker / job / executor
|
||||
= batch-size, priorité, stratégie de sélection, policy de traitement
|
||||
```
|
||||
|
||||
La pagination Store reste une primitive de navigation/cursorisation. Une limite physique future d'un backend peut être exposée ou gérée par continuation, mais Store n'invente pas de plafond métier global inférieur.
|
||||
|
||||
## 5. Trajectoire suivante confirmée
|
||||
|
||||
Le redécoupage final conserve `ksp-store-lib` et `ksp-store-postgres-lib` développés ensemble à chaque release, en trois slices verticales :
|
||||
|
||||
```text
|
||||
0.3.2 fondation runtime/backend PostgreSQL
|
||||
0.3.3 vertical slice PostgreSQL RawTransaction complète
|
||||
0.3.4 vertical slice PostgreSQL RawAccountState + complétude Store RAW
|
||||
0.3.5 Interface events partagés réellement nécessaires
|
||||
0.3.6 Job API + premier backfill RAW
|
||||
0.3.7 application backfill/inspection RAW
|
||||
```
|
||||
|
||||
Le prompt actif de la prochaine release est :
|
||||
|
||||
```text
|
||||
prompts/021-V0_3_2_START_PROMPT.md
|
||||
```
|
||||
|
||||
Il exige l'archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` pour l'audit ciblé des anciennes surfaces Store/PostgreSQL, sans lui donner d'autorité normative sur KSP.
|
||||
|
||||
## 6. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.3.1/rel.001.md
|
||||
```
|
||||
|
||||
## 7. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
## 8. Fichiers supprimés
|
||||
|
||||
```text
|
||||
aucun
|
||||
```
|
||||
|
||||
## 9. Validations exécutées pour préparer le delta
|
||||
|
||||
Contrôles statiques de l'overlay :
|
||||
|
||||
```text
|
||||
Cargo.toml version 0.3.1
|
||||
payload rel.001 2 fichiers exactement
|
||||
CHANGELOG.md non rouvert
|
||||
ROADMAP.md non rouvert
|
||||
prompt 021 non rouvert
|
||||
README/USAGE non rouverts
|
||||
plan / validation non rouverts
|
||||
architecture / règles non rouvertes
|
||||
code / tests non rouverts
|
||||
aucun fichier de rattrapage présent
|
||||
```
|
||||
|
||||
La préparation de l'archive ne rejoue aucun test Cargo dans l'environnement de génération. Les preuves techniques acquises sont celles des gates opérateur ci-dessus ; le gate stable final reste à exécuter après application.
|
||||
|
||||
## 10. Validation stable après application
|
||||
|
||||
Exécuter avant commit/tag :
|
||||
|
||||
```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.3.1
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Il n'est pas nécessaire de rejouer les trois builds Tauri, les graphes Cargo ni les smokes live : `rel.001` ne modifie ni code/runtime, ni dépendance, ni Config, ni surface desktop.
|
||||
|
||||
## 11. Questions ouvertes
|
||||
|
||||
Aucune question ouverte ne bloque la publication de `0.3.1`.
|
||||
|
||||
Les choix de pool, TLS, mécanisme de migrations/checksums/verrouillage, forme exacte de `StoreSettings/std.store` et health/readiness appartiennent à l'audit/sizing de `0.3.2-pre.001`.
|
||||
|
||||
Les capabilities PostgreSQL métier `RawTransaction` et `RawAccountState` restent volontairement reportées à `0.3.3` et `0.3.4` respectivement.
|
||||
|
||||
## 12. Commit et tag stable
|
||||
|
||||
Après succès du gate stable :
|
||||
|
||||
```text
|
||||
commit : v0.3.1-rel.001
|
||||
tag : v0.3.1
|
||||
```
|
||||
|
||||
Aucun tag intermédiaire `rel.001` n'est requis.
|
||||
|
||||
Commandes Git typiques :
|
||||
|
||||
```bash
|
||||
git add Cargo.toml deltas/0.3.1/rel.001.md
|
||||
git commit -m "v0.3.1-rel.001"
|
||||
git tag v0.3.1
|
||||
git push
|
||||
git push origin v0.3.1
|
||||
```
|
||||
|
||||
## 13. Suite
|
||||
|
||||
Après publication du tag stable `v0.3.1`, ouvrir exclusivement :
|
||||
|
||||
```text
|
||||
0.3.2-pre.001 — Store runtime / PostgreSQL foundation
|
||||
```
|
||||
|
||||
avec :
|
||||
|
||||
```text
|
||||
prompts/021-V0_3_2_START_PROMPT.md
|
||||
```
|
||||
|
||||
La session suivante doit recevoir :
|
||||
|
||||
```text
|
||||
archive opérateur exacte de v0.3.1
|
||||
khadhroony-bot3_v0.5.3-pre.005-fix010.zip
|
||||
```
|
||||
|
||||
`0.3.2-pre.001` reste une tranche d'audit/brainstorming/sizing avant implémentation lourde et doit préserver strictement le découpage `0.3.2` / `0.3.3` / `0.3.4` défini en fin de `0.3.1`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user