Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7cfb9557d | |||
| c27428d6c0 | |||
| dfa01b1381 | |||
| 5fc7f65b2c | |||
| bbf804b7fc | |||
| 13687ec2fe | |||
| b756da5149 | |||
| 04234926ce | |||
| bfc69f5631 | |||
| 902b5fba99 | |||
| fccb7d876c | |||
| 50d4142797 | |||
| eb6dbc31e8 | |||
| fb8b585aa2 | |||
| dad18c16d5 | |||
| a704a5722e | |||
| dfa3723a7f | |||
| 6bd593f467 | |||
| 03ad0ba063 | |||
| d95b1095e5 | |||
| bcce89f076 | |||
| 66d5b03495 | |||
| 7af751c888 | |||
| 9f2d5ea704 | |||
| 8428b75b96 | |||
| 282b616a0e | |||
| 12eaf7a3de | |||
| f28e4d87f0 | |||
| ba2e52f841 | |||
| 5f1938c7c3 | |||
| 5e2f959c01 | |||
| 8da04717b6 | |||
| c094828704 | |||
| ce658e58f7 | |||
| 7f0a642972 | |||
| 84ab2b3651 | |||
| 1f0b202135 | |||
| aa56a12846 | |||
| 10996f11f7 | |||
| 1352a61d70 | |||
| 66a3926adb | |||
| 917e602a87 | |||
| 56fadb364a | |||
| 8fa1c8ce8d | |||
| c17e78c6a8 | |||
| 61bf7ba468 | |||
| 9712c7e1f7 | |||
| a560df80ce | |||
| bb56574824 | |||
| 8363c8bdc9 | |||
| b375f263dc | |||
| e1fe419028 | |||
| 9ec4f26874 | |||
| 80eea82398 | |||
| e39cf656b2 | |||
| c4f56d9e85 | |||
| c1dbaad88d | |||
| d93184d41d | |||
| de3cec6a23 | |||
| 0a4cddafc4 | |||
| 6d2b1401aa | |||
| 0a8fb5dd1b | |||
| a8c90107b5 | |||
| ced653bfc0 | |||
| 153014be9d | |||
| 8d4b3b67fb | |||
| 6cc94ebb46 | |||
| 8e8f1f0b4a | |||
| 78e015413b | |||
| 2f0eb316f5 | |||
| 55325ffd32 | |||
| ad02b6f13c | |||
| 47b12e14e6 | |||
| c43d4d7c38 | |||
| b76baf6835 | |||
| c83e3261d0 | |||
| 28ca5bdac5 | |||
| afd4c770f9 | |||
| ecc5607fd4 | |||
| 11e4cde77f | |||
| 62ed72f2b5 | |||
| 1a07574bae | |||
| 29a888d1dc | |||
| 3d307be645 | |||
| 7dfe892467 | |||
| 2cf00e9426 | |||
| 4eba9be48c | |||
| bebfc63b75 | |||
| 0319794e59 | |||
| bcf2f16c05 | |||
| 27d4cb1f36 | |||
| c1f61a380f | |||
| 5e5ed6d1ab | |||
| 31461cf9be | |||
| 8c8b7ee986 | |||
| 5bfe6f0820 | |||
| 8c4e835fdd | |||
| 695d1080bd | |||
| e774a065b4 | |||
| 29f27ae109 | |||
| 0ee28eeb95 | |||
| 900444bff5 | |||
| 99477d0d2b | |||
| 9148920f42 |
11
.env.example
11
.env.example
@@ -1,10 +1,19 @@
|
||||
# file: .env.example
|
||||
# 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
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,8 +1,66 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 16 -->
|
||||
<!-- version: 23 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
## 0.3.4 — Store/PostgreSQL RawAccountState + complétude RAW — 2026-08-31
|
||||
|
||||
`0.3.4` complète la seconde vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` et ferme la conformance PostgreSQL des **10 capabilities** backend-agnostic de `ksp-store-api` : les six capabilities `RawTransaction*` acquises en `0.3.3` restent intactes et les quatre capabilities `RawAccountStateRead`, `RawAccountStateWrite`, `RawAccountObservationRead` et `RawAccountObservationWrite` sont désormais implémentées par `PostgresBackend` puis dispatchées par la façade `Store`. La séparation reste stricte : les consommateurs ordinaires passent par `ksp-store-lib`, le backend PostgreSQL conserve SQL/driver/pool/TLS/migrations privés, et la façade reste compilable/testable sans backend via `--no-default-features`.
|
||||
|
||||
La migration additive V002 introduit `ksp_raw_account_states` et `ksp_raw_account_observations` au-dessus de V000/V001 sans modifier leurs bytes. L'identité canonique account est `(pubkey, slot, state_hash)` : plusieurs états d'un même compte dans un même slot restent représentables lorsque `state_hash` diffère, tandis qu'une collision sur la référence complète déclenche une comparaison exacte de `lamports`, `owner`, `executable`, `rent_epoch` et `data` avant de conclure à l'idempotence ou à `store_api.raw_conflict`. Les `u64` physiques utilisent `NUMERIC(20,0)`, les clés/hashes/signatures utilisent des `BYTEA` de largeur contrainte, et les bytes account restent complets jusqu'à la borne KSP de 16 MiB. V002 contient exactement 32 ressources gérées et son checksum final est `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`; V000 et V001 restent respectivement `d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450` et `31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`.
|
||||
|
||||
L'acquisition `RawAccountState + RawAccountObservation` est transactionnelle : `INSERT ... ON CONFLICT DO NOTHING`, lecture/verrouillage du canonical en collision, comparaison exacte et rollback complet lorsque l'observation diverge. Une observation supplémentaire vérifie la référence existante sous transaction et ne crée jamais implicitement son state. Les métadonnées Yellowstone `is_startup`, `transaction_signature` et `write_version` restent optionnelles et observation-only ; aucune FK transaction n'est inventée. La navigation account utilise une keyset `(slot, pubkey, state_hash)` ASC/DESC, avec filtre pubkey optionnel et cursor KSPA V1 opaque de 109 octets lié au réseau, au filtre, au range, à la direction et à la famille afin d'empêcher les replays cross-query/cross-family. Aucun `OFFSET`, plafond métier de batch, index owner/provider/time ou lifecycle destructif account n'est introduit.
|
||||
|
||||
Les gates de clôture valident audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, tests ciblés Store/API/PostgreSQL/Config, `cargo test --workspace`, façade sans feature PostgreSQL et graphes Cargo. Le live `postgres_raw_account_live` passe sur **PostgreSQL 17** avec bootstrap V000/V001/V002, round-trip des bytes et du domaine `u64`, idempotence/conflit, états distincts même pubkey+slot, observations/metadata optionnelles, pagination/cursors, concurrence, annulation/rollback et coexistence `RawTransaction`. La réconciliation documentaire finale est ensuite validée sans réouvrir code ni migrations.
|
||||
|
||||
`prompts/024-V0_3_5_START_PROMPT.md` ouvre `0.3.5` sur `ksp-interface-lib` uniquement. Cette release doit réauditer les surfaces d'acquisition actuelles et matérialiser seulement les modèles passifs/event-only réellement partagés, sans recopier `RawTransaction`/`RawAccountState`, sans créer un event bus et sans déplacer les DTOs provider/transport. Les candidats logs, slot/root/slotsUpdates, transaction status et vote sont traités par matrice sémantique ; une famille reste reportée si la convergence ou le consumer réel n'est pas démontré. L'archive historique kbot3 n'est pas requise pour ce gate : les sources de vérité sont la base KSP stable et les contrats officiels actuels des transports concernés.
|
||||
|
||||
## 0.3.3 — Store/PostgreSQL RawTransaction vertical slice — 2026-08-30
|
||||
|
||||
`0.3.3` complète la première vertical slice RAW physique sur le couple `ksp-store-lib` / `ksp-store-postgres-lib` sans modifier les contrats backend-agnostic acquis dans `ksp-store-api`. `PostgresBackend` et la façade `Store` implémentent désormais les six capabilities `RawTransactionRead`, `RawTransactionWrite`, `RawTransactionObservationRead`, `RawTransactionObservationWrite`, `RawTransactionRetentionRead` et `RawTransactionRetentionWrite`. Une base PostgreSQL reste liée à un unique `RawNetworkId` par `ksp_store_identity`; le mauvais réseau est refusé avant I/O, les slots `u64` sont conservés exactement en `NUMERIC(20,0)`, et la migration logique V001 reste découpée en ressources tables/contraintes/indexes avec vérification de compatibilité du schéma effectif.
|
||||
|
||||
La persistence canonique utilise une transaction PostgreSQL unique pour `RawTransaction + observation`, tente l'insert sous contrainte d'unicité puis compare le contenu réel sous verrou en cas de collision : contenu identique -> idempotence, divergence -> `store_api.raw_conflict`. Les observations supplémentaires conservent leur provenance complète et ne créent jamais implicitement un canonical absent. La lecture reconstruit `Full` depuis le payload chaud et `Archived` depuis la relation archive ; `Purged` reste un tombstone minimal. La navigation est une keyset stricte `(slot, signature)` ASC/DESC sans `OFFSET`, avec cursor V1 opaque de 109 octets lié au réseau, à la direction, aux bornes et à la dernière clé. Aucun plafond métier de batch n'est introduit : seule la borne physique PostgreSQL de `LIMIT requested + 1` est appliquée.
|
||||
|
||||
La rétention physique supportée est `Full -> Archived -> Purged`, sérialisée par `FOR UPDATE`, avec outcomes `Applied`, `AlreadyAtTarget` et `ExpectedStateMismatch`. Le mode normal respecte le tombstone après purge ; `ForceRehydrate` reste explicite et atomique. `Compacted` demeure volontairement non supporté physiquement et retourne `store.postgres_retention_compaction_unsupported` plutôt que de présenter TOAST ou une compression implicite comme contrat KSP. Les erreurs backend sont réduites à des classes/phases statiques puis projetées vers des codes Store/API stables, sans URI, SQL, SQLSTATE, bind ni texte serveur.
|
||||
|
||||
La preuve PostgreSQL réelle a conduit à durcir l'introspection de schéma PostgreSQL 17 : canonicalisation ciblée des CHECK numériques reconstruits par le catalogue, conservation des littéraux texte, restauration des helpers de classification de schéma et distinction d'un drift d'une migration déjà enregistrée lorsque `schema_autoupdate=false`. Les ressources SQL V000/V001 et leurs checksums sont restés inchangés pendant ces corrections (`V000 d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450`, `V001 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51`).
|
||||
|
||||
Le gate technique final `pre.011` passe audits Rust/Markdown, `cargo check --workspace`, Clippy all-targets, les tests ciblés Store/API/PostgreSQL/Config, les tests et checks façade avec `--no-default-features`, `cargo test --workspace` et les graphes Cargo. Le live `postgres_raw_transaction_live` est ensuite rejoué avec succès sur **PostgreSQL 17**, couvrant bootstrap/drift-repair, atomicité, concurrence identique/divergente, rollback sur collision et annulation, pagination/cursor, rétention/races, ForceRehydrate et réouverture durable. Aucun build Tauri supplémentaire n'est requis : `0.3.3` ne change ni resources applicatives ni packaging desktop. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. `prompts/023-V0_3_4_START_PROMPT.md` ouvre cette slice suivante sur les quatre capabilities `RawAccount*`, une migration additive au-dessus de V000/V001, puis la conformance finale des dix capabilities RAW ; l'archive historique kbot3 y reste une source de comparaison ciblée account/observation, jamais une architecture à recopier.
|
||||
|
||||
## 0.3.2 — Store/PostgreSQL runtime foundation — 2026-08-30
|
||||
|
||||
`0.3.2` introduit ensemble `ksp-store-lib` et `ksp-store-postgres-lib` comme fondation runtime/backend PostgreSQL au-dessus des contrats backend-agnostic de `ksp-store-api`, sans encore implémenter de capability RAW métier. La façade `Store` conserve un backend connu `Postgres` activé par défaut via Cargo feature, reste compilable avec `--no-default-features`, lie chaque instance à exactement un `RawNetworkId` et n'expose aucun `Pool`, `Client`, `Row`, `Statement`, driver ou SQL physique. `ksp-config-lib` possède désormais `std.store` avec trois targets nommés `devnet`, `mainnet` et `testnet`, chacun associé à un réseau explicite et à une URI PostgreSQL Secret indépendante ; Store/backend ne lisent directement ni `.env`, ni `KSP_*`, ni `PG*`, ni `.pgpass`.
|
||||
|
||||
Le backend de référence utilise `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2`, `tokio-postgres-rustls 0.14.0`, Rustls 0.23 avec roots système/AWS-LC et des modes TLS KSP limités à `Disabled` et `VerifyFull`. Les URI sont parsées puis normalisées sous la policy typée KSP, le pool et les deadlines connect/wait/create/recycle/shutdown sont bornés, et une ouverture réussie exige une connexion physique puis un bootstrap valide. Le moteur de migrations privé commence par `V000__bootstrap.sql`, enregistre version/nom/SHA-256 dans `ksp_store_schema_migrations`, sérialise les runners par advisory transaction lock borné et refuse checksum mismatch ou schema plus récent sans down automatique. La façade fournit également `runtime_snapshot()` et `health().await` avec une projection portable/redacted de readiness et des compteurs pool sûrs.
|
||||
|
||||
Les canaris de hardening verrouillent 84 exports crate-root côté façade, la frontière backend/Config, l'absence de bypass environnement, la redaction des URI/erreurs, la matrice de features et l'absence de persistence `RawTransaction`/`RawAccountState`. Le gate technique final a été rejoué après `cargo clean` : audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés de toutes les crates, `cargo test --workspace`, graphes Cargo et les trois builds Tauri Linux passent. Le smoke PostgreSQL réel passe sur **PostgreSQL 17** et prouve bootstrap initial/idempotent/concurrent, mismatch/recovery checksum, rollback transactionnel, health `Ready` et fermeture bornée ; la policy de fondation reste PostgreSQL >= 15 sans maximum KSP artificiel.
|
||||
|
||||
`prompts/022-V0_3_3_START_PROMPT.md` ouvre ensuite uniquement la vertical slice PostgreSQL `RawTransaction` complète sur les mêmes crates et la même fondation : six capabilities transaction/observation/rétention, atomicité, idempotence/conflit, get/list cursorisé, tombstone et `ForceRehydrate`. `RawAccountState` PostgreSQL et la complétude RAW restent réservés à `0.3.4`. L'archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` reste obligatoire au `pre.001` de `0.3.3`, mais seulement pour réauditer l'ancien schéma/repository RAW transaction comme source d'héritage, jamais comme autorité KSP.
|
||||
|
||||
## 0.3.1 — Store API RAW foundation — 2026-08-29
|
||||
|
||||
`0.3.1` introduit `ksp-store-api` comme contrat backend-agnostic de persistence N1 RAW, sans runtime Store ni backend physique. La release stabilise deux familles réellement convergentes : `RawTransaction` avec payload canonique opaque/versionné, identité réseau+signature et observations d’acquisition séparées, puis `RawAccountState` avec bytes complets, identité réseau+pubkey+slot+hash et observations pouvant conserver les enrichissements Yellowstone sans les confondre avec l’état canonique. `TransactionStatusObservation` reste reporté faute de convergence sémantique suffisante entre snapshot HTTP, transition WebSocket et update Yellowstone ; `logsSubscribe`, slot/root/slotsUpdates et vote restent event-only candidats, `RawBlock` reste une idée conditionnelle et Yellowstone `Entry` reste rejeté de la taxonomie active.
|
||||
|
||||
La façade publique conserve un modèle objet sans SQL ni rows backend, des primitives de provenance/hash/timestamps bornées, des queries cursorisées sans plafond métier KSP arbitraire, des outcomes d’idempotence/conflit, dix capabilities fines object-safe et un lifecycle logique de rétention transactionnelle `Full -> Compacted -> Archived -> Purged`. Le tombstone minimal empêche le rebackfill normal après purge tandis que `ForceRehydrate` reste une intention distincte ; une course de compare-and-transition est représentée par `ExpectedStateMismatch` plutôt que par un overwrite silencieux. Le backlog, le batch-size, la priorité, la policy de processing, la compression/archive physique, les notifications runtime et les couches STRUCTURAL/DECODED/DOMAIN restent hors Store API. Le graphe normal final de `ksp-store-api` reste strictement limité à `ksp-core-lib`; aucun PostgreSQL, Tokio, serde, Config, Transport, Program, Logging ou backend concret n’entre dans la crate.
|
||||
|
||||
Les canaris de clôture verrouillent 60 exports crate-root, 10 capabilities, l’inventaire exact des modules RAW, l’implémentabilité par un backend externe, les bornes adversariales, la redaction des `Debug`, la frontière Interface/Store et l’absence de surface N2/N3/N4. Le gate technique de référence a été exécuté après `cargo clean` et passe audits Rust/Markdown, `cargo check --workspace`, Clippy, tests ciblés des crates, `cargo test --workspace`, builds Tauri des trois Desk et graphes Cargo ; les gates documentaires suivants restent également verts. Le redécoupage final prépare trois releases Store/PostgreSQL où `ksp-store-lib` et `ksp-store-postgres-lib` avancent toujours ensemble : `0.3.2` pour la fondation runtime/backend, `0.3.3` pour la vertical slice `RawTransaction`, puis `0.3.4` pour `RawAccountState` et la complétude RAW. `prompts/021-V0_3_2_START_PROMPT.md` ouvre donc uniquement la fondation conjointe Store/PostgreSQL, avec `tokio-postgres` comme driver retenu mais pooling, TLS, migrations et Config à réauditer avant implémentation lourde.
|
||||
|
||||
## 0.2.14 — Program API foundation — 2026-08-28
|
||||
|
||||
`0.2.14` introduit `ksp-program-api` comme première API publique extensible du domaine Program, volontairement limitée au décodage d’instructions et indépendante des runtimes supérieurs. La façade réexporte les contrats Core/Interface nécessaires puis possède `ProgramInstructionRecognition` (`NoMatch`, `ProgramMatch`, `ExactMatch`), `ProgramInstructionDecodeOutcome<Decoded>` (`Decoded`, `Unsupported`) et le trait `ProgramInstructionDecoder: Send + Sync`. L’output `Decoded` reste possédé par l’implémentation et ne reçoit aucun bound implicite `Debug`, `Clone`, `Send` ou `Sync`; les erreurs réelles restent dans le `Result` Core. Les Program IDs sont des `Pubkey` opaques : une implémentation externe peut prendre en charge un programme absent du registry Core sans enum centrale fermée, `Any`, JSON, descriptor global ni registry runtime.
|
||||
|
||||
Le graphe normal final reste strictement `ksp-program-api -> ksp-core-lib + ksp-interface-lib`, Interface dépendant elle-même de Core. Aucun `ksp-program-lib`, codec, serde, logging, réseau, filesystem, environnement, Store, Materializer, Wallet, Config ou Tauri n’entre dans cette foundation. Les canaris public API, implémentation externe, dependency firewall, release completeness et hardening couvrent notamment l’inventaire exact de dix exports crate-root, les trois modules de production, un Program Pubkey non enregistré, l’input Interface maximal de 255 accounts / 10 240 bytes, l’absence d’echo automatique d’un payload hostile et l’absence de claim `dyn` hétérogène. `pre.005-fix.001` corrige uniquement un faux positif cross-crate du scanner Logging provoqué par le motif de test recherché, sans changement fonctionnel. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, les 18 tests Program API, ownership Logging et le workspace complet avant la réconciliation documentaire finale.
|
||||
|
||||
`prompts/020-V0_3_1_START_PROMPT.md` ouvre `0.3.1 — Store RAW foundation` exclusivement depuis le tag stable `v0.2.14`. Le gate `pre.001` exige également l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer l’ancien `ks-store`, ses migrations PostgreSQL et ses contrats N1/N2/N3 sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`. L’archive reste une source historique uniquement : `0.3.1` doit créer `ksp-store-api` et `ksp-store-lib` avec PostgreSQL de référence et persistence **RAW seulement**, sans aspirer les contrats CORE/DECODE/SPECIALIZED, les jobs/workers, l’Interface `0.3.2` ni la configuration produit.
|
||||
|
||||
## 0.2.13 — Interface / wire foundation — 2026-08-28
|
||||
|
||||
`0.2.13` introduit `ksp-interface-lib` comme première façade wire officielle KSP, volontairement passive et Program-facing. La surface stable réexporte le `Pubkey` canonique de Core, ajoute `ProgramAccountMeta` et `ProgramInstruction` à champs privés avec accessors explicites, conserve l’ordre et les doublons des account metas, accepte les Program Pubkeys opaques et borne l’admission à **255 account metas** et **10 240 octets** de data. Les deux bornes sont des limites d’admission Interface et ne prétendent pas garantir à elles seules le fit d’une transaction Solana top-level. Les erreurs réutilisent le contrat Core `Error/Result` avec uniquement `field`, `actual_len` et `maximum_len`, tandis que le `Debug` de l’instruction n’expose que `program_id`, `account_count` et `data_len`.
|
||||
|
||||
La dependency direction finale reste strictement `ksp-interface-lib -> ksp-core-lib -> solana-pubkey`. Aucun `serde`, codec générique (`borsh`, `wincode`, `bincode`), `solana-instruction`, runtime réseau ou logging n’est ajouté à cette foundation. Les canaris public API, consumer externe, dependency firewall et release completeness verrouillent les six exports crate-root, les quatre modules de production, l’absence de narrowing/codec caché, le transfert des `Vec` admis sans réallocation interne et la non-répétition de matériel hostile dans les erreurs. `pre.005-fix.001` corrige uniquement un faux positif du scanner Logging provoqué par le littéral de test `tracing::`, sans changement de production ni de dépendance. Les gates `pre.006` et `pre.007` passent ensuite audits Rust/Markdown, `cargo check`, Clippy, tests Interface, ownership Logging, workspace complet et graphes Cargo avant la réconciliation documentaire finale.
|
||||
|
||||
`prompts/019-V0_2_14_START_PROMPT.md` ouvre `0.2.14 — Program API foundation` exclusivement depuis le tag stable `v0.2.13`. Le gate `pre.001` de cette prochaine release exige aussi l’archive historique `khadhroony-bot3_v0.5.3-pre.005-fix010.zip` afin d’auditer les anciens contrats decoder/executor sous une matrice `REPRENDRE / REDESSINER / REPORTER / REJETER`, sans faire de kbot3 une autorité architecturale ni recréer son monolithe `ks-lib`.
|
||||
|
||||
## 0.2.12 — SOL Prices Desk + projection prix Wallet — 2026-08-27
|
||||
|
||||
`0.2.12` ajoute `ksp-app-solprices-desk`, troisième Desk Tauri spécialisé de KSP, comme HID strictement provider-neutral au-dessus de `ksp-offchain-transport-lib`. Son composite dédié sélectionne `std.logging` et `std.offchain_transport`; la vue Prices expose l’inventaire des huit providers SOL/USD, disponibilité, sémantique, auth, prix exact sous forme de chaîne, timestamps provider/KSP distincts et retry deadline sans inventer de fraîcheur. Les refresh manuel individuel, sélectionné et global passent exclusivement par `MarketPriceService`; les batchs sont bornés à `1..=64`, les états `in_flight` restent backend-owned et aucun polling, scheduler, fallback, moyenne ou prix canonique n’est introduit dans le Desk. Le frontend conserve l’absence de réseau/persistence navigateur et journalise les interactions sans prix, credential, URL, header ou payload provider.
|
||||
|
||||
16
Cargo.toml
16
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 302
|
||||
# version: 382
|
||||
|
||||
[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-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.12"
|
||||
version = "0.3.4"
|
||||
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 }
|
||||
|
||||
64
ROADMAP.md
64
ROADMAP.md
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 90 -->
|
||||
<!-- version: 98 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -57,8 +57,8 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
- [X] `0.2.10` — OrbitFlare Yellowstone gRPC stable : profil Config V3 Devnet, License Key injectée comme metadata secrète `x-token`, smoke live `Subscribe -> Slot + Ping` validé deux fois, sans modification du moteur N1/N2 ni heartbeat provider.
|
||||
- [X] `0.2.11` — Off-chain price transport stable : `ksp-offchain-transport-lib` expose SOL/USD via huit adapters REST `reqwest` sans SDK provider, décimal exact, sémantiques/provenance explicites, registry/availability/rate limits et refresh single/many/all génériques ; Config `std.offchain_transport` construit le service sans dépendance inverse, DexScreener reste lié à une paire explicite sans discovery, aucun consensus/fallback automatique n’est introduit, et le smoke live keyless final passe 7/7 après correction CoinMarketCap V2.
|
||||
- [X] `0.2.12` — SOL Prices Desk + projection prix Wallet stables : HID provider-neutral avec refresh row/selected/all et batch `1..=64`, observations/timestamps exacts sans polling/consensus, puis refresh balance Wallet enrichi d’une moyenne SOL/USD consumer-owned et d’un équivalent USD exact best-effort ; smoke live de composition, workspace complet et bundles Tauri Linux validés.
|
||||
- [ ] `0.2.13` — Introduire la première surface de `ksp-interface-lib`, façade wire KSP publique et passive utilisable par les implémentations officielles et externes ; `pre.001` doit confronter l’architecture actuelle aux contrats utiles de l’ancien `khadhroony-bot3` sans recréer son monolithe `ks-lib`, et séparer explicitement données wire, comportement Program et implémentations.
|
||||
- [ ] `0.2.14` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet ; les traits de reconnaissance/decode/build et autres comportements ne doivent pas être aspirés prématurément dans `ksp-interface-lib`.
|
||||
- [X] `0.2.13` — Interface / wire foundation stable : `ksp-interface-lib` expose `Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` passifs, admission bornée à 255 account metas / 10 240 bytes, erreurs et `Debug` sans payload hostile, façade crate-root et consumer externe canaris ; graphe strict `Interface -> Core`, sans serde/codec générique, `solana-instruction`, réseau ni logging runtime.
|
||||
- [X] `0.2.14` — Program API foundation stable : `ksp-program-api` expose une surface instruction-only ouverte avec `ProgramInstructionRecognition`, `ProgramInstructionDecodeOutcome<Decoded>` et `ProgramInstructionDecoder`; implémentation externe avec Program Pubkey opaque validée, 10 exports crate-root / 3 modules de production verrouillés, graphe strict Core + Interface, sans registry/runtime/serde/codec/Store/Materializer/Execution. Le prompt `0.3.1` prépare Store RAW-only.
|
||||
|
||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||
|
||||
@@ -80,33 +80,53 @@ 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.
|
||||
- [X] `0.3.3` — Vertical slice PostgreSQL `RawTransaction` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : six capabilities transaction/observation/rétention, V001 physique liée à un réseau, acquisition canonical+observation atomique, idempotence/conflit, get/list keyset cursorisé, archive/purge/tombstone/ForceRehydrate, hardening des erreurs et du schéma, concurrence et rollback validés sur PostgreSQL 17.
|
||||
- [X] `0.3.4` — Vertical slice PostgreSQL `RawAccountState` complète sur `ksp-store-lib` + `ksp-store-postgres-lib` : quatre capabilities account ajoutées aux six transaction pour une conformance RAW 10/10, V002 additive de 32 ressources au-dessus de V000/V001 immuables, state+observation atomiques, idempotence/conflit exacts, metadata Yellowstone observation-only, get/list keyset `(slot,pubkey,state_hash)` avec cursor KSPA anti-replay, hardening cross-family et live validé sur PostgreSQL 17 sans rétention destructive account.
|
||||
- [ ] `0.3.5` — Étendre `ksp-interface-lib` uniquement avec les modèles passifs/event-only dont une matrice des surfaces HTTP/WS/Yellowstone/Helius démontre la sémantique réellement partagée et le besoin consumer ; auditer notamment logs, slot/root/slotsUpdates, transaction status et vote, sans dupliquer `RawTransaction`/`RawAccountState`, sans event bus et sans dépendance Interface vers Transport/Store.
|
||||
- [ ] `0.3.6` — Introduire `ksp-job-api` et un premier job de backfill historique concret consommant `ksp-store-lib`, avec policy/batch-size/progression possédés par le job et non par Store.
|
||||
- [ ] `0.3.7` — Introduire une application spécialisée de backfill/inspection RAW.
|
||||
- [ ] 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 est complétée en `0.3.4` avec les quatre capabilities account et la conformance RAW 10/10.
|
||||
- [ ] **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`.
|
||||
- [X] rétention physique `RawTransaction` PostgreSQL — `0.3.3` matérialise `Full -> Archived -> Purged`, tombstone et ForceRehydrate atomiques ; `Compacted` reste explicitement unsupported tant qu’aucune représentation compactée réelle n’existe.
|
||||
- [ ] **TODO** — policy de rétention/compaction : définir les critères d’éligibilité fondés sur les preuves de processing et la maintenance worker/job ; Store applique une transition demandée mais ne décide pas seul qu’un RAW peut être archivé/purgé, et la compaction physique ne sera ajoutée qu’avec un besoin réel.
|
||||
- [X] frontière `ksp-interface-lib` / `ksp-store-api` — ownership documenté et canaris de non-duplication stabilisés en `0.3.1`; les events passifs non persistés restent Interface, les modèles persistants/replayables restent Store API.
|
||||
- [ ] **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 +139,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 +163,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
|
||||
|
||||
|
||||
81
config/examples/std.store.example.json
Normal file
81
config/examples/std.store.example.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"network": "devnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "testnet",
|
||||
"network": "testnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
277
config/schemas/std.store.schema.json
Normal file
277
config/schemas/std.store.schema.json
Normal file
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.store:v2",
|
||||
"title": "KSP standard Store configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 1
|
||||
},
|
||||
"profiles": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 2
|
||||
},
|
||||
"profiles": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||
},
|
||||
"networkId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_.:-]+$"
|
||||
},
|
||||
"duration100To60000": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 60000
|
||||
},
|
||||
"profileV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"network",
|
||||
"backend",
|
||||
"postgres"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/$defs/networkId"
|
||||
},
|
||||
"backend": {
|
||||
"const": "postgres"
|
||||
},
|
||||
"postgres": {
|
||||
"$ref": "#/$defs/postgresV1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"network",
|
||||
"backend",
|
||||
"postgres"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/$defs/networkId"
|
||||
},
|
||||
"backend": {
|
||||
"const": "postgres"
|
||||
},
|
||||
"postgres": {
|
||||
"$ref": "#/$defs/postgresV2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postgresV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection_uri",
|
||||
"pool",
|
||||
"tls",
|
||||
"bootstrap",
|
||||
"shutdown_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"connection_uri": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"pool": {
|
||||
"$ref": "#/$defs/pool"
|
||||
},
|
||||
"tls": {
|
||||
"$ref": "#/$defs/tls"
|
||||
},
|
||||
"bootstrap": {
|
||||
"$ref": "#/$defs/bootstrapV1"
|
||||
},
|
||||
"shutdown_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"postgresV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection_uri",
|
||||
"pool",
|
||||
"tls",
|
||||
"bootstrap",
|
||||
"shutdown_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"connection_uri": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"pool": {
|
||||
"$ref": "#/$defs/pool"
|
||||
},
|
||||
"tls": {
|
||||
"$ref": "#/$defs/tls"
|
||||
},
|
||||
"bootstrap": {
|
||||
"$ref": "#/$defs/bootstrapV2"
|
||||
},
|
||||
"shutdown_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"pool": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"max_connections",
|
||||
"connect_timeout_ms",
|
||||
"wait_timeout_ms",
|
||||
"create_timeout_ms",
|
||||
"recycle_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"max_connections": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 64
|
||||
},
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
},
|
||||
"wait_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
},
|
||||
"create_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
},
|
||||
"recycle_timeout_ms": {
|
||||
"$ref": "#/$defs/duration100To60000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"verify_full"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootstrapV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"auto_migrate",
|
||||
"migration_timeout_ms",
|
||||
"migration_lock_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"auto_migrate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"migration_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000
|
||||
},
|
||||
"migration_lock_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 120000
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootstrapV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_autocreate",
|
||||
"schema_autoupdate",
|
||||
"migration_timeout_ms",
|
||||
"migration_lock_timeout_ms"
|
||||
],
|
||||
"properties": {
|
||||
"schema_autocreate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"schema_autoupdate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"migration_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000
|
||||
},
|
||||
"migration_lock_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
config/std.store.json
Normal file
81
config/std.store.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"network": "devnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "testnet",
|
||||
"network": "testnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -56,11 +56,13 @@
|
||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||
"../../config/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,
|
||||
|
||||
305
crates/ksp-config-lib/src/store.rs
Normal file
305
crates/ksp-config-lib/src/store.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
// file: crates/ksp-config-lib/src/store.rs
|
||||
// version: 3
|
||||
|
||||
/// Effective standard Store configuration mapped to `ksp_store_lib::StoreSettings`.
|
||||
pub struct ResolvedStoreConfig {
|
||||
effective: crate::ResolvedConfigJson,
|
||||
file_id: crate::ConfigFileId,
|
||||
profile_id: String,
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
settings: ksp_store_lib::StoreSettings,
|
||||
source_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl ResolvedStoreConfig {
|
||||
/// Returns the detailed environment-resolved Config view.
|
||||
#[must_use]
|
||||
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||
return &self.effective;
|
||||
}
|
||||
|
||||
/// Returns the logical Config file identifier used by this runtime configuration.
|
||||
#[must_use]
|
||||
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||
return &self.file_id;
|
||||
}
|
||||
|
||||
/// Returns the selected standard Store profile identifier.
|
||||
///
|
||||
/// For `std.store`, the profile identifier is also the stable named Store target identifier.
|
||||
#[must_use]
|
||||
pub fn profile_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the selected named Store target identifier.
|
||||
#[must_use]
|
||||
pub fn target_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the source that selected the standard Store profile.
|
||||
#[must_use]
|
||||
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||
return self.selection_source;
|
||||
}
|
||||
|
||||
/// Borrows the backend-neutral Store settings without exposing the connection URI.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &ksp_store_lib::StoreSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Consumes the resolved Config and returns the Store-owned runtime settings.
|
||||
#[must_use]
|
||||
pub fn into_settings(self) -> ksp_store_lib::StoreSettings {
|
||||
return self.settings;
|
||||
}
|
||||
|
||||
/// Returns the physical source Config document path.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &std::path::Path {
|
||||
return self.source_path.as_path();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedStoreConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("ResolvedStoreConfig")
|
||||
.field("effective", &self.effective)
|
||||
.field("file_id", &self.file_id)
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("settings", &self.settings)
|
||||
.field("source_path", &self.source_path)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads `std.store`, resolves one profile/environment and maps it to backend-neutral Store settings.
|
||||
pub fn load_resolved_store_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_STORE);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return resolve_store_profile(&profile, environment);
|
||||
}
|
||||
|
||||
/// Maps an already resolved `cfg.std.store` profile while preserving its selection provenance.
|
||||
pub fn resolve_store_config_profile(
|
||||
&self,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||
if profile.file_id().as_str() != crate::FILE_ID_STD_STORE {
|
||||
return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Store document"));
|
||||
}
|
||||
let descriptor = self.registry().descriptor(profile.file_id());
|
||||
if let std::result::Result::Err(error) = descriptor {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return resolve_store_profile(profile, environment);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveStoreSource {
|
||||
backend: String,
|
||||
format_version: u32,
|
||||
network: String,
|
||||
postgres: EffectivePostgresSource,
|
||||
profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresSource {
|
||||
bootstrap: EffectivePostgresBootstrapSource,
|
||||
connection_uri: String,
|
||||
pool: EffectivePostgresPoolSource,
|
||||
shutdown_timeout_ms: u64,
|
||||
tls: EffectivePostgresTlsSource,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresPoolSource {
|
||||
connect_timeout_ms: u64,
|
||||
create_timeout_ms: u64,
|
||||
max_connections: u32,
|
||||
recycle_timeout_ms: u64,
|
||||
wait_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresTlsSource {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectivePostgresBootstrapSource {
|
||||
auto_migrate: std::option::Option<bool>,
|
||||
migration_lock_timeout_ms: u64,
|
||||
migration_timeout_ms: u64,
|
||||
schema_autocreate: std::option::Option<bool>,
|
||||
schema_autoupdate: std::option::Option<bool>,
|
||||
}
|
||||
|
||||
fn resolve_store_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedStoreConfig> {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Store Config profile");
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let provenance = validate_connection_uri_provenance(&effective, profile);
|
||||
if let std::result::Result::Err(error) = provenance {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let source = serde_json::from_value::<EffectiveStoreSource>(effective.value().clone());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective Store Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if source.format_version != 1 && source.format_version != 2 {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported"));
|
||||
}
|
||||
if source.profile_id != profile.profile_id() {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Store profile_id does not match the selected profile"));
|
||||
}
|
||||
if source.backend != "postgres" {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Store backend is unsupported").with_context("backend", source.backend));
|
||||
}
|
||||
let tls_mode = match source.postgres.tls.mode.as_str() {
|
||||
"disabled" => ksp_store_lib::PostgresTlsMode::Disabled,
|
||||
"verify_full" => ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store PostgreSQL TLS mode is unsupported")),
|
||||
};
|
||||
let pool = ksp_store_lib::PostgresPoolSettings::new(
|
||||
source.postgres.pool.max_connections,
|
||||
std::time::Duration::from_millis(source.postgres.pool.connect_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.pool.wait_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.pool.create_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.pool.recycle_timeout_ms),
|
||||
);
|
||||
let bootstrap = match source.format_version {
|
||||
1 => {
|
||||
let auto_migrate =
|
||||
match (source.postgres.bootstrap.auto_migrate, source.postgres.bootstrap.schema_autocreate, source.postgres.bootstrap.schema_autoupdate) {
|
||||
(std::option::Option::Some(value), std::option::Option::None, std::option::Option::None) => value,
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store V1 bootstrap policy is invalid")),
|
||||
};
|
||||
ksp_store_lib::PostgresBootstrapSettings::new(
|
||||
auto_migrate,
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms),
|
||||
)
|
||||
},
|
||||
2 => {
|
||||
let (schema_autocreate, schema_autoupdate) =
|
||||
match (source.postgres.bootstrap.auto_migrate, source.postgres.bootstrap.schema_autocreate, source.postgres.bootstrap.schema_autoupdate) {
|
||||
(std::option::Option::None, std::option::Option::Some(autocreate), std::option::Option::Some(autoupdate)) => (autocreate, autoupdate),
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store V2 bootstrap policy is invalid")),
|
||||
};
|
||||
ksp_store_lib::PostgresBootstrapSettings::with_schema_policy(
|
||||
schema_autocreate,
|
||||
schema_autoupdate,
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms),
|
||||
std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms),
|
||||
)
|
||||
},
|
||||
_ => return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported")),
|
||||
};
|
||||
let network = ksp_store_lib::RawNetworkId::new(source.network);
|
||||
let network = match network {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(effective_error(profile, "effective Store network identifier is invalid")),
|
||||
};
|
||||
let postgres = ksp_store_lib::PostgresStoreSettings::new(source.postgres.connection_uri, pool, tls_mode, bootstrap);
|
||||
let settings = ksp_store_lib::StoreSettings::new(
|
||||
network,
|
||||
ksp_store_lib::StoreBackendSettings::Postgres(postgres),
|
||||
std::time::Duration::from_millis(source.postgres.shutdown_timeout_ms),
|
||||
);
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(store_contract_error(profile, &error));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
network = settings.network().as_str(),
|
||||
backend = settings.backend_kind().code(),
|
||||
"mapped standard Store Config to Store settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedStoreConfig {
|
||||
effective,
|
||||
file_id: profile.file_id().clone(),
|
||||
profile_id: profile.profile_id().to_owned(),
|
||||
selection_source: profile.selection_source(),
|
||||
settings,
|
||||
source_path: profile.path().to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_connection_uri_provenance(effective: &crate::ResolvedConfigJson, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<()> {
|
||||
let provenance = match effective.provenance_at("/postgres/connection_uri") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI provenance is unavailable")),
|
||||
};
|
||||
let mut has_secret_environment = false;
|
||||
for item in provenance {
|
||||
let variable_name = match item.variable_name() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !sensitivity.is_secret() {
|
||||
return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI may reference only secret environment variables"));
|
||||
}
|
||||
has_secret_environment = true;
|
||||
}
|
||||
if !has_secret_environment {
|
||||
return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI requires secret environment provenance"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn store_contract_error(profile: &crate::ResolvedConfigProfile, error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||
return effective_error(profile, "effective Store settings fail the Store runtime contract")
|
||||
.with_context("store_error_domain", error.code().domain())
|
||||
.with_context("store_error_code", error.code().code());
|
||||
}
|
||||
|
||||
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||
.with_context("file_id", profile.file_id().as_str())
|
||||
.with_context("profile_id", profile.profile_id())
|
||||
.with_context("reason", reason);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/store.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||
// 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("../..");
|
||||
|
||||
81
crates/ksp-config-lib/unit_tests/fixtures/std.store.json
Normal file
81
crates/ksp-config-lib/unit_tests/fixtures/std.store.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"network": "devnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_DEVNET_POSTGRES_URI:-postgresql://localhost/ksp_devnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet",
|
||||
"network": "mainnet-beta",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_MAINNET_POSTGRES_URI:-postgresql://localhost/ksp_mainnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "testnet",
|
||||
"network": "testnet",
|
||||
"backend": "postgres",
|
||||
"postgres": {
|
||||
"connection_uri": "${KSP_SECRET_STORE_TESTNET_POSTGRES_URI:-postgresql://localhost/ksp_testnet}",
|
||||
"pool": {
|
||||
"max_connections": 8,
|
||||
"connect_timeout_ms": 10000,
|
||||
"wait_timeout_ms": 5000,
|
||||
"create_timeout_ms": 10000,
|
||||
"recycle_timeout_ms": 5000
|
||||
},
|
||||
"tls": {
|
||||
"mode": "verify_full"
|
||||
},
|
||||
"bootstrap": {
|
||||
"schema_autocreate": true,
|
||||
"schema_autoupdate": true,
|
||||
"migration_timeout_ms": 30000,
|
||||
"migration_lock_timeout_ms": 10000
|
||||
},
|
||||
"shutdown_timeout_ms": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||
// 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();
|
||||
|
||||
285
crates/ksp-config-lib/unit_tests/store.rs
Normal file
285
crates/ksp-config-lib/unit_tests/store.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
// file: crates/ksp-config-lib/unit_tests/store.rs
|
||||
// version: 3
|
||||
|
||||
#[test]
|
||||
fn committed_store_profile_maps_exact_runtime_settings_and_secret_fallback() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "committed Store profile should map without opening PostgreSQL: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_STORE);
|
||||
assert_eq!(resolved.profile_id(), "devnet");
|
||||
assert_eq!(resolved.target_id(), "devnet");
|
||||
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||
assert_eq!(resolved.settings().backend_kind(), ksp_store_lib::StoreBackendKind::Postgres);
|
||||
assert_eq!(resolved.settings().network().as_str(), "devnet");
|
||||
assert_eq!(resolved.settings().shutdown_timeout(), std::time::Duration::from_millis(5_000));
|
||||
let postgres = match resolved.settings().backend() {
|
||||
ksp_store_lib::StoreBackendSettings::Postgres(postgres) => std::option::Option::Some(postgres),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
assert!(postgres.is_some(), "pre.004 fixture should map to the PostgreSQL Store backend");
|
||||
if let std::option::Option::Some(postgres) = postgres {
|
||||
assert_eq!(postgres.pool().max_connections(), 8);
|
||||
assert_eq!(postgres.pool().connect_timeout(), std::time::Duration::from_millis(10_000));
|
||||
assert_eq!(postgres.pool().wait_timeout(), std::time::Duration::from_millis(5_000));
|
||||
assert_eq!(postgres.pool().create_timeout(), std::time::Duration::from_millis(10_000));
|
||||
assert_eq!(postgres.pool().recycle_timeout(), std::time::Duration::from_millis(5_000));
|
||||
assert_eq!(postgres.tls_mode(), ksp_store_lib::PostgresTlsMode::VerifyFull);
|
||||
assert!(postgres.bootstrap().auto_migrate());
|
||||
assert!(postgres.bootstrap().schema_autocreate());
|
||||
assert!(postgres.bootstrap().schema_autoupdate());
|
||||
assert_eq!(postgres.bootstrap().migration_timeout(), std::time::Duration::from_millis(30_000));
|
||||
assert_eq!(postgres.bootstrap().migration_lock_timeout(), std::time::Duration::from_millis(10_000));
|
||||
}
|
||||
assert!(resolved.effective().sensitivity().is_secret());
|
||||
let safe = resolved.effective().safe_value().to_string();
|
||||
assert!(!safe.contains("postgresql://localhost/ksp_devnet"));
|
||||
assert!(safe.contains(crate::REDACTED_CONFIG_VALUE));
|
||||
let debug = format!("{resolved:?}");
|
||||
assert!(!debug.contains("postgresql://localhost/ksp_devnet"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_store_uri_wins_and_remains_redacted_in_safe_views() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let canary = "postgresql://secret-user:secret-pass@db.example/ksp_devnet";
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_STORE_DEVNET_POSTGRES_URI".to_owned(), canary.to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "secret process Store URI should map: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.effective().value().pointer("/postgres/connection_uri").and_then(serde_json::Value::as_str), std::option::Option::Some(canary));
|
||||
assert!(!resolved.effective().safe_value().to_string().contains(canary));
|
||||
assert!(!format!("{resolved:?}").contains(canary));
|
||||
let provenance = resolved.effective().provenance_at("/postgres/connection_uri");
|
||||
assert!(provenance.is_some());
|
||||
if let std::option::Option::Some(provenance) = provenance {
|
||||
assert!(provenance.iter().any(|item| return item.environment_source() == std::option::Option::Some(crate::ConfigEnvironmentSource::Process)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_or_nonsecret_store_uri_is_rejected_by_effective_adapter() {
|
||||
for value in ["postgresql://literal.invalid/ksp", "${KSP_PUBLIC_STORE_POSTGRES_URI:-postgresql://public.invalid/ksp}"] {
|
||||
let fixture = tempfile::tempdir();
|
||||
assert!(fixture.is_ok());
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = committed_document_value();
|
||||
let mut source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||
if let std::option::Option::Some(profiles) = profiles
|
||||
&& let std::option::Option::Some(profile) = profiles.first_mut()
|
||||
{
|
||||
profile["postgres"]["connection_uri"] = serde_json::Value::String(value.to_owned());
|
||||
}
|
||||
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||
assert!(engine.is_ok());
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_err(), "Store URI without secret provenance must be rejected");
|
||||
if let std::result::Result::Err(error) = resolved {
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||
assert!(!format!("{error:?}").contains("literal.invalid"));
|
||||
assert!(!format!("{error:?}").contains("public.invalid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_store_targets_select_one_network_and_database_without_runtime_multiplexing() {
|
||||
let engine = committed_engine();
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||
process.insert("KSP_SECRET_STORE_DEVNET_POSTGRES_URI".to_owned(), "postgresql://devnet.invalid/ksp_devnet".to_owned());
|
||||
process.insert("KSP_SECRET_STORE_MAINNET_POSTGRES_URI".to_owned(), "postgresql://mainnet.invalid/ksp_mainnet".to_owned());
|
||||
process.insert("KSP_SECRET_STORE_TESTNET_POSTGRES_URI".to_owned(), "postgresql://testnet.invalid/ksp_testnet".to_owned());
|
||||
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||
for (target_id, network, expected_uri) in [
|
||||
("devnet", "devnet", "postgresql://devnet.invalid/ksp_devnet"),
|
||||
("mainnet", "mainnet-beta", "postgresql://mainnet.invalid/ksp_mainnet"),
|
||||
("testnet", "testnet", "postgresql://testnet.invalid/ksp_testnet"),
|
||||
] {
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::Some(target_id), &environment);
|
||||
assert!(resolved.is_ok(), "named Store target should resolve independently: {target_id}: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.target_id(), target_id);
|
||||
assert_eq!(resolved.profile_id(), target_id);
|
||||
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||
assert_eq!(resolved.settings().network().as_str(), network);
|
||||
assert_eq!(
|
||||
resolved.effective().value().pointer("/postgres/connection_uri").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some(expected_uri),
|
||||
);
|
||||
assert!(!resolved.effective().safe_value().to_string().contains(expected_uri));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_fix_001_v1_auto_migrate_remains_backward_readable_and_maps_both_schema_policies() {
|
||||
for auto_migrate in [false, true] {
|
||||
let fixture = tempfile::tempdir();
|
||||
assert!(fixture.is_ok());
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = committed_document_value();
|
||||
let mut source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
source["format_version"] = serde_json::Value::from(1);
|
||||
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||
if let std::option::Option::Some(profiles) = profiles {
|
||||
for profile in profiles {
|
||||
let bootstrap = profile.pointer_mut("/postgres/bootstrap").and_then(serde_json::Value::as_object_mut);
|
||||
if let std::option::Option::Some(bootstrap) = bootstrap {
|
||||
bootstrap.remove("schema_autocreate");
|
||||
bootstrap.remove("schema_autoupdate");
|
||||
bootstrap.insert("auto_migrate".to_owned(), serde_json::Value::Bool(auto_migrate));
|
||||
}
|
||||
}
|
||||
}
|
||||
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||
assert!(engine.is_ok());
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "Store Config V1 compatibility mapping failed: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved
|
||||
&& let ksp_store_lib::StoreBackendSettings::Postgres(postgres) = resolved.settings().backend()
|
||||
{
|
||||
assert_eq!(postgres.bootstrap().schema_autocreate(), auto_migrate);
|
||||
assert_eq!(postgres.bootstrap().schema_autoupdate(), auto_migrate);
|
||||
assert_eq!(postgres.bootstrap().auto_migrate(), auto_migrate);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_fix_001_v2_schema_creation_and_update_policies_map_independently() {
|
||||
let fixture = tempfile::tempdir();
|
||||
assert!(fixture.is_ok());
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = committed_document_value();
|
||||
let mut source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut);
|
||||
if let std::option::Option::Some(profiles) = profiles
|
||||
&& let std::option::Option::Some(profile) = profiles.first_mut()
|
||||
{
|
||||
profile["postgres"]["bootstrap"]["schema_autocreate"] = serde_json::Value::Bool(false);
|
||||
profile["postgres"]["bootstrap"]["schema_autoupdate"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
let engine = fixture_engine_with_document(fixture.path(), &source);
|
||||
assert!(engine.is_ok());
|
||||
let engine = match engine {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||
let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment);
|
||||
assert!(resolved.is_ok(), "Store Config V2 split schema policy mapping failed: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved
|
||||
&& let ksp_store_lib::StoreBackendSettings::Postgres(postgres) = resolved.settings().backend()
|
||||
{
|
||||
assert!(!postgres.bootstrap().schema_autocreate());
|
||||
assert!(postgres.bootstrap().schema_autoupdate());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
let workspace = workspace_root();
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = crate::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn fixture_engine_with_document(root: &std::path::Path, document: &serde_json::Value) -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||
let config_root = root.join("config");
|
||||
if let std::result::Result::Err(error) = std::fs::create_dir_all(config_root.as_path()) {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config root cannot be created").with_source(error),
|
||||
);
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(document);
|
||||
let bytes = match bytes {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "test Store Config cannot be encoded").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let path = config_root.join(crate::DEFAULT_STD_STORE_FILENAME);
|
||||
if let std::result::Result::Err(error) = std::fs::write(path.as_path(), bytes) {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Store Config cannot be written").with_source(error),
|
||||
);
|
||||
}
|
||||
let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root, workspace_root().join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = crate::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
}
|
||||
|
||||
fn committed_document_value() -> std::result::Result<serde_json::Value, serde_json::Error> {
|
||||
return serde_json::from_str(include_str!("../../../config/std.store.json"));
|
||||
}
|
||||
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
}
|
||||
14
crates/ksp-interface-lib/Cargo.toml
Normal file
14
crates/ksp-interface-lib/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
# file: crates/ksp-interface-lib/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "ksp-interface-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
131
crates/ksp-interface-lib/README.md
Normal file
131
crates/ksp-interface-lib/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
<!-- file: crates/ksp-interface-lib/README.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# ksp-interface-lib
|
||||
|
||||
`ksp-interface-lib` est la façade wire officielle KSP destinée aux contrats passifs partagés par les implémentations Program Solana officielles ou externes. La crate expose uniquement des structures de représentation/admission ; elle ne possède ni transport, ni exécution, ni persistence, ni comportement métier Program.
|
||||
|
||||
## Ownership
|
||||
|
||||
La crate réutilise les primitives fondamentales déjà possédées par `ksp-core-lib` :
|
||||
|
||||
```text
|
||||
Pubkey
|
||||
Error / ErrorCode / Result
|
||||
Program IDs fondamentaux
|
||||
```
|
||||
|
||||
`Pubkey` est réexporté depuis le crate-root Interface afin qu'un consumer wire n'introduise aucun wrapper d'identité parallèle. Les Program IDs restent possédés et répertoriés par Core.
|
||||
|
||||
La dependency direction candidate `0.2.13` reste strictement :
|
||||
|
||||
```text
|
||||
ksp-interface-lib
|
||||
└── ksp-core-lib
|
||||
└── solana-pubkey
|
||||
```
|
||||
|
||||
## Surface publique `0.2.13`
|
||||
|
||||
La façade crate-root expose exactement :
|
||||
|
||||
```text
|
||||
Pubkey
|
||||
ProgramAccountMeta
|
||||
MAX_PROGRAM_INSTRUCTION_ACCOUNTS
|
||||
ProgramInstruction
|
||||
MAX_PROGRAM_INSTRUCTION_DATA_LEN
|
||||
ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED
|
||||
```
|
||||
|
||||
Aucun module interne n'est public.
|
||||
|
||||
### `ProgramAccountMeta`
|
||||
|
||||
`ProgramAccountMeta` représente un compte ordonné d'une instruction avec :
|
||||
|
||||
```text
|
||||
pubkey
|
||||
is_signer
|
||||
is_writable
|
||||
```
|
||||
|
||||
Les champs restent privés. Les constructeurs publics sont `readonly(pubkey, is_signer)` et `writable(pubkey, is_signer)`, complétés par les accessors `pubkey()`, `is_signer()` et `is_writable()`.
|
||||
|
||||
La structure n'applique aucune sémantique spécifique à un programme et n'exige pas que la `Pubkey` appartienne au registry des Program IDs Core.
|
||||
|
||||
### `ProgramInstruction`
|
||||
|
||||
`ProgramInstruction` représente un contrat passif borné :
|
||||
|
||||
```text
|
||||
program_id
|
||||
accounts: Vec<ProgramAccountMeta>
|
||||
data: Vec<u8>
|
||||
```
|
||||
|
||||
`ProgramInstruction::try_new` consomme directement les deux `Vec`, conserve l'ordre et les doublons des accounts et préserve les octets opaques de `data` sans décodage.
|
||||
|
||||
Les cas vides sont valides et une `program_id` inconnue du registry KSP reste admissible.
|
||||
|
||||
## Bornes d'admission
|
||||
|
||||
Interface applique deux limites locales :
|
||||
|
||||
| Limite | Valeur |
|
||||
|------------------------------------|----------|
|
||||
| `MAX_PROGRAM_INSTRUCTION_ACCOUNTS` | `255` |
|
||||
| `MAX_PROGRAM_INSTRUCTION_DATA_LEN` | `10_240` |
|
||||
|
||||
Ces valeurs sont des **bornes d'admission Interface**. Elles ne constituent pas une garantie qu'une instruction donnée tient dans toutes les contraintes d'une transaction Solana top-level. La limite CPI de comptes uniques n'est notamment pas transformée en règle artificielle sur la liste d'account metas.
|
||||
|
||||
Un dépassement utilise le code commun :
|
||||
|
||||
```text
|
||||
interface.program_instruction_limit_exceeded
|
||||
```
|
||||
|
||||
Le contexte d'erreur est limité aux métadonnées sûres `field`, `actual_len` et `maximum_len`. Aucun payload arbitraire ni account meta hostile n'est recopié dans l'erreur.
|
||||
|
||||
Le `Debug` de `ProgramInstruction` est volontairement borné : il affiche `program_id`, `account_count` et `data_len`, jamais les accounts complets ni les octets du payload.
|
||||
|
||||
## Codecs et runtime
|
||||
|
||||
La foundation `0.2.13` n'ajoute aucun codec par réflexe :
|
||||
|
||||
```text
|
||||
serde / serde_json absents
|
||||
borsh absent
|
||||
wincode absent
|
||||
bincode absent
|
||||
solana-instruction absent
|
||||
```
|
||||
|
||||
Des codecs/layouts/discriminants spécifiques pourront être ajoutés ultérieurement uniquement lorsqu'un vertical Program réel en démontre le besoin et que leur ownership wire appartient bien à Interface.
|
||||
|
||||
La crate ne produit aucun événement runtime. Elle ne dépend donc pas de `ksp-logging-lib` et ne possède ni `constants.rs` ni `TRACING_TARGET`. Si un futur comportement Interface exige réellement du logging, le runtime devra passer par la façade Logging KSP plutôt que par une dépendance directe à Tracing.
|
||||
|
||||
## Frontières
|
||||
|
||||
`ksp-interface-lib` ne possède pas :
|
||||
|
||||
```text
|
||||
RPC / WebSocket / gRPC
|
||||
provider DTOs Transport
|
||||
wallet / signature
|
||||
Config / environnement
|
||||
persistence / Store
|
||||
Program decoding / recognition / proofs
|
||||
execution policy / signers
|
||||
transaction replay / CPI path / runtime logs
|
||||
lifecycle réseau
|
||||
```
|
||||
|
||||
La foundation Program API est reportée à `0.2.14`. Les wires génériques d'acquisition/CORE restent reportés à `0.3.2+`.
|
||||
|
||||
## Références
|
||||
|
||||
- [Usage public](USAGE.md)
|
||||
- [Plan `0.2.13`](../../docs/plans/020-V0_2_13_INTERFACE_PLAN.md)
|
||||
- [Validation `0.2.13`](../../docs/validation/016-V0_2_13_INTERFACE.md)
|
||||
- [Architecture Wire + Program](../../docs/architecture/006-WIRE_AND_PROGRAM.md)
|
||||
122
crates/ksp-interface-lib/USAGE.md
Normal file
122
crates/ksp-interface-lib/USAGE.md
Normal file
@@ -0,0 +1,122 @@
|
||||
<!-- file: crates/ksp-interface-lib/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Usage de ksp-interface-lib
|
||||
|
||||
Cette page décrit la façade publique matérialisée par `0.2.13`. Les modules internes ne font pas partie du contrat consommable : utiliser uniquement les exports du crate-root.
|
||||
|
||||
## Construire des account metas
|
||||
|
||||
```rust
|
||||
let readonly_account = ksp_interface_lib::Pubkey::new_from_array([1_u8; 32]);
|
||||
let writable_account = ksp_interface_lib::Pubkey::new_from_array([2_u8; 32]);
|
||||
|
||||
let readonly = ksp_interface_lib::ProgramAccountMeta::readonly(readonly_account, true);
|
||||
let writable = ksp_interface_lib::ProgramAccountMeta::writable(writable_account, false);
|
||||
|
||||
assert_eq!(readonly.pubkey(), &readonly_account);
|
||||
assert!(readonly.is_signer());
|
||||
assert!(!readonly.is_writable());
|
||||
|
||||
assert_eq!(writable.pubkey(), &writable_account);
|
||||
assert!(!writable.is_signer());
|
||||
assert!(writable.is_writable());
|
||||
```
|
||||
|
||||
`readonly`/`writable` décrivent uniquement les flags wire de l'account meta. Interface ne valide pas l'identité du compte contre un registry Program.
|
||||
|
||||
## Construire une instruction passive
|
||||
|
||||
```rust
|
||||
let program_id = ksp_interface_lib::Pubkey::new_from_array([3_u8; 32]);
|
||||
let account_id = ksp_interface_lib::Pubkey::new_from_array([4_u8; 32]);
|
||||
|
||||
let account = ksp_interface_lib::ProgramAccountMeta::writable(account_id, true);
|
||||
let result = ksp_interface_lib::ProgramInstruction::try_new(
|
||||
program_id,
|
||||
std::vec![account, account],
|
||||
std::vec![7_u8, 8, 9],
|
||||
);
|
||||
|
||||
match result {
|
||||
std::result::Result::Ok(instruction) => {
|
||||
assert_eq!(instruction.program_id(), &program_id);
|
||||
assert_eq!(instruction.accounts(), &[account, account]);
|
||||
assert_eq!(instruction.data(), &[7_u8, 8, 9]);
|
||||
}
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("{error}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
L'ordre et les doublons des accounts sont conservés. Les octets `data` restent opaques : `ProgramInstruction` ne les sérialise, désérialise ni interprète.
|
||||
|
||||
Les `Vec` fournis à `try_new` sont consommés par la structure après validation des bornes ; aucun clone ou reformatage interne n'est requis par le contrat actuel.
|
||||
|
||||
## Bornes
|
||||
|
||||
Les limites publiques sont :
|
||||
|
||||
```rust
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||
```
|
||||
|
||||
`255` account metas et `10_240` bytes de data sont admis. `256` account metas ou `10_241` bytes sont refusés par `try_new` avant création d'un `ProgramInstruction` valide.
|
||||
|
||||
Ces limites sont des bornes locales Interface et ne promettent pas qu'une instruction admise respecte à elle seule toutes les contraintes de taille/account-set d'une transaction Solana complète.
|
||||
|
||||
## Observer une erreur de limite
|
||||
|
||||
Le code public est :
|
||||
|
||||
```rust
|
||||
assert_eq!(
|
||||
ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.domain(),
|
||||
"interface",
|
||||
);
|
||||
assert_eq!(
|
||||
ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.code(),
|
||||
"program_instruction_limit_exceeded",
|
||||
);
|
||||
```
|
||||
|
||||
Une erreur de dépassement expose uniquement un contexte structurel sûr :
|
||||
|
||||
```text
|
||||
field
|
||||
actual_len
|
||||
maximum_len
|
||||
```
|
||||
|
||||
Le contenu du payload et les account metas arbitraires ne sont pas projetés dans le diagnostic.
|
||||
|
||||
## Debug borné
|
||||
|
||||
Le `Debug` de `ProgramInstruction` contient uniquement :
|
||||
|
||||
```text
|
||||
program_id
|
||||
account_count
|
||||
data_len
|
||||
```
|
||||
|
||||
Il ne faut donc pas attendre de ce rendu une sérialisation wire ou un dump du payload.
|
||||
|
||||
## Dépendances à ne pas ajouter côté consumer
|
||||
|
||||
Un consumer de la façade Interface n'a pas besoin d'ajouter un SDK Program Solana uniquement pour reconstruire `ProgramInstruction`. La crate utilise le `Pubkey` canonique partagé avec Core et conserve son propre contrat passif.
|
||||
|
||||
La foundation ne fournit volontairement pas :
|
||||
|
||||
```text
|
||||
serde générique
|
||||
Borsh / Wincode générique
|
||||
solana-instruction interop automatique
|
||||
transport réseau
|
||||
Program decoder/preparer
|
||||
signing/execution
|
||||
```
|
||||
|
||||
Ces surfaces doivent être introduites dans leur owner respectif lorsqu'un cas réel le justifie, pas comme dépendances implicites d'un consumer Interface.
|
||||
6
crates/ksp-interface-lib/src/error.rs
Normal file
6
crates/ksp-interface-lib/src/error.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// file: crates/ksp-interface-lib/src/error.rs
|
||||
// version: 1
|
||||
|
||||
/// Error code used when a bounded passive Program instruction contract exceeds one of its Interface-owned admission limits.
|
||||
pub const ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("interface", "program_instruction_limit_exceeded");
|
||||
30
crates/ksp-interface-lib/src/lib.rs
Normal file
30
crates/ksp-interface-lib/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
// file: crates/ksp-interface-lib/src/lib.rs
|
||||
// version: 3
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Passive wire contracts shared by KSP Program implementations.
|
||||
//!
|
||||
//! The foundation reuses the canonical Solana [`Pubkey`] owned by
|
||||
//! `ksp-core-lib` and exposes only bounded, passive Program-facing structures.
|
||||
//! Runtime, transport, persistence and Program behavior remain outside this
|
||||
//! crate.
|
||||
|
||||
mod error;
|
||||
mod program_account_meta;
|
||||
mod program_instruction;
|
||||
|
||||
/// Error code used when an Interface-owned Program instruction admission limit is exceeded.
|
||||
pub use self::error::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED;
|
||||
/// Maximum number of account metas admitted by one passive Program instruction contract.
|
||||
pub use self::program_account_meta::MAX_PROGRAM_INSTRUCTION_ACCOUNTS;
|
||||
/// Passive account metadata attached to one Program instruction.
|
||||
pub use self::program_account_meta::ProgramAccountMeta;
|
||||
/// Maximum opaque data payload admitted by one passive Program instruction contract.
|
||||
pub use self::program_instruction::MAX_PROGRAM_INSTRUCTION_DATA_LEN;
|
||||
/// Passive, bounded Program instruction wire contract.
|
||||
pub use self::program_instruction::ProgramInstruction;
|
||||
/// Canonical Solana account address primitive owned by `ksp-core-lib`.
|
||||
pub use ksp_core_lib::Pubkey;
|
||||
52
crates/ksp-interface-lib/src/program_account_meta.rs
Normal file
52
crates/ksp-interface-lib/src/program_account_meta.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
// file: crates/ksp-interface-lib/src/program_account_meta.rs
|
||||
// version: 1
|
||||
|
||||
/// Maximum number of account metas admitted by one passive Program instruction contract.
|
||||
pub const MAX_PROGRAM_INSTRUCTION_ACCOUNTS: usize = 255;
|
||||
|
||||
/// Passive account metadata attached to one Program instruction.
|
||||
///
|
||||
/// Account metas preserve the caller-provided Solana account identity and the
|
||||
/// signer/writable flags without applying Program-specific semantics.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ProgramAccountMeta {
|
||||
pubkey: crate::Pubkey,
|
||||
is_signer: bool,
|
||||
is_writable: bool,
|
||||
}
|
||||
|
||||
impl ProgramAccountMeta {
|
||||
/// Creates one read-only account meta.
|
||||
#[must_use]
|
||||
pub const fn readonly(pubkey: crate::Pubkey, is_signer: bool) -> Self {
|
||||
return Self { pubkey, is_signer, is_writable: false };
|
||||
}
|
||||
|
||||
/// Creates one writable account meta.
|
||||
#[must_use]
|
||||
pub const fn writable(pubkey: crate::Pubkey, is_signer: bool) -> Self {
|
||||
return Self { pubkey, is_signer, is_writable: true };
|
||||
}
|
||||
|
||||
/// Returns whether this account must sign the containing instruction.
|
||||
#[must_use]
|
||||
pub const fn is_signer(&self) -> bool {
|
||||
return self.is_signer;
|
||||
}
|
||||
|
||||
/// Returns whether this account may be written by the containing instruction.
|
||||
#[must_use]
|
||||
pub const fn is_writable(&self) -> bool {
|
||||
return self.is_writable;
|
||||
}
|
||||
|
||||
/// Returns the canonical account address.
|
||||
#[must_use]
|
||||
pub const fn pubkey(&self) -> &crate::Pubkey {
|
||||
return &self.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/program_account_meta.rs"]
|
||||
mod tests;
|
||||
81
crates/ksp-interface-lib/src/program_instruction.rs
Normal file
81
crates/ksp-interface-lib/src/program_instruction.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
// file: crates/ksp-interface-lib/src/program_instruction.rs
|
||||
// version: 1
|
||||
|
||||
/// Maximum opaque data payload admitted by one passive Program instruction contract.
|
||||
pub const MAX_PROGRAM_INSTRUCTION_DATA_LEN: usize = 10 * 1024;
|
||||
|
||||
/// Passive, bounded Program instruction wire contract.
|
||||
///
|
||||
/// The instruction preserves the caller-provided Program identity, ordered
|
||||
/// account metas and opaque data bytes without applying Program-specific
|
||||
/// semantics. Construction enforces only the Interface-owned admission bounds.
|
||||
pub struct ProgramInstruction {
|
||||
program_id: crate::Pubkey,
|
||||
accounts: std::vec::Vec<crate::ProgramAccountMeta>,
|
||||
data: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl ProgramInstruction {
|
||||
/// Creates one passive Program instruction after enforcing Interface admission bounds.
|
||||
///
|
||||
/// The provided vectors are consumed directly. Their account order and duplicates
|
||||
/// are preserved exactly when construction succeeds.
|
||||
pub fn try_new(program_id: crate::Pubkey, accounts: std::vec::Vec<crate::ProgramAccountMeta>, data: std::vec::Vec<u8>) -> ksp_core_lib::Result<Self> {
|
||||
if accounts.len() > crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED,
|
||||
"Program instruction account count exceeds the Interface admission limit",
|
||||
)
|
||||
.with_context("field", "accounts")
|
||||
.with_context("actual_len", accounts.len().to_string())
|
||||
.with_context("maximum_len", crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS.to_string()),
|
||||
);
|
||||
}
|
||||
if data.len() > crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED,
|
||||
"Program instruction data length exceeds the Interface admission limit",
|
||||
)
|
||||
.with_context("field", "data")
|
||||
.with_context("actual_len", data.len().to_string())
|
||||
.with_context("maximum_len", crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { program_id, accounts, data });
|
||||
}
|
||||
|
||||
/// Returns the ordered account metas exactly as admitted at construction.
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> &[crate::ProgramAccountMeta] {
|
||||
return self.accounts.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the opaque Program instruction data bytes.
|
||||
#[must_use]
|
||||
pub fn data(&self) -> &[u8] {
|
||||
return self.data.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the Program identity.
|
||||
#[must_use]
|
||||
pub const fn program_id(&self) -> &crate::Pubkey {
|
||||
return &self.program_id;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProgramInstruction {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("ProgramInstruction")
|
||||
.field("program_id", &self.program_id)
|
||||
.field("account_count", &self.accounts.len())
|
||||
.field("data_len", &self.data.len())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/program_instruction.rs"]
|
||||
mod tests;
|
||||
131
crates/ksp-interface-lib/tests/dependency_boundary.rs
Normal file
131
crates/ksp-interface-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
// file: crates/ksp-interface-lib/tests/dependency_boundary.rs
|
||||
// version: 5
|
||||
|
||||
//! Dependency and passive-surface canaries for the Interface foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_manifest_has_exact_core_only_runtime_dependency() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||
assert!(dependencies_tail.is_some(), "Interface dependencies section must exist");
|
||||
let dependencies_tail = match dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib"]);
|
||||
for forbidden in [
|
||||
"ksp-config-lib",
|
||||
"ksp-logging-lib",
|
||||
"ksp-offchain-transport-lib",
|
||||
"ksp-onchain-transport-lib",
|
||||
"ksp-program-api",
|
||||
"ksp-program-lib",
|
||||
"ksp-store-api",
|
||||
"ksp-store-lib",
|
||||
"ksp-wallet-lib",
|
||||
"borsh",
|
||||
"bincode",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solana-instruction",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"wincode",
|
||||
] {
|
||||
assert!(!dependencies.contains(forbidden), "forbidden Interface dependency detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_surface_remains_passive_without_codecs_or_runtime_logging() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(crate_root.contains("ProgramAccountMeta"));
|
||||
assert!(crate_root.contains("MAX_PROGRAM_INSTRUCTION_ACCOUNTS"));
|
||||
assert!(crate_root.contains("ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED"));
|
||||
assert!(crate_root.contains("ProgramInstruction"));
|
||||
assert!(crate_root.contains("MAX_PROGRAM_INSTRUCTION_DATA_LEN"));
|
||||
assert!(!crate_root.contains("TRACING_TARGET"));
|
||||
assert!(!crate_root.contains("ksp_logging_lib"));
|
||||
assert!(!std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs").exists());
|
||||
let instruction_source = include_str!("../src/program_instruction.rs");
|
||||
for forbidden in ["serde", "borsh", "bincode", "wincode", "solana_instruction", "ksp_logging_lib", "TRACING_TARGET"] {
|
||||
assert!(!instruction_source.contains(forbidden), "forbidden Interface surface detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in section.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_all_production_sources_preserve_the_dependency_firewall() {
|
||||
let production_sources = [
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/program_account_meta.rs"),
|
||||
include_str!("../src/program_instruction.rs"),
|
||||
];
|
||||
for source in production_sources {
|
||||
for forbidden in [
|
||||
"borsh::",
|
||||
"bincode::",
|
||||
"ksp_config_lib::",
|
||||
"ksp_logging_lib::",
|
||||
"ksp_offchain_transport_lib::",
|
||||
"ksp_onchain_transport_lib::",
|
||||
"ksp_program_api::",
|
||||
"ksp_program_lib::",
|
||||
"ksp_store_api::",
|
||||
"ksp_store_lib::",
|
||||
"ksp_wallet_lib::",
|
||||
"reqwest::",
|
||||
"serde::",
|
||||
"serde_json::",
|
||||
"solana_instruction::",
|
||||
"tauri::",
|
||||
"tokio::",
|
||||
"tonic::",
|
||||
concat!("tracing", "::"),
|
||||
"wincode::",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden production dependency path detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_instruction_source_has_no_narrowing_cast_or_hidden_codec_entry_point() {
|
||||
let instruction_source = include_str!("../src/program_instruction.rs");
|
||||
for forbidden in [" as u8", " as u16", " as u32", " as u64", "serialize", "deserialize", "encode", "decode"] {
|
||||
assert!(!instruction_source.contains(forbidden), "forbidden instruction implementation pattern detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
27
crates/ksp-interface-lib/tests/external_consumer.rs
Normal file
27
crates/ksp-interface-lib/tests/external_consumer.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
// file: crates/ksp-interface-lib/tests/external_consumer.rs
|
||||
// version: 1
|
||||
|
||||
//! Downstream-style consumer canary for the public Interface facade.
|
||||
|
||||
fn consume_instruction(instruction: ksp_interface_lib::ProgramInstruction) -> (ksp_interface_lib::Pubkey, usize, usize) {
|
||||
return (*instruction.program_id(), instruction.accounts().len(), instruction.data().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_external_consumer_uses_only_the_crate_root_facade() {
|
||||
let program_id = ksp_interface_lib::Pubkey::new_from_array([0xA1_u8; 32]);
|
||||
let account_id = ksp_interface_lib::Pubkey::new_from_array([0xA2_u8; 32]);
|
||||
let readonly = ksp_interface_lib::ProgramAccountMeta::readonly(account_id, true);
|
||||
let writable = ksp_interface_lib::ProgramAccountMeta::writable(account_id, false);
|
||||
let instruction = ksp_interface_lib::ProgramInstruction::try_new(program_id, std::vec![readonly, writable, readonly], std::vec![7_u8, 8, 9]);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(consume_instruction(instruction), (program_id, 3, 3));
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||
assert_eq!(ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.domain(), "interface");
|
||||
return;
|
||||
}
|
||||
79
crates/ksp-interface-lib/tests/public_api.rs
Normal file
79
crates/ksp-interface-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
// file: crates/ksp-interface-lib/tests/public_api.rs
|
||||
// version: 4
|
||||
|
||||
//! Integration canaries for the public `ksp-interface-lib` foundation.
|
||||
|
||||
fn consume_pubkey(pubkey: ksp_interface_lib::Pubkey) -> [u8; 32] {
|
||||
return pubkey.to_bytes();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_002_pubkey_contract_is_available_from_crate_root() {
|
||||
let pubkey = ksp_interface_lib::Pubkey::default();
|
||||
assert_eq!(consume_pubkey(pubkey), [0_u8; 32]);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_account_meta_and_limit_contracts_are_available_from_crate_root() {
|
||||
let pubkey = ksp_interface_lib::Pubkey::new_from_array([3_u8; 32]);
|
||||
let readonly = ksp_interface_lib::ProgramAccountMeta::readonly(pubkey, true);
|
||||
let writable = ksp_interface_lib::ProgramAccountMeta::writable(pubkey, false);
|
||||
assert_eq!(readonly.pubkey(), &pubkey);
|
||||
assert!(readonly.is_signer());
|
||||
assert!(!readonly.is_writable());
|
||||
assert_eq!(writable.pubkey(), &pubkey);
|
||||
assert!(!writable.is_signer());
|
||||
assert!(writable.is_writable());
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_interface_error_code_is_stable_and_core_owned() {
|
||||
let code: ksp_core_lib::ErrorCode = ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED;
|
||||
assert_eq!(code.domain(), "interface");
|
||||
assert_eq!(code.code(), "program_instruction_limit_exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_004_program_instruction_contract_is_available_from_crate_root() {
|
||||
let program_id = ksp_interface_lib::Pubkey::new_from_array([4_u8; 32]);
|
||||
let account = ksp_interface_lib::ProgramAccountMeta::readonly(ksp_interface_lib::Pubkey::new_from_array([5_u8; 32]), true);
|
||||
let instruction = ksp_interface_lib::ProgramInstruction::try_new(program_id, std::vec![account], std::vec![1_u8, 2, 3]);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(instruction.program_id(), &program_id);
|
||||
assert_eq!(instruction.accounts(), &[account]);
|
||||
assert_eq!(instruction.data(), &[1_u8, 2, 3]);
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_005_limit_error_is_observable_without_private_module_access() {
|
||||
let program_id = ksp_interface_lib::Pubkey::new_from_array([0xC1_u8; 32]);
|
||||
let rejected = ksp_interface_lib::ProgramInstruction::try_new(
|
||||
program_id,
|
||||
std::vec::Vec::new(),
|
||||
std::vec![0xC2_u8; ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN + 1],
|
||||
);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED);
|
||||
assert_eq!(error.context().len(), 3);
|
||||
assert_eq!(error.context()[0].key(), "field");
|
||||
assert_eq!(error.context()[0].value(), "data");
|
||||
assert_eq!(error.context()[1].key(), "actual_len");
|
||||
assert_eq!(error.context()[1].value(), "10241");
|
||||
assert_eq!(error.context()[2].key(), "maximum_len");
|
||||
assert_eq!(error.context()[2].value(), "10240");
|
||||
return;
|
||||
}
|
||||
75
crates/ksp-interface-lib/tests/release_completeness.rs
Normal file
75
crates/ksp-interface-lib/tests/release_completeness.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
// file: crates/ksp-interface-lib/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
//! Release-level completeness canaries for the `0.2.13` Interface foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_005_exact_crate_root_export_inventory_is_stable() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let mut actual = std::vec::Vec::new();
|
||||
for line in crate_root.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub use ") {
|
||||
actual.push(trimmed);
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
let mut expected = std::vec![
|
||||
"pub use self::error::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED;",
|
||||
"pub use self::program_account_meta::MAX_PROGRAM_INSTRUCTION_ACCOUNTS;",
|
||||
"pub use self::program_account_meta::ProgramAccountMeta;",
|
||||
"pub use self::program_instruction::MAX_PROGRAM_INSTRUCTION_DATA_LEN;",
|
||||
"pub use self::program_instruction::ProgramInstruction;",
|
||||
"pub use ksp_core_lib::Pubkey;",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_production_module_inventory_contains_no_second_wire_domain() -> std::io::Result<()> {
|
||||
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let entries = match std::fs::read_dir(source_root) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match entry.file_name().into_string() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if name.ends_with(".rs") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, std::vec!["error.rs", "lib.rs", "program_account_meta.rs", "program_instruction.rs"]);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_foundation_has_one_error_code_and_two_bounded_passive_types() {
|
||||
assert_eq!(ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.domain(), "interface");
|
||||
assert_eq!(ksp_interface_lib::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED.code(), "program_instruction_limit_exceeded");
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||
assert_eq!(ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN, 10_240);
|
||||
let program_id = ksp_interface_lib::Pubkey::new_from_array([0xB1_u8; 32]);
|
||||
let account = ksp_interface_lib::ProgramAccountMeta::readonly(ksp_interface_lib::Pubkey::new_from_array([0xB2_u8; 32]), false);
|
||||
let instruction = ksp_interface_lib::ProgramInstruction::try_new(program_id, std::vec![account], std::vec![0xB3_u8]);
|
||||
assert!(instruction.is_ok());
|
||||
return;
|
||||
}
|
||||
31
crates/ksp-interface-lib/unit_tests/program_account_meta.rs
Normal file
31
crates/ksp-interface-lib/unit_tests/program_account_meta.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// file: crates/ksp-interface-lib/unit_tests/program_account_meta.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn readonly_and_writable_constructors_preserve_identity_signer_and_writable_flags() {
|
||||
let readonly_pubkey = crate::Pubkey::new_from_array([7_u8; 32]);
|
||||
let writable_pubkey = crate::Pubkey::new_from_array([9_u8; 32]);
|
||||
let readonly = crate::ProgramAccountMeta::readonly(readonly_pubkey, true);
|
||||
assert_eq!(readonly.pubkey(), &readonly_pubkey);
|
||||
assert!(readonly.is_signer());
|
||||
assert!(!readonly.is_writable());
|
||||
let writable = crate::ProgramAccountMeta::writable(writable_pubkey, false);
|
||||
assert_eq!(writable.pubkey(), &writable_pubkey);
|
||||
assert!(!writable.is_signer());
|
||||
assert!(writable.is_writable());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_meta_accepts_opaque_pubkeys_without_program_registry_validation() {
|
||||
let opaque_pubkey = crate::Pubkey::new_from_array([0xA5_u8; 32]);
|
||||
let meta = crate::ProgramAccountMeta::readonly(opaque_pubkey, false);
|
||||
assert_eq!(meta.pubkey(), &opaque_pubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_limit_constant_matches_the_interface_admission_contract() {
|
||||
assert_eq!(crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS, 255);
|
||||
return;
|
||||
}
|
||||
162
crates/ksp-interface-lib/unit_tests/program_instruction.rs
Normal file
162
crates/ksp-interface-lib/unit_tests/program_instruction.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
// file: crates/ksp-interface-lib/unit_tests/program_instruction.rs
|
||||
// version: 2
|
||||
|
||||
fn meta(byte: u8, writable: bool) -> crate::ProgramAccountMeta {
|
||||
let pubkey = crate::Pubkey::new_from_array([byte; 32]);
|
||||
if writable {
|
||||
return crate::ProgramAccountMeta::writable(pubkey, false);
|
||||
}
|
||||
return crate::ProgramAccountMeta::readonly(pubkey, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_accepts_empty_accounts_and_data_with_opaque_program_id() {
|
||||
let program_id = crate::Pubkey::new_from_array([0xD3_u8; 32]);
|
||||
let instruction = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), std::vec::Vec::new());
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(instruction.program_id(), &program_id);
|
||||
assert!(instruction.accounts().is_empty());
|
||||
assert!(instruction.data().is_empty());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_preserves_account_order_duplicates_and_opaque_data() {
|
||||
let program_id = crate::Pubkey::new_from_array([0x41_u8; 32]);
|
||||
let first = meta(1, false);
|
||||
let duplicate = meta(2, true);
|
||||
let accounts = std::vec![first, duplicate, first];
|
||||
let data = std::vec![0_u8, 1, 2, 0xFF];
|
||||
let instruction = crate::ProgramInstruction::try_new(program_id, accounts, data.clone());
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(instruction.accounts(), &[first, duplicate, first]);
|
||||
assert_eq!(instruction.data(), data.as_slice());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_accepts_account_limit_and_rejects_one_above_it() {
|
||||
let program_id = crate::Pubkey::new_from_array([0x51_u8; 32]);
|
||||
let account = meta(7, false);
|
||||
let accepted = crate::ProgramInstruction::try_new(program_id, std::vec![account; crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS], std::vec::Vec::new());
|
||||
assert!(accepted.is_ok());
|
||||
let rejected = crate::ProgramInstruction::try_new(program_id, std::vec![account; crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS + 1], std::vec::Vec::new());
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED);
|
||||
assert_eq!(error.message(), "Program instruction account count exceeds the Interface admission limit");
|
||||
assert_eq!(error.context().len(), 3);
|
||||
assert_eq!(error.context()[0].key(), "field");
|
||||
assert_eq!(error.context()[0].value(), "accounts");
|
||||
assert_eq!(error.context()[1].key(), "actual_len");
|
||||
assert_eq!(error.context()[1].value(), "256");
|
||||
assert_eq!(error.context()[2].key(), "maximum_len");
|
||||
assert_eq!(error.context()[2].value(), "255");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_accepts_data_limit_and_rejects_one_byte_above_it() {
|
||||
let program_id = crate::Pubkey::new_from_array([0x61_u8; 32]);
|
||||
let accepted = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), std::vec![0xA5_u8; crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN]);
|
||||
assert!(accepted.is_ok());
|
||||
let rejected = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), std::vec![0x5A_u8; crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN + 1]);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_PROGRAM_INSTRUCTION_LIMIT_EXCEEDED);
|
||||
assert_eq!(error.message(), "Program instruction data length exceeds the Interface admission limit");
|
||||
assert_eq!(error.context().len(), 3);
|
||||
assert_eq!(error.context()[0].value(), "data");
|
||||
assert_eq!(error.context()[1].value(), "10241");
|
||||
assert_eq!(error.context()[2].value(), "10240");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_debug_is_bounded_and_omits_accounts_and_payload_bytes() {
|
||||
let program_id = crate::Pubkey::new_from_array([0x71_u8; 32]);
|
||||
let payload = b"PAYLOAD_SENTINEL_NEVER_RENDER".to_vec();
|
||||
let instruction = crate::ProgramInstruction::try_new(program_id, std::vec![meta(8, false), meta(9, true)], payload);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let rendered = std::format!("{instruction:?}");
|
||||
assert!(rendered.contains("ProgramInstruction"));
|
||||
assert!(rendered.contains("account_count: 2"));
|
||||
assert!(rendered.contains("data_len: 29"));
|
||||
assert!(!rendered.contains("PAYLOAD_SENTINEL_NEVER_RENDER"));
|
||||
assert!(!rendered.contains("is_signer"));
|
||||
assert!(!rendered.contains("is_writable"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_preserves_owned_vector_allocations_without_internal_reallocation() {
|
||||
let program_id = crate::Pubkey::new_from_array([0x81_u8; 32]);
|
||||
let accounts = std::vec![meta(10, false), meta(11, true), meta(12, false)];
|
||||
let accounts_ptr = accounts.as_ptr();
|
||||
let data = std::vec![0x10_u8, 0x20, 0x30, 0x40];
|
||||
let data_ptr = data.as_ptr();
|
||||
let instruction = crate::ProgramInstruction::try_new(program_id, accounts, data);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(instruction.accounts().as_ptr(), accounts_ptr);
|
||||
assert_eq!(instruction.data().as_ptr(), data_ptr);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_limit_errors_do_not_echo_hostile_payload_or_account_material() {
|
||||
let program_id = crate::Pubkey::new_from_array([0x91_u8; 32]);
|
||||
let hostile_marker = b"HOSTILE_INTERFACE_PAYLOAD_SENTINEL";
|
||||
let mut hostile_data = hostile_marker.to_vec();
|
||||
hostile_data.resize(crate::MAX_PROGRAM_INSTRUCTION_DATA_LEN + 1, 0xA5_u8);
|
||||
let data_error = crate::ProgramInstruction::try_new(program_id, std::vec::Vec::new(), hostile_data);
|
||||
assert!(data_error.is_err());
|
||||
let data_error = match data_error {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
let data_display = std::format!("{data_error}");
|
||||
let data_debug = std::format!("{data_error:?}");
|
||||
assert!(!data_display.contains("HOSTILE_INTERFACE_PAYLOAD_SENTINEL"));
|
||||
assert!(!data_debug.contains("HOSTILE_INTERFACE_PAYLOAD_SENTINEL"));
|
||||
for context in data_error.context() {
|
||||
assert!(!context.value().contains("HOSTILE_INTERFACE_PAYLOAD_SENTINEL"));
|
||||
}
|
||||
let hostile_account = meta(0xE1, true);
|
||||
let hostile_account_debug = std::format!("{hostile_account:?}");
|
||||
let account_error =
|
||||
crate::ProgramInstruction::try_new(program_id, std::vec![hostile_account; crate::MAX_PROGRAM_INSTRUCTION_ACCOUNTS + 1], std::vec::Vec::new());
|
||||
assert!(account_error.is_err());
|
||||
let account_error = match account_error {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
let account_debug = std::format!("{account_error:?}");
|
||||
assert!(!account_debug.contains(hostile_account_debug.as_str()));
|
||||
assert_eq!(account_error.context().len(), 3);
|
||||
assert_eq!(account_error.context()[0].value(), "accounts");
|
||||
assert_eq!(account_error.context()[1].value(), "256");
|
||||
assert_eq!(account_error.context()[2].value(), "255");
|
||||
return;
|
||||
}
|
||||
15
crates/ksp-program-api/Cargo.toml
Normal file
15
crates/ksp-program-api/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
# file: crates/ksp-program-api/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "ksp-program-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-interface-lib = { path = "../ksp-interface-lib" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
156
crates/ksp-program-api/README.md
Normal file
156
crates/ksp-program-api/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
<!-- file: crates/ksp-program-api/README.md -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# ksp-program-api
|
||||
|
||||
`ksp-program-api` est la façade publique ouverte du domaine Program KSP. Elle porte uniquement les contrats communs nécessaires aux implémentations Program officielles futures comme aux crates externes ; elle ne possède aucun decoder concret, registry runtime, payload canonique DECODE ni logique d'exécution.
|
||||
|
||||
La surface candidate de `0.2.14` est volontairement **instruction-only** et reste indépendante des couches runtime supérieures.
|
||||
|
||||
## Ownership
|
||||
|
||||
Le graphe normal est limité à :
|
||||
|
||||
```text
|
||||
ksp-program-api
|
||||
├── ksp-core-lib
|
||||
└── ksp-interface-lib
|
||||
└── ksp-core-lib
|
||||
```
|
||||
|
||||
Core reste propriétaire de :
|
||||
|
||||
```text
|
||||
Error
|
||||
ErrorCode
|
||||
ErrorContext
|
||||
Result
|
||||
Pubkey
|
||||
```
|
||||
|
||||
Interface reste propriétaire de :
|
||||
|
||||
```text
|
||||
ProgramAccountMeta
|
||||
ProgramInstruction
|
||||
```
|
||||
|
||||
Program API possède :
|
||||
|
||||
```text
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome<Decoded>
|
||||
ProgramInstructionDecoder
|
||||
```
|
||||
|
||||
`ksp-program-api` réexporte toute sa surface consommable depuis son crate-root. Aucun module interne n'est public.
|
||||
|
||||
## Surface publique candidate
|
||||
|
||||
L'inventaire crate-root de `0.2.14` contient exactement :
|
||||
|
||||
```text
|
||||
Error
|
||||
ErrorCode
|
||||
ErrorContext
|
||||
Result
|
||||
Pubkey
|
||||
ProgramAccountMeta
|
||||
ProgramInstruction
|
||||
ProgramInstructionRecognition
|
||||
ProgramInstructionDecodeOutcome<Decoded>
|
||||
ProgramInstructionDecoder
|
||||
```
|
||||
|
||||
Les canaris de release completeness verrouillent cet inventaire ainsi que les trois fichiers/modules Rust de production de la crate.
|
||||
|
||||
## Recognition
|
||||
|
||||
`ProgramInstructionRecognition` est `#[non_exhaustive]` :
|
||||
|
||||
```text
|
||||
NoMatch l'implémentation ne revendique pas l'instruction
|
||||
ProgramMatch le Program correspond, sans reconnaissance instruction-local exacte
|
||||
ExactMatch l'implémentation affirme un match instruction-local exact
|
||||
```
|
||||
|
||||
La reconnaissance ne porte aucun score, priorité, proof, confidence, discriminator textuel ni inventaire central de Programs. `ExactMatch` reste une affirmation de l'implémentation, pas une preuve indépendante produite par KSP.
|
||||
|
||||
## Decode outcome
|
||||
|
||||
`ProgramInstructionDecodeOutcome<Decoded>` est `#[non_exhaustive]` :
|
||||
|
||||
```text
|
||||
Decoded(Decoded) valeur typée possédée par l'implémentation
|
||||
Unsupported instruction reconnue mais non supportée par cette capability
|
||||
```
|
||||
|
||||
Les échecs réels utilisent le `Result` Core. Il n'existe aucune variante parallèle `Failed` ou `Ignored`.
|
||||
|
||||
Le `Debug` de l'outcome n'impose pas `Decoded: Debug` et n'affiche jamais la valeur `Decoded`.
|
||||
|
||||
## Decoder instruction-only
|
||||
|
||||
`ProgramInstructionDecoder` est un trait ouvert `Send + Sync` :
|
||||
|
||||
```text
|
||||
type Decoded
|
||||
program_ids(&self) -> &[Pubkey]
|
||||
recognize(&self, &ProgramInstruction) -> ProgramInstructionRecognition
|
||||
decode(&self, &ProgramInstruction) -> Result<ProgramInstructionDecodeOutcome<Self::Decoded>>
|
||||
```
|
||||
|
||||
`Decoded` ne reçoit aucun bound implicite supplémentaire : une implémentation reste propriétaire de son type de sortie concret. Le trait ne fournit aucun default method et ne promet pas de composition `dyn` hétérogène.
|
||||
|
||||
`program_ids()` expose des `Pubkey` opaques ; une implémentation externe peut prendre en charge un Program ID absent du registry Core. Aucun enum central, `Any`, JSON ou descriptor global n'est nécessaire.
|
||||
|
||||
`program_ids` et `recognize` servent à la sélection explicite. `decode` consomme par référence une `ProgramInstruction` déjà admise/bornée par Interface et déjà sélectionnée pour le decoder.
|
||||
|
||||
## Hardening validé
|
||||
|
||||
La candidate verrouille notamment :
|
||||
|
||||
```text
|
||||
Program Pubkey non enregistré accepté
|
||||
input Interface maximal 255 accounts + 10_240 bytes accepté à la frontière decoder
|
||||
payload hostile aucun echo automatique ajouté par Program API
|
||||
Debug outcome valeur Decoded jamais rendue
|
||||
associated Decoded aucun Debug/Clone/Send/Sync imposé
|
||||
closed-world Program enum absent
|
||||
registry / descriptors / priority absents
|
||||
ProgramExecutionPreparer absent
|
||||
serde / JSON / Any / codecs absents
|
||||
logging / runtime / filesystem / environment / I/O absents
|
||||
```
|
||||
|
||||
Une implémentation tierce reste responsable du contenu des erreurs qu'elle construit explicitement. `ksp-program-api` garantit seulement qu'il n'ajoute aucun canal parallèle ni copie automatique du payload d'entrée.
|
||||
|
||||
## Frontières
|
||||
|
||||
La foundation `0.2.14` ne contient pas :
|
||||
|
||||
```text
|
||||
ksp-program-lib
|
||||
registry runtime
|
||||
identity/version/coverage de decoder
|
||||
payload canonique D3
|
||||
ProgramAccountDecoder
|
||||
ProgramEventDecoder
|
||||
ProgramReturnDataDecoder
|
||||
ProgramExecutionPreparer
|
||||
serde / serde_json
|
||||
borsh / wincode / bincode
|
||||
solana-instruction
|
||||
network / async runtime
|
||||
logging / tracing
|
||||
Wallet / Transport / Store / Materializer / Config / Tauri
|
||||
```
|
||||
|
||||
Ces surfaces restent reportées jusqu'aux vertical slices qui démontreront leurs contrats réels. En particulier, les codecs wire officiels restent possédés par `ksp-interface-lib` et ne sont introduits qu'en présence d'un protocole réel.
|
||||
|
||||
## Références
|
||||
|
||||
- [Usage public](USAGE.md)
|
||||
- [Plan `0.2.14`](../../docs/plans/021-V0_2_14_PROGRAM_API_PLAN.md)
|
||||
- [Validation `0.2.14`](../../docs/validation/017-V0_2_14_PROGRAM_API.md)
|
||||
- [Architecture Wire + Program](../../docs/architecture/006-WIRE_AND_PROGRAM.md)
|
||||
166
crates/ksp-program-api/USAGE.md
Normal file
166
crates/ksp-program-api/USAGE.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<!-- file: crates/ksp-program-api/USAGE.md -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# Usage de ksp-program-api
|
||||
|
||||
Cette page décrit la surface publique candidate de `0.2.14`. Utiliser uniquement les exports du crate-root ; aucun module interne ne fait partie du contrat consommable.
|
||||
|
||||
## Construire un input Program avec la façade
|
||||
|
||||
```rust
|
||||
let program_id = ksp_program_api::Pubkey::new_from_array([1_u8; 32]);
|
||||
let account_id = ksp_program_api::Pubkey::new_from_array([2_u8; 32]);
|
||||
let account = ksp_program_api::ProgramAccountMeta::readonly(account_id, true);
|
||||
|
||||
let instruction = ksp_program_api::ProgramInstruction::try_new(
|
||||
program_id,
|
||||
std::vec![account],
|
||||
std::vec![0x01_u8, 0x02, 0x03],
|
||||
);
|
||||
|
||||
assert!(instruction.is_ok());
|
||||
```
|
||||
|
||||
`Pubkey`, `ProgramAccountMeta` et `ProgramInstruction` conservent leur ownership Core/Interface. Les bornes `255` account metas et `10_240` bytes de data sont appliquées par Interface avant l'entrée dans le decoder.
|
||||
|
||||
## Implémenter un decoder externe
|
||||
|
||||
Le type décodé reste entièrement possédé par la crate d'implémentation :
|
||||
|
||||
```rust
|
||||
struct ExternalDecodedInstruction {
|
||||
opcode: u8,
|
||||
}
|
||||
|
||||
struct ExternalDecoder {
|
||||
program_ids: [ksp_program_api::Pubkey; 1],
|
||||
}
|
||||
|
||||
impl ksp_program_api::ProgramInstructionDecoder for ExternalDecoder {
|
||||
type Decoded = ExternalDecodedInstruction;
|
||||
|
||||
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||
return &self.program_ids;
|
||||
}
|
||||
|
||||
fn recognize(
|
||||
&self,
|
||||
instruction: &ksp_program_api::ProgramInstruction,
|
||||
) -> ksp_program_api::ProgramInstructionRecognition {
|
||||
if instruction.program_id() != &self.program_ids[0] {
|
||||
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||
}
|
||||
|
||||
if instruction.data().first() == std::option::Option::Some(&0x2A_u8) {
|
||||
return ksp_program_api::ProgramInstructionRecognition::ExactMatch;
|
||||
}
|
||||
|
||||
return ksp_program_api::ProgramInstructionRecognition::ProgramMatch;
|
||||
}
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
instruction: &ksp_program_api::ProgramInstruction,
|
||||
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||
let opcode = match instruction.data().first() {
|
||||
std::option::Option::Some(value) if *value == 0x2A_u8 => *value,
|
||||
_ => {
|
||||
return std::result::Result::Ok(
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return std::result::Result::Ok(
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(
|
||||
ExternalDecodedInstruction { opcode },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Aucun `ksp-program-lib`, enum centrale, `Any`, JSON ou codec n'est nécessaire. Le Program ID peut être un `Pubkey` opaque non enregistré par Core.
|
||||
|
||||
## Sélection explicite
|
||||
|
||||
La sélection reste distincte du décodage :
|
||||
|
||||
```rust
|
||||
let recognition = ksp_program_api::ProgramInstructionDecoder::recognize(
|
||||
&decoder,
|
||||
&instruction,
|
||||
);
|
||||
|
||||
match recognition {
|
||||
ksp_program_api::ProgramInstructionRecognition::NoMatch => {}
|
||||
ksp_program_api::ProgramInstructionRecognition::ProgramMatch => {}
|
||||
ksp_program_api::ProgramInstructionRecognition::ExactMatch => {}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
L'enum est `#[non_exhaustive]`. `ExactMatch` exprime l'affirmation du decoder. `decode` n'est pas un substitut à `recognize` : il traite une instruction déjà sélectionnée pour cette implémentation.
|
||||
|
||||
## Outcome et erreur
|
||||
|
||||
```rust
|
||||
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(
|
||||
&decoder,
|
||||
&instruction,
|
||||
)?;
|
||||
|
||||
match outcome {
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => {
|
||||
let _opcode = value.opcode;
|
||||
}
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported => {}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
Une erreur réelle est un `Err(ksp_program_api::Error)`. `Unsupported` n'est pas une deuxième forme d'erreur : il indique qu'une instruction reconnue n'est volontairement pas décodée par cette capability.
|
||||
|
||||
Program API ne recopie automatiquement ni le payload de l'instruction ni les account metas dans l'erreur. Une implémentation externe reste responsable des messages/contextes qu'elle construit explicitement.
|
||||
|
||||
## Output sans bounds implicites
|
||||
|
||||
L'associated type `Decoded` n'impose pas `Debug`, `Clone`, `Send` ou `Sync`. Les supertraits `Send + Sync` s'appliquent au decoder lui-même, pas à la valeur décodée :
|
||||
|
||||
```rust
|
||||
struct LocalDecoded(std::rc::Rc<std::cell::Cell<u8>>);
|
||||
```
|
||||
|
||||
Un decoder peut utiliser ce type comme `Decoded` tant que son propre état satisfait `Send + Sync`.
|
||||
|
||||
## Debug sûr
|
||||
|
||||
`ProgramInstructionDecodeOutcome<Decoded>` possède un `Debug` volontairement opaque :
|
||||
|
||||
```rust
|
||||
struct SecretDecoded;
|
||||
|
||||
let outcome = ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(SecretDecoded);
|
||||
assert_eq!(std::format!("{outcome:?}"), "Decoded");
|
||||
```
|
||||
|
||||
`SecretDecoded` n'a pas besoin d'implémenter `Debug` et sa valeur n'est jamais rendue par l'outcome.
|
||||
|
||||
## Ce qui n'est pas simulé côté consumer
|
||||
|
||||
Il n'existe dans `0.2.14` aucun :
|
||||
|
||||
```text
|
||||
registry de decoders
|
||||
composition dyn hétérogène
|
||||
identity/version/coverage descriptor
|
||||
priority/conflict policy
|
||||
payload canonique D3
|
||||
ProgramAccountDecoder / Event / ReturnData
|
||||
ProgramExecutionPreparer
|
||||
execution policy
|
||||
serde / JSON / codec
|
||||
logging / runtime réseau
|
||||
```
|
||||
|
||||
Ces surfaces ne doivent pas être recréées localement comme si elles faisaient déjà partie du contrat commun. Elles attendent les vertical slices qui justifieront leurs invariants réels.
|
||||
37
crates/ksp-program-api/src/lib.rs
Normal file
37
crates/ksp-program-api/src/lib.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
// file: crates/ksp-program-api/src/lib.rs
|
||||
// version: 3
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Open Program contracts shared by KSP and external Program implementations.
|
||||
//!
|
||||
//! The foundation exposes Core/Interface types plus the minimal instruction
|
||||
//! recognition/decode vocabulary and the instruction decoder trait. Registries,
|
||||
//! codecs, runtime logging and execution preparation remain outside this
|
||||
//! foundation until their ownership is justified.
|
||||
|
||||
mod program_instruction_decode;
|
||||
mod program_instruction_decoder;
|
||||
|
||||
/// Result of a successful Program instruction decode attempt.
|
||||
pub use self::program_instruction_decode::ProgramInstructionDecodeOutcome;
|
||||
/// Recognition strength reported by one Program instruction implementation.
|
||||
pub use self::program_instruction_decode::ProgramInstructionRecognition;
|
||||
/// Open contract implemented by one Program instruction decoder.
|
||||
pub use self::program_instruction_decoder::ProgramInstructionDecoder;
|
||||
/// Common KSP error type used by Program-facing contracts.
|
||||
pub use ksp_core_lib::Error;
|
||||
/// Stable structured code identifying a KSP error category and condition.
|
||||
pub use ksp_core_lib::ErrorCode;
|
||||
/// Structured contextual field attached to a KSP error.
|
||||
pub use ksp_core_lib::ErrorContext;
|
||||
/// Canonical Solana account address primitive owned by `ksp-core-lib`.
|
||||
pub use ksp_core_lib::Pubkey;
|
||||
/// Common KSP result alias using [`Error`].
|
||||
pub use ksp_core_lib::Result;
|
||||
/// Passive account metadata attached to one Program instruction.
|
||||
pub use ksp_interface_lib::ProgramAccountMeta;
|
||||
/// Passive, bounded Program instruction wire contract.
|
||||
pub use ksp_interface_lib::ProgramInstruction;
|
||||
50
crates/ksp-program-api/src/program_instruction_decode.rs
Normal file
50
crates/ksp-program-api/src/program_instruction_decode.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
// file: crates/ksp-program-api/src/program_instruction_decode.rs
|
||||
// version: 1
|
||||
|
||||
/// Recognition strength reported by one Program instruction implementation.
|
||||
///
|
||||
/// Recognition is intentionally instruction-local. It does not encode registry
|
||||
/// priority, a persisted proof, a textual discriminator or a global Program
|
||||
/// kind. [`Self::ExactMatch`] is an assertion made by the implementation for
|
||||
/// the current instruction, while [`Self::ProgramMatch`] only establishes the
|
||||
/// Program-level match.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ProgramInstructionRecognition {
|
||||
/// The implementation does not claim the instruction.
|
||||
NoMatch,
|
||||
/// The Program or Program family matches, but the instruction is not proven exact.
|
||||
ProgramMatch,
|
||||
/// The implementation claims an exact instruction-local match.
|
||||
ExactMatch,
|
||||
}
|
||||
|
||||
/// Result of a successful Program instruction decode attempt.
|
||||
///
|
||||
/// Decode failures are represented by the surrounding KSP [`crate::Result`],
|
||||
/// not by a parallel failure variant. `Unsupported` is reserved for a known
|
||||
/// Program instruction that the implementation deliberately does not decode.
|
||||
///
|
||||
/// The custom [`std::fmt::Debug`] implementation never formats the `Decoded`
|
||||
/// value, so external decoded payloads are not exposed accidentally through
|
||||
/// generic diagnostics.
|
||||
#[non_exhaustive]
|
||||
pub enum ProgramInstructionDecodeOutcome<Decoded> {
|
||||
/// The instruction was decoded into the implementation-owned output type.
|
||||
Decoded(Decoded),
|
||||
/// The instruction is known but unsupported by this decode capability.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl<Decoded> std::fmt::Debug for ProgramInstructionDecodeOutcome<Decoded> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Decoded(_) => return formatter.write_str("Decoded"),
|
||||
Self::Unsupported => return formatter.write_str("Unsupported"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/program_instruction_decode.rs"]
|
||||
mod tests;
|
||||
28
crates/ksp-program-api/src/program_instruction_decoder.rs
Normal file
28
crates/ksp-program-api/src/program_instruction_decoder.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
// file: crates/ksp-program-api/src/program_instruction_decoder.rs
|
||||
// version: 1
|
||||
|
||||
/// Open contract implemented by one Program instruction decoder.
|
||||
///
|
||||
/// The decoder owns its concrete [`Self::Decoded`] type. No central Program
|
||||
/// enum, erased `Any` payload or serialization contract is required. Program
|
||||
/// identifiers remain opaque [`crate::Pubkey`] values and do not need to be
|
||||
/// registered by Core.
|
||||
///
|
||||
/// Candidate selection is explicit: callers use [`Self::program_ids`] and
|
||||
/// [`Self::recognize`] before invoking [`Self::decode`]. `decode` therefore
|
||||
/// reports only a successful typed value, an intentional unsupported state, or
|
||||
/// a KSP [`crate::Result`] error. The trait defines no default methods and does
|
||||
/// not promise heterogeneous runtime object composition.
|
||||
pub trait ProgramInstructionDecoder: Send + Sync {
|
||||
/// Concrete decoded instruction type owned by the implementation.
|
||||
type Decoded;
|
||||
|
||||
/// Returns the opaque Program identifiers claimed by this decoder.
|
||||
fn program_ids(&self) -> &[crate::Pubkey];
|
||||
|
||||
/// Reports how strongly this decoder recognizes one bounded instruction.
|
||||
fn recognize(&self, instruction: &crate::ProgramInstruction) -> crate::ProgramInstructionRecognition;
|
||||
|
||||
/// Decodes one instruction already selected for this decoder.
|
||||
fn decode(&self, instruction: &crate::ProgramInstruction) -> crate::Result<crate::ProgramInstructionDecodeOutcome<Self::Decoded>>;
|
||||
}
|
||||
105
crates/ksp-program-api/tests/dependency_boundary.rs
Normal file
105
crates/ksp-program-api/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
// file: crates/ksp-program-api/tests/dependency_boundary.rs
|
||||
// version: 3
|
||||
|
||||
//! Dependency and declarative-surface canaries for the Program API foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_manifest_has_exact_core_and_interface_runtime_dependencies() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||
assert!(dependencies_tail.is_some(), "Program API dependencies section must exist");
|
||||
let dependencies_tail = match dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib", "ksp-interface-lib"]);
|
||||
for forbidden in [
|
||||
"ksp-config-lib",
|
||||
"ksp-logging-lib",
|
||||
"ksp-materializer-api",
|
||||
"ksp-materializer-lib",
|
||||
"ksp-offchain-transport-lib",
|
||||
"ksp-onchain-transport-lib",
|
||||
"ksp-program-lib",
|
||||
"ksp-store-api",
|
||||
"ksp-store-lib",
|
||||
"ksp-wallet-lib",
|
||||
"borsh",
|
||||
"bincode",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solana-instruction",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"wincode",
|
||||
] {
|
||||
assert!(!dependencies.contains(forbidden), "forbidden Program API dependency detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_crate_root_adds_decoder_without_runtime_surface() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"ProgramInstructionDecodeOutcome",
|
||||
"ProgramInstructionDecoder",
|
||||
"ProgramInstructionRecognition",
|
||||
"Error",
|
||||
"ErrorCode",
|
||||
"ErrorContext",
|
||||
"Pubkey",
|
||||
"Result",
|
||||
"ProgramAccountMeta",
|
||||
"ProgramInstruction",
|
||||
] {
|
||||
assert!(crate_root.contains(required), "required Program API facade export missing: {required}");
|
||||
}
|
||||
for forbidden in ["pub mod ", "ProgramExecutionPreparer", "TRACING_TARGET", "ksp_logging_lib", "serde", "Any"] {
|
||||
assert!(!crate_root.contains(forbidden), "forbidden pre.004 Program API surface detected: {forbidden}");
|
||||
}
|
||||
let decoder_source = include_str!("../src/program_instruction_decoder.rs");
|
||||
assert!(decoder_source.contains("pub trait ProgramInstructionDecoder: Send + Sync"));
|
||||
assert!(decoder_source.contains("type Decoded;"));
|
||||
assert!(decoder_source.contains("fn program_ids(&self) -> &[crate::Pubkey];"));
|
||||
assert!(decoder_source.contains("fn recognize(&self, instruction: &crate::ProgramInstruction)"));
|
||||
assert!(decoder_source.contains("crate::Result<crate::ProgramInstructionDecodeOutcome<Self::Decoded>>"));
|
||||
assert!(!decoder_source.contains("ProgramExecutionPreparer"));
|
||||
assert!(!decoder_source.contains("serde"));
|
||||
assert!(!decoder_source.contains("std::any::Any"));
|
||||
let outcome_source = include_str!("../src/program_instruction_decode.rs");
|
||||
for forbidden in ["Ignored", "Failed", "serde", "Any"] {
|
||||
assert!(!outcome_source.contains(forbidden), "forbidden recognition/outcome concept detected: {forbidden}");
|
||||
}
|
||||
assert!(!std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs").exists());
|
||||
return;
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in section.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
115
crates/ksp-program-api/tests/external_implementation.rs
Normal file
115
crates/ksp-program-api/tests/external_implementation.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// file: crates/ksp-program-api/tests/external_implementation.rs
|
||||
// version: 1
|
||||
|
||||
//! Downstream-style implementation canary for the open Program decoder contract.
|
||||
|
||||
const EXTERNAL_PROGRAM_ID_BYTES: [u8; 32] = [0xE7_u8; 32];
|
||||
const FOREIGN_PROGRAM_ID_BYTES: [u8; 32] = [0xE8_u8; 32];
|
||||
const SUPPORTED_OPCODE: u8 = 0x2A_u8;
|
||||
|
||||
struct ExternalDecodedInstruction {
|
||||
opcode: u8,
|
||||
}
|
||||
|
||||
struct ExternalProgramDecoder {
|
||||
program_ids: [ksp_program_api::Pubkey; 1],
|
||||
}
|
||||
|
||||
impl ExternalProgramDecoder {
|
||||
fn new() -> Self {
|
||||
return Self { program_ids: [ksp_program_api::Pubkey::new_from_array(EXTERNAL_PROGRAM_ID_BYTES)] };
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_program_api::ProgramInstructionDecoder for ExternalProgramDecoder {
|
||||
type Decoded = ExternalDecodedInstruction;
|
||||
|
||||
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||
return &self.program_ids;
|
||||
}
|
||||
|
||||
fn recognize(&self, instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||
if instruction.program_id() != &self.program_ids[0] {
|
||||
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||
}
|
||||
if instruction.data().first() == std::option::Option::Some(&SUPPORTED_OPCODE) {
|
||||
return ksp_program_api::ProgramInstructionRecognition::ExactMatch;
|
||||
}
|
||||
return ksp_program_api::ProgramInstructionRecognition::ProgramMatch;
|
||||
}
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
instruction: &ksp_program_api::ProgramInstruction,
|
||||
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||
if instruction.program_id() != &self.program_ids[0] {
|
||||
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported);
|
||||
}
|
||||
let opcode = match instruction.data().first() {
|
||||
std::option::Option::Some(value) if *value == SUPPORTED_OPCODE => *value,
|
||||
_ => return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(ExternalDecodedInstruction { opcode }));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_send_sync<T: Send + Sync>(_value: &T) {
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_external_decoder_uses_unregistered_pubkey_and_implementation_owned_output() {
|
||||
let decoder = ExternalProgramDecoder::new();
|
||||
assert_send_sync(&decoder);
|
||||
let external_program_id = ksp_program_api::Pubkey::new_from_array(EXTERNAL_PROGRAM_ID_BYTES);
|
||||
assert!(ksp_core_lib::find_program_pubkey(&external_program_id).is_none());
|
||||
assert_eq!(ksp_program_api::ProgramInstructionDecoder::program_ids(&decoder), &[external_program_id]);
|
||||
let exact = ksp_program_api::ProgramInstruction::try_new(external_program_id, std::vec![], std::vec![SUPPORTED_OPCODE]);
|
||||
assert!(exact.is_ok());
|
||||
let exact = match exact {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(ksp_program_api::ProgramInstructionDecoder::recognize(&decoder, &exact), ksp_program_api::ProgramInstructionRecognition::ExactMatch);
|
||||
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &exact);
|
||||
assert!(outcome.is_ok());
|
||||
let outcome = match outcome {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let decoded = match outcome {
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||
_ => return,
|
||||
};
|
||||
assert_eq!(decoded.opcode, SUPPORTED_OPCODE);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_004_external_decoder_distinguishes_program_match_unsupported_and_no_match() {
|
||||
let decoder = ExternalProgramDecoder::new();
|
||||
let external_program_id = ksp_program_api::Pubkey::new_from_array(EXTERNAL_PROGRAM_ID_BYTES);
|
||||
let program_only = ksp_program_api::ProgramInstruction::try_new(external_program_id, std::vec![], std::vec![0x11_u8]);
|
||||
assert!(program_only.is_ok());
|
||||
let program_only = match program_only {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(ksp_program_api::ProgramInstructionDecoder::recognize(&decoder, &program_only), ksp_program_api::ProgramInstructionRecognition::ProgramMatch);
|
||||
let unsupported = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &program_only);
|
||||
assert!(unsupported.is_ok());
|
||||
let unsupported = match unsupported {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(matches!(unsupported, ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported));
|
||||
let foreign_program_id = ksp_program_api::Pubkey::new_from_array(FOREIGN_PROGRAM_ID_BYTES);
|
||||
let foreign = ksp_program_api::ProgramInstruction::try_new(foreign_program_id, std::vec![], std::vec![SUPPORTED_OPCODE]);
|
||||
assert!(foreign.is_ok());
|
||||
let foreign = match foreign {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(ksp_program_api::ProgramInstructionDecoder::recognize(&decoder, &foreign), ksp_program_api::ProgramInstructionRecognition::NoMatch);
|
||||
return;
|
||||
}
|
||||
77
crates/ksp-program-api/tests/public_api.rs
Normal file
77
crates/ksp-program-api/tests/public_api.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// file: crates/ksp-program-api/tests/public_api.rs
|
||||
// version: 3
|
||||
|
||||
//! Integration canaries for the public `ksp-program-api` foundation.
|
||||
|
||||
fn consume_result(value: ksp_program_api::Result<ksp_program_api::Pubkey>) -> ksp_program_api::Result<ksp_program_api::Pubkey> {
|
||||
return value;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_002_core_and_interface_facade_is_available_from_crate_root() {
|
||||
let program_id = ksp_program_api::Pubkey::new_from_array([0xA1_u8; 32]);
|
||||
let account_id = ksp_program_api::Pubkey::new_from_array([0xA2_u8; 32]);
|
||||
let account = ksp_program_api::ProgramAccountMeta::readonly(account_id, true);
|
||||
let instruction = ksp_program_api::ProgramInstruction::try_new(program_id, std::vec![account], std::vec![0xA3_u8]);
|
||||
assert!(instruction.is_ok());
|
||||
let forwarded = consume_result(std::result::Result::Ok(program_id));
|
||||
assert!(forwarded.is_ok());
|
||||
let error_code_type: std::option::Option<ksp_program_api::ErrorCode> = std::option::Option::None;
|
||||
let error_context_type: std::option::Option<ksp_program_api::ErrorContext> = std::option::Option::None;
|
||||
let error_type: std::option::Option<ksp_program_api::Error> = std::option::Option::None;
|
||||
assert!(error_code_type.is_none());
|
||||
assert!(error_context_type.is_none());
|
||||
assert!(error_type.is_none());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_002_scaffold_does_not_require_private_modules() {
|
||||
let source = include_str!("../src/lib.rs");
|
||||
assert!(!source.contains("pub mod "));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_recognition_and_decode_outcome_are_available_from_crate_root() {
|
||||
let recognition = ksp_program_api::ProgramInstructionRecognition::ProgramMatch;
|
||||
assert_eq!(std::format!("{recognition:?}"), "ProgramMatch");
|
||||
let decoded = ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(31_u16);
|
||||
assert_eq!(std::format!("{decoded:?}"), "Decoded");
|
||||
let decoded_value = match decoded {
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||
_ => 0_u16,
|
||||
};
|
||||
assert_eq!(decoded_value, 31_u16);
|
||||
let unsupported = ksp_program_api::ProgramInstructionDecodeOutcome::<u16>::Unsupported;
|
||||
assert_eq!(std::format!("{unsupported:?}"), "Unsupported");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_004_instruction_decoder_trait_is_available_from_crate_root() {
|
||||
let decoder = NeverInstantiatedDecoder;
|
||||
assert!(ksp_program_api::ProgramInstructionDecoder::program_ids(&decoder).is_empty());
|
||||
return;
|
||||
}
|
||||
|
||||
struct NeverInstantiatedDecoder;
|
||||
|
||||
impl ksp_program_api::ProgramInstructionDecoder for NeverInstantiatedDecoder {
|
||||
type Decoded = u8;
|
||||
|
||||
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||
return &[];
|
||||
}
|
||||
|
||||
fn recognize(&self, _instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||
}
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
_instruction: &ksp_program_api::ProgramInstruction,
|
||||
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Unsupported);
|
||||
}
|
||||
}
|
||||
128
crates/ksp-program-api/tests/release_completeness.rs
Normal file
128
crates/ksp-program-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
// file: crates/ksp-program-api/tests/release_completeness.rs
|
||||
// version: 2
|
||||
|
||||
//! Release-level completeness canaries for the `0.2.14` Program API foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_005_exact_crate_root_export_inventory_is_stable() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let mut actual = std::vec::Vec::new();
|
||||
for line in crate_root.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub use ") {
|
||||
actual.push(trimmed);
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
let mut expected = std::vec![
|
||||
"pub use self::program_instruction_decode::ProgramInstructionDecodeOutcome;",
|
||||
"pub use self::program_instruction_decode::ProgramInstructionRecognition;",
|
||||
"pub use self::program_instruction_decoder::ProgramInstructionDecoder;",
|
||||
"pub use ksp_core_lib::Error;",
|
||||
"pub use ksp_core_lib::ErrorCode;",
|
||||
"pub use ksp_core_lib::ErrorContext;",
|
||||
"pub use ksp_core_lib::Pubkey;",
|
||||
"pub use ksp_core_lib::Result;",
|
||||
"pub use ksp_interface_lib::ProgramAccountMeta;",
|
||||
"pub use ksp_interface_lib::ProgramInstruction;",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_production_module_inventory_is_instruction_only() -> std::io::Result<()> {
|
||||
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let entries = match std::fs::read_dir(source_root) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match entry.file_name().into_string() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if name.ends_with(".rs") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, std::vec!["lib.rs", "program_instruction_decode.rs", "program_instruction_decoder.rs"]);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_public_enum_and_trait_inventory_remains_open_world() {
|
||||
let sources = [include_str!("../src/lib.rs"), include_str!("../src/program_instruction_decode.rs"), include_str!("../src/program_instruction_decoder.rs")];
|
||||
let mut public_enums = std::vec::Vec::new();
|
||||
let mut public_traits = std::vec::Vec::new();
|
||||
for source in sources {
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub enum ") {
|
||||
public_enums.push(trimmed);
|
||||
}
|
||||
if trimmed.starts_with("pub trait ") {
|
||||
public_traits.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
public_enums.sort_unstable();
|
||||
public_traits.sort_unstable();
|
||||
assert_eq!(public_enums, std::vec!["pub enum ProgramInstructionDecodeOutcome<Decoded> {", "pub enum ProgramInstructionRecognition {"]);
|
||||
assert_eq!(public_traits, std::vec!["pub trait ProgramInstructionDecoder: Send + Sync {"]);
|
||||
let decode_source = include_str!("../src/program_instruction_decode.rs");
|
||||
assert!(decode_source.contains("#[non_exhaustive]\npub enum ProgramInstructionRecognition"));
|
||||
assert!(decode_source.contains("#[non_exhaustive]\npub enum ProgramInstructionDecodeOutcome<Decoded>"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_production_sources_have_no_registry_preparer_codec_or_runtime_creep() {
|
||||
let direct_tracing_path = ["tracing", "::"].concat();
|
||||
let sources = [include_str!("../src/lib.rs"), include_str!("../src/program_instruction_decode.rs"), include_str!("../src/program_instruction_decoder.rs")];
|
||||
for source in sources {
|
||||
for forbidden in [
|
||||
"pub enum ProgramKind",
|
||||
"pub struct ProgramRegistry",
|
||||
"pub trait ProgramAccountDecoder",
|
||||
"pub trait ProgramEventDecoder",
|
||||
"pub trait ProgramReturnDataDecoder",
|
||||
"pub trait ProgramExecutionPreparer",
|
||||
"std::any::Any",
|
||||
"serde::",
|
||||
"serde_json::",
|
||||
"borsh::",
|
||||
"bincode::",
|
||||
"wincode::",
|
||||
"ksp_logging_lib::",
|
||||
"reqwest::",
|
||||
"tokio::",
|
||||
"tonic::",
|
||||
"tauri::",
|
||||
"std::env::",
|
||||
"std::fs::",
|
||||
"std::net::",
|
||||
"dyn ProgramInstructionDecoder",
|
||||
"dyn crate::ProgramInstructionDecoder",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden Program API production surface detected: {forbidden}");
|
||||
}
|
||||
assert!(!source.contains(direct_tracing_path.as_str()), "forbidden Program API production direct tracing path detected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
156
crates/ksp-program-api/tests/security_hardening.rs
Normal file
156
crates/ksp-program-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
// file: crates/ksp-program-api/tests/security_hardening.rs
|
||||
// version: 1
|
||||
|
||||
//! Adversarial and bound-safety canaries for the Program API foundation.
|
||||
|
||||
const HOSTILE_MARKER: &str = "PROGRAM-SECRET-CANARY";
|
||||
const MALFORMED_OPCODE: u8 = 0xFF_u8;
|
||||
const PROGRAM_ID_BYTES: [u8; 32] = [0xD1_u8; 32];
|
||||
|
||||
struct BoundsObserved {
|
||||
account_count: usize,
|
||||
data_len: usize,
|
||||
}
|
||||
|
||||
struct BoundedDecoder {
|
||||
program_ids: [ksp_program_api::Pubkey; 1],
|
||||
}
|
||||
|
||||
impl BoundedDecoder {
|
||||
fn new() -> Self {
|
||||
return Self { program_ids: [ksp_program_api::Pubkey::new_from_array(PROGRAM_ID_BYTES)] };
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_program_api::ProgramInstructionDecoder for BoundedDecoder {
|
||||
type Decoded = BoundsObserved;
|
||||
|
||||
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||
return &self.program_ids;
|
||||
}
|
||||
|
||||
fn recognize(&self, instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||
if instruction.program_id() == &self.program_ids[0] {
|
||||
return ksp_program_api::ProgramInstructionRecognition::ExactMatch;
|
||||
}
|
||||
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||
}
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
instruction: &ksp_program_api::ProgramInstruction,
|
||||
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||
if instruction.data().first() == std::option::Option::Some(&MALFORMED_OPCODE) {
|
||||
return std::result::Result::Err(ksp_program_api::Error::new(
|
||||
ksp_program_api::ErrorCode::new("program_test", "malformed_instruction"),
|
||||
"malformed external Program instruction",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(BoundsObserved {
|
||||
account_count: instruction.accounts().len(),
|
||||
data_len: instruction.data().len(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
struct BoundlessDecoded {
|
||||
marker: std::rc::Rc<std::cell::Cell<u8>>,
|
||||
}
|
||||
|
||||
struct BoundlessOutputDecoder;
|
||||
|
||||
impl ksp_program_api::ProgramInstructionDecoder for BoundlessOutputDecoder {
|
||||
type Decoded = BoundlessDecoded;
|
||||
|
||||
fn program_ids(&self) -> &[ksp_program_api::Pubkey] {
|
||||
return &[];
|
||||
}
|
||||
|
||||
fn recognize(&self, _instruction: &ksp_program_api::ProgramInstruction) -> ksp_program_api::ProgramInstructionRecognition {
|
||||
return ksp_program_api::ProgramInstructionRecognition::NoMatch;
|
||||
}
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
_instruction: &ksp_program_api::ProgramInstruction,
|
||||
) -> ksp_program_api::Result<ksp_program_api::ProgramInstructionDecodeOutcome<Self::Decoded>> {
|
||||
return std::result::Result::Ok(ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(BoundlessDecoded {
|
||||
marker: std::rc::Rc::new(std::cell::Cell::new(0x5A_u8)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_max_interface_instruction_crosses_decoder_boundary_without_new_contract() {
|
||||
let decoder = BoundedDecoder::new();
|
||||
let account = ksp_program_api::ProgramAccountMeta::readonly(ksp_program_api::Pubkey::new_from_array([0xD2_u8; 32]), false);
|
||||
let accounts = std::vec![account; ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS];
|
||||
let data = std::vec![0x5A_u8; ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN];
|
||||
let instruction = ksp_program_api::ProgramInstruction::try_new(decoder.program_ids[0], accounts, data);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &instruction);
|
||||
assert!(outcome.is_ok());
|
||||
let outcome = match outcome {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let observed = match outcome {
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||
_ => return,
|
||||
};
|
||||
assert_eq!(observed.account_count, ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_ACCOUNTS);
|
||||
assert_eq!(observed.data_len, ksp_interface_lib::MAX_PROGRAM_INSTRUCTION_DATA_LEN);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_malformed_payload_error_path_does_not_gain_automatic_payload_echo() {
|
||||
let decoder = BoundedDecoder::new();
|
||||
let mut payload = std::vec![MALFORMED_OPCODE];
|
||||
payload.extend_from_slice(HOSTILE_MARKER.as_bytes());
|
||||
let instruction = ksp_program_api::ProgramInstruction::try_new(decoder.program_ids[0], std::vec![], payload);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &instruction);
|
||||
assert!(outcome.is_err());
|
||||
let error = match outcome {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code().domain(), "program_test");
|
||||
assert_eq!(error.code().code(), "malformed_instruction");
|
||||
assert!(!std::format!("{error}").contains(HOSTILE_MARKER));
|
||||
assert!(!std::format!("{error:?}").contains(HOSTILE_MARKER));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_associated_decoded_type_keeps_no_implicit_debug_clone_send_or_sync_bound() {
|
||||
let decoder = BoundlessOutputDecoder;
|
||||
let instruction = ksp_program_api::ProgramInstruction::try_new(ksp_program_api::Pubkey::new_from_array([0xD3_u8; 32]), std::vec![], std::vec![]);
|
||||
assert!(instruction.is_ok());
|
||||
let instruction = match instruction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let outcome = ksp_program_api::ProgramInstructionDecoder::decode(&decoder, &instruction);
|
||||
assert!(outcome.is_ok());
|
||||
let outcome = match outcome {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(std::format!("{outcome:?}"), "Decoded");
|
||||
let decoded = match outcome {
|
||||
ksp_program_api::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||
_ => return,
|
||||
};
|
||||
assert_eq!(decoded.marker.get(), 0x5A_u8);
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// file: crates/ksp-program-api/unit_tests/program_instruction_decode.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn recognition_variants_are_distinct_and_payload_free() {
|
||||
assert_ne!(crate::ProgramInstructionRecognition::NoMatch, crate::ProgramInstructionRecognition::ProgramMatch);
|
||||
assert_ne!(crate::ProgramInstructionRecognition::ProgramMatch, crate::ProgramInstructionRecognition::ExactMatch);
|
||||
assert_eq!(std::format!("{:?}", crate::ProgramInstructionRecognition::NoMatch), "NoMatch");
|
||||
assert_eq!(std::format!("{:?}", crate::ProgramInstructionRecognition::ProgramMatch), "ProgramMatch");
|
||||
assert_eq!(std::format!("{:?}", crate::ProgramInstructionRecognition::ExactMatch), "ExactMatch");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_outcome_preserves_decoded_value_and_unsupported_state() {
|
||||
let decoded = crate::ProgramInstructionDecodeOutcome::Decoded(17_u64);
|
||||
let decoded_value = match decoded {
|
||||
crate::ProgramInstructionDecodeOutcome::Decoded(value) => value,
|
||||
crate::ProgramInstructionDecodeOutcome::Unsupported => 0_u64,
|
||||
};
|
||||
assert_eq!(decoded_value, 17_u64);
|
||||
let unsupported = crate::ProgramInstructionDecodeOutcome::<u64>::Unsupported;
|
||||
assert!(matches!(unsupported, crate::ProgramInstructionDecodeOutcome::Unsupported));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_outcome_debug_never_requires_or_renders_decoded_debug() {
|
||||
struct ExternalDecoded {
|
||||
secret_marker: u8,
|
||||
}
|
||||
let decoded = crate::ProgramInstructionDecodeOutcome::Decoded(ExternalDecoded { secret_marker: 0xA7_u8 });
|
||||
assert_eq!(std::format!("{decoded:?}"), "Decoded");
|
||||
let secret_marker = match decoded {
|
||||
crate::ProgramInstructionDecodeOutcome::Decoded(value) => value.secret_marker,
|
||||
crate::ProgramInstructionDecodeOutcome::Unsupported => 0_u8,
|
||||
};
|
||||
assert_eq!(secret_marker, 0xA7_u8);
|
||||
assert_eq!(std::format!("{:?}", crate::ProgramInstructionDecodeOutcome::<ExternalDecoded>::Unsupported), "Unsupported");
|
||||
return;
|
||||
}
|
||||
14
crates/ksp-store-api/Cargo.toml
Normal file
14
crates/ksp-store-api/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
# file: crates/ksp-store-api/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "ksp-store-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
19
crates/ksp-store-api/src/capability.rs
Normal file
19
crates/ksp-store-api/src/capability.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// file: crates/ksp-store-api/src/capability.rs
|
||||
// version: 3
|
||||
|
||||
//! Private home for backend-agnostic Store capability contracts.
|
||||
//!
|
||||
//! Capabilities are split by persistent family and operation direction so a
|
||||
//! backend can implement only the contracts it actually supports. The runtime
|
||||
//! Store facade, backend selection and concrete database implementations remain
|
||||
//! outside `ksp-store-api`.
|
||||
|
||||
pub(crate) mod raw_account;
|
||||
pub(crate) mod raw_retention;
|
||||
pub(crate) mod raw_transaction;
|
||||
|
||||
/// Boxed async operation returned by object-safe Store capability contracts.
|
||||
///
|
||||
/// The alias uses only standard-library primitives so backend implementations
|
||||
/// need no async helper dependency merely to implement `ksp-store-api`.
|
||||
pub type StoreApiFuture<'a, T> = std::pin::Pin<std::boxed::Box<dyn std::future::Future<Output = T> + std::marker::Send + 'a>>;
|
||||
61
crates/ksp-store-api/src/capability/raw_account.rs
Normal file
61
crates/ksp-store-api/src/capability/raw_account.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
// file: crates/ksp-store-api/src/capability/raw_account.rs
|
||||
// version: 2
|
||||
|
||||
/// Read capability for complete canonical RAW account states.
|
||||
///
|
||||
/// Implementations must return the common Store model without leaking backend
|
||||
/// rows, SQL handles or acquisition transport types. Absence is represented by
|
||||
/// `None`; backend/runtime failures use the common KSP error contract.
|
||||
pub trait RawAccountStateRead: std::marker::Send + std::marker::Sync {
|
||||
/// Reads one complete canonical RAW account state by durable reference.
|
||||
fn get_raw_account_state<'a>(
|
||||
&'a self,
|
||||
reference: &'a crate::RawAccountStateReference,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawAccountState>>>;
|
||||
|
||||
/// Lists deterministic account-state references using an opaque backend cursor.
|
||||
///
|
||||
/// The backend must not invent a KSP policy ceiling below the caller's
|
||||
/// request. A real backend/physical limitation may determine the page that
|
||||
/// can be served and continuation is represented by the opaque cursor.
|
||||
fn list_raw_account_states<'a>(
|
||||
&'a self,
|
||||
query: &'a crate::RawAccountStateQuery,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawPage<crate::RawAccountStateReference>>>;
|
||||
}
|
||||
|
||||
/// Write capability for complete RAW account-state acquisitions.
|
||||
///
|
||||
/// The account state and its acquisition observation form one logical
|
||||
/// persistence operation. An implementation must not leave one side durable if
|
||||
/// the other side fails. Divergent content for one logical identity is a stable
|
||||
/// conflict error, never a silent overwrite.
|
||||
pub trait RawAccountStateWrite: std::marker::Send + std::marker::Sync {
|
||||
/// Persists one complete RAW account state together with one observation atomically.
|
||||
fn persist_raw_account_acquisition<'a>(
|
||||
&'a self,
|
||||
state: crate::RawAccountState,
|
||||
observation: crate::RawAccountObservation,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawAcquisitionWriteOutcome>>;
|
||||
}
|
||||
|
||||
/// Read capability for persisted RAW account-state observations.
|
||||
pub trait RawAccountObservationRead: std::marker::Send + std::marker::Sync {
|
||||
/// Reads one account observation by deterministic producer-owned idempotence key.
|
||||
fn get_raw_account_observation<'a>(
|
||||
&'a self,
|
||||
observation_key: &'a crate::RawObservationKey,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawAccountObservation>>>;
|
||||
}
|
||||
|
||||
/// Write capability for an additional observation of an already persisted RAW account state.
|
||||
///
|
||||
/// This capability allows repeated HTTP/WS/gRPC acquisitions to be retained
|
||||
/// without resubmitting account bytes. The referenced state must already exist.
|
||||
pub trait RawAccountObservationWrite: std::marker::Send + std::marker::Sync {
|
||||
/// Persists one additional acquisition observation for an existing RAW account state.
|
||||
fn record_raw_account_observation<'a>(
|
||||
&'a self,
|
||||
observation: crate::RawAccountObservation,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawObservationWriteOutcome>>;
|
||||
}
|
||||
33
crates/ksp-store-api/src/capability/raw_retention.rs
Normal file
33
crates/ksp-store-api/src/capability/raw_retention.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
// file: crates/ksp-store-api/src/capability/raw_retention.rs
|
||||
// version: 2
|
||||
|
||||
/// Read capability for canonical RAW transaction retention metadata.
|
||||
pub trait RawTransactionRetentionRead: std::marker::Send + std::marker::Sync {
|
||||
/// Reads the logical retention state for one known transaction identity.
|
||||
fn get_raw_transaction_retention_state<'a>(
|
||||
&'a self,
|
||||
reference: &'a crate::RawTransactionReference,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawRetentionState>>>;
|
||||
|
||||
/// Reads the minimal durable tombstone when the transaction payload was purged.
|
||||
fn get_raw_transaction_tombstone<'a>(
|
||||
&'a self,
|
||||
reference: &'a crate::RawTransactionReference,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawTransactionTombstone>>>;
|
||||
}
|
||||
|
||||
/// Write capability for policy-authorized RAW transaction retention transitions.
|
||||
///
|
||||
/// This contract applies a transition selected by an external worker/job policy;
|
||||
/// the Store backend never decides by itself that higher processing is complete.
|
||||
/// Implementations must compare the persisted state with `transition.expected()`
|
||||
/// atomically with the update. A concurrent state change must return
|
||||
/// [`crate::RawRetentionWriteOutcome::ExpectedStateMismatch`] unless the entity
|
||||
/// is already at the requested target.
|
||||
pub trait RawTransactionRetentionWrite: std::marker::Send + std::marker::Sync {
|
||||
/// Applies one atomic forward lifecycle transition chosen by the caller.
|
||||
fn transition_raw_transaction_retention<'a>(
|
||||
&'a self,
|
||||
transition: crate::RawTransactionRetentionTransition,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawRetentionWriteOutcome>>;
|
||||
}
|
||||
63
crates/ksp-store-api/src/capability/raw_transaction.rs
Normal file
63
crates/ksp-store-api/src/capability/raw_transaction.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
// file: crates/ksp-store-api/src/capability/raw_transaction.rs
|
||||
// version: 2
|
||||
|
||||
/// Read capability for canonical RAW transactions.
|
||||
///
|
||||
/// Implementations must return the canonical Store model without exposing
|
||||
/// backend rows, SQL handles or transport-specific DTOs. Absence is represented
|
||||
/// by `None`; backend/runtime failures use the common KSP error contract.
|
||||
pub trait RawTransactionRead: std::marker::Send + std::marker::Sync {
|
||||
/// Reads one canonical RAW transaction by durable backend-independent reference.
|
||||
fn get_raw_transaction<'a>(
|
||||
&'a self,
|
||||
reference: &'a crate::RawTransactionReference,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawTransaction>>>;
|
||||
|
||||
/// Lists deterministic transaction references using an opaque backend cursor.
|
||||
///
|
||||
/// The backend must honor the caller-requested page limit unless a real
|
||||
/// backend/physical limitation prevents it. `ksp-store-api` imposes no
|
||||
/// arbitrary smaller result ceiling or executor policy.
|
||||
fn list_raw_transactions<'a>(
|
||||
&'a self,
|
||||
query: &'a crate::RawTransactionQuery,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawPage<crate::RawTransactionReference>>>;
|
||||
}
|
||||
|
||||
/// Write capability for canonical RAW transaction acquisitions.
|
||||
///
|
||||
/// The transaction and its acquisition observation form one logical persistence
|
||||
/// operation. An implementation must not leave one side durable if the other
|
||||
/// side fails. Divergent content for one logical identity is a stable conflict
|
||||
/// error, never a silent overwrite.
|
||||
pub trait RawTransactionWrite: std::marker::Send + std::marker::Sync {
|
||||
/// Persists one complete RAW transaction together with one observation atomically.
|
||||
fn persist_raw_transaction_acquisition<'a>(
|
||||
&'a self,
|
||||
transaction: crate::RawTransaction,
|
||||
observation: crate::RawTransactionObservation,
|
||||
mode: crate::RawTransactionAcquisitionMode,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawAcquisitionWriteOutcome>>;
|
||||
}
|
||||
|
||||
/// Read capability for persisted RAW transaction observations.
|
||||
pub trait RawTransactionObservationRead: std::marker::Send + std::marker::Sync {
|
||||
/// Reads one transaction observation by deterministic producer-owned idempotence key.
|
||||
fn get_raw_transaction_observation<'a>(
|
||||
&'a self,
|
||||
observation_key: &'a crate::RawObservationKey,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<std::option::Option<crate::RawTransactionObservation>>>;
|
||||
}
|
||||
|
||||
/// Write capability for an additional observation of an already persisted RAW transaction.
|
||||
///
|
||||
/// This capability exists so repeated acquisitions can be recorded without
|
||||
/// resubmitting the potentially large canonical transaction payload. The
|
||||
/// referenced transaction must already exist.
|
||||
pub trait RawTransactionObservationWrite: std::marker::Send + std::marker::Sync {
|
||||
/// Persists one additional acquisition observation for an existing RAW transaction.
|
||||
fn record_raw_transaction_observation<'a>(
|
||||
&'a self,
|
||||
observation: crate::RawTransactionObservation,
|
||||
) -> crate::StoreApiFuture<'a, crate::Result<crate::RawObservationWriteOutcome>>;
|
||||
}
|
||||
15
crates/ksp-store-api/src/error.rs
Normal file
15
crates/ksp-store-api/src/error.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// file: crates/ksp-store-api/src/error.rs
|
||||
// version: 2
|
||||
|
||||
/// Error code used when a RAW write collides with divergent content for the same logical identity.
|
||||
pub const ERROR_CODE_RAW_CONFLICT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_conflict");
|
||||
/// Error code used when a RAW Store model violates one of its backend-agnostic invariants.
|
||||
pub const ERROR_CODE_RAW_MODEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_model_invalid");
|
||||
/// Error code used when a KSP-owned RAW persistence payload violates its format or admission contract.
|
||||
pub const ERROR_CODE_RAW_PAYLOAD_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_payload_invalid");
|
||||
/// Error code used when acquisition provenance is malformed, unsafe or internally inconsistent.
|
||||
pub const ERROR_CODE_RAW_PROVENANCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_provenance_invalid");
|
||||
/// Error code used when one RAW query or cursor violates backend-agnostic query invariants.
|
||||
pub const ERROR_CODE_RAW_QUERY_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_query_invalid");
|
||||
/// Error code used when a RAW retention transition violates the logical lifecycle contract.
|
||||
pub const ERROR_CODE_RAW_RETENTION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("store_api", "raw_retention_invalid");
|
||||
142
crates/ksp-store-api/src/lib.rs
Normal file
142
crates/ksp-store-api/src/lib.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
// file: crates/ksp-store-api/src/lib.rs
|
||||
// version: 5
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Backend-agnostic persistence contracts for KSP Store implementations.
|
||||
//!
|
||||
//! `ksp-store-api` owns persistent models plus the contracts that operate on
|
||||
//! them. The `0.3.1` release is limited to N1 RAW/acquisition data. N2
|
||||
//! STRUCTURAL is a later, distinct layer and no Program decode belongs to this
|
||||
//! crate.
|
||||
//!
|
||||
//! Models and capabilities deliberately have separate private module homes.
|
||||
//! Backend implementations, SQL, migrations, Config, Transport and runtime
|
||||
//! dispatch remain outside this crate.
|
||||
|
||||
mod capability;
|
||||
mod error;
|
||||
mod model;
|
||||
|
||||
/// Boxed async operation returned by object-safe Store capability contracts.
|
||||
pub use self::capability::StoreApiFuture;
|
||||
/// Read capability for persisted RAW account-state observations.
|
||||
pub use self::capability::raw_account::RawAccountObservationRead;
|
||||
/// Write capability for additional observations of already persisted RAW account states.
|
||||
pub use self::capability::raw_account::RawAccountObservationWrite;
|
||||
/// Read capability for complete canonical RAW account states.
|
||||
pub use self::capability::raw_account::RawAccountStateRead;
|
||||
/// Write capability for complete canonical RAW account-state acquisitions.
|
||||
pub use self::capability::raw_account::RawAccountStateWrite;
|
||||
/// Read capability for canonical RAW transaction retention metadata.
|
||||
pub use self::capability::raw_retention::RawTransactionRetentionRead;
|
||||
/// Write capability for policy-authorized RAW transaction retention transitions.
|
||||
pub use self::capability::raw_retention::RawTransactionRetentionWrite;
|
||||
/// Read capability for persisted RAW transaction observations.
|
||||
pub use self::capability::raw_transaction::RawTransactionObservationRead;
|
||||
/// Write capability for additional observations of already persisted RAW transactions.
|
||||
pub use self::capability::raw_transaction::RawTransactionObservationWrite;
|
||||
/// Read capability for canonical RAW transactions.
|
||||
pub use self::capability::raw_transaction::RawTransactionRead;
|
||||
/// Write capability for canonical RAW transaction acquisitions.
|
||||
pub use self::capability::raw_transaction::RawTransactionWrite;
|
||||
/// Error code used when a RAW write collides with divergent content for the same logical identity.
|
||||
pub use self::error::ERROR_CODE_RAW_CONFLICT;
|
||||
/// Error code used when a RAW Store model violates one of its backend-agnostic invariants.
|
||||
pub use self::error::ERROR_CODE_RAW_MODEL_INVALID;
|
||||
/// Error code used when a KSP-owned RAW persistence payload violates its format or admission contract.
|
||||
pub use self::error::ERROR_CODE_RAW_PAYLOAD_INVALID;
|
||||
/// Error code used when acquisition provenance is malformed, unsafe or internally inconsistent.
|
||||
pub use self::error::ERROR_CODE_RAW_PROVENANCE_INVALID;
|
||||
/// Error code used when one RAW query or cursor violates backend-agnostic query invariants.
|
||||
pub use self::error::ERROR_CODE_RAW_QUERY_INVALID;
|
||||
/// Error code used when a RAW retention transition violates the logical lifecycle contract.
|
||||
pub use self::error::ERROR_CODE_RAW_RETENTION_INVALID;
|
||||
/// Persistable acquisition observation linked to one complete canonical RAW account state.
|
||||
pub use self::model::raw_account::RawAccountObservation;
|
||||
/// Canonical complete N1 RAW account state independent from acquisition transport.
|
||||
pub use self::model::raw_account::RawAccountState;
|
||||
/// Durable backend-independent identity of one canonical RAW account state.
|
||||
pub use self::model::raw_account::RawAccountStateReference;
|
||||
/// Combined outcome of one atomic canonical RAW entity plus observation acquisition.
|
||||
pub use self::model::raw_outcome::RawAcquisitionWriteOutcome;
|
||||
/// Outcome for one canonical RAW entity in an idempotent persistence operation.
|
||||
pub use self::model::raw_outcome::RawEntityWriteOutcome;
|
||||
/// Outcome for one deterministic acquisition observation write.
|
||||
pub use self::model::raw_outcome::RawObservationWriteOutcome;
|
||||
/// Maximum opaque query cursor length admitted by the Store API.
|
||||
pub use self::model::raw_pagination::MAX_RAW_PAGE_CURSOR_BYTES;
|
||||
/// Backend-independent list query for complete canonical RAW account states.
|
||||
pub use self::model::raw_pagination::RawAccountStateQuery;
|
||||
/// One deterministic page of backend-independent Store results.
|
||||
pub use self::model::raw_pagination::RawPage;
|
||||
/// Opaque backend-owned cursor returned by one deterministic Store query.
|
||||
pub use self::model::raw_pagination::RawPageCursor;
|
||||
/// Caller-requested page size without an arbitrary KSP policy ceiling.
|
||||
pub use self::model::raw_pagination::RawPageLimit;
|
||||
/// Opaque-cursor page request used by backend-independent list operations.
|
||||
pub use self::model::raw_pagination::RawPageRequest;
|
||||
/// Optional inclusive Solana slot bounds for one Store query.
|
||||
pub use self::model::raw_pagination::RawSlotRange;
|
||||
/// Deterministic traversal direction for Store list queries.
|
||||
pub use self::model::raw_pagination::RawSortDirection;
|
||||
/// Backend-independent list query for canonical RAW transactions.
|
||||
pub use self::model::raw_pagination::RawTransactionQuery;
|
||||
/// Maximum complete RAW account-data length admitted by the Store API.
|
||||
pub use self::model::raw_primitives::MAX_RAW_ACCOUNT_DATA_BYTES;
|
||||
/// Maximum UTF-8 byte length accepted for one safe logical RAW/provenance code.
|
||||
pub use self::model::raw_primitives::MAX_RAW_CODE_BYTES;
|
||||
/// Maximum KSP-owned canonical RAW payload admitted by the Store API.
|
||||
pub use self::model::raw_primitives::MAX_RAW_PAYLOAD_BYTES;
|
||||
/// Maximum source-wire payload size recorded as acquisition metadata.
|
||||
pub use self::model::raw_primitives::MAX_RAW_SOURCE_PAYLOAD_BYTES;
|
||||
/// Maximum supported Unix millisecond timestamp.
|
||||
pub use self::model::raw_primitives::MAX_RAW_UNIX_MILLIS;
|
||||
/// Origin category describing why one acquisition was performed.
|
||||
pub use self::model::raw_primitives::RawAcquisitionOrigin;
|
||||
/// Safe source-independent acquisition provenance attached to one persisted observation.
|
||||
pub use self::model::raw_primitives::RawAcquisitionProvenance;
|
||||
/// Fixed-size digest identifying canonical or source bytes without retaining them.
|
||||
pub use self::model::raw_primitives::RawContentHash;
|
||||
/// Bounded identifier of one KSP-owned source-independent RAW persistence format.
|
||||
pub use self::model::raw_primitives::RawFormatId;
|
||||
/// Bounded logical network/cluster identifier used in backend-independent Store identities.
|
||||
pub use self::model::raw_primitives::RawNetworkId;
|
||||
/// Stable deterministic idempotence key for one persisted acquisition observation.
|
||||
pub use self::model::raw_primitives::RawObservationKey;
|
||||
/// Bounded source-independent KSP RAW persistence payload.
|
||||
pub use self::model::raw_primitives::RawPayload;
|
||||
/// Bounded logical code used by acquisition provenance fields.
|
||||
pub use self::model::raw_primitives::RawProvenanceCode;
|
||||
/// Bounded UTC timestamp represented as whole milliseconds since Unix epoch.
|
||||
pub use self::model::raw_primitives::RawTimestamp;
|
||||
/// Canonical 64-byte Solana transaction signature used by Store identities.
|
||||
pub use self::model::raw_primitives::RawTransactionSignature;
|
||||
/// Logical availability state of one canonical RAW payload.
|
||||
pub use self::model::raw_retention::RawRetentionState;
|
||||
/// Outcome of one atomic RAW retention transition.
|
||||
pub use self::model::raw_retention::RawRetentionWriteOutcome;
|
||||
/// Explicit write mode for canonical RAW transaction acquisitions.
|
||||
pub use self::model::raw_retention::RawTransactionAcquisitionMode;
|
||||
/// Requested compare-and-transition operation for one RAW transaction retention state.
|
||||
pub use self::model::raw_retention::RawTransactionRetentionTransition;
|
||||
/// Minimal durable identity retained after a canonical RAW transaction payload is purged.
|
||||
pub use self::model::raw_retention::RawTransactionTombstone;
|
||||
/// Canonical source-independent N1 RAW transaction persisted by Store backends.
|
||||
pub use self::model::raw_transaction::RawTransaction;
|
||||
/// Persistable acquisition observation linked to one canonical RAW transaction.
|
||||
pub use self::model::raw_transaction::RawTransactionObservation;
|
||||
/// Durable backend-independent identity of one canonical RAW transaction.
|
||||
pub use self::model::raw_transaction::RawTransactionReference;
|
||||
/// Common KSP error type used by Store-facing contracts.
|
||||
pub use ksp_core_lib::Error;
|
||||
/// Stable structured code identifying a KSP error category and condition.
|
||||
pub use ksp_core_lib::ErrorCode;
|
||||
/// Structured contextual field attached to a KSP error.
|
||||
pub use ksp_core_lib::ErrorContext;
|
||||
/// Canonical Solana account address primitive shared by persistent models.
|
||||
pub use ksp_core_lib::Pubkey;
|
||||
/// Common KSP result alias using [`Error`].
|
||||
pub use ksp_core_lib::Result;
|
||||
16
crates/ksp-store-api/src/model.rs
Normal file
16
crates/ksp-store-api/src/model.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// file: crates/ksp-store-api/src/model.rs
|
||||
// version: 4
|
||||
|
||||
//! Private home for persistent Store models.
|
||||
//!
|
||||
//! The current release is N1 RAW-only. Future N2 STRUCTURAL models are a
|
||||
//! separate data layer and are not introduced here. Persistent models and
|
||||
//! backend capabilities remain separate even when one capability operates on
|
||||
//! one or more models.
|
||||
|
||||
pub(crate) mod raw_account;
|
||||
pub(crate) mod raw_outcome;
|
||||
pub(crate) mod raw_pagination;
|
||||
pub(crate) mod raw_primitives;
|
||||
pub(crate) mod raw_retention;
|
||||
pub(crate) mod raw_transaction;
|
||||
231
crates/ksp-store-api/src/model/raw_account.rs
Normal file
231
crates/ksp-store-api/src/model/raw_account.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
// file: crates/ksp-store-api/src/model/raw_account.rs
|
||||
// version: 1
|
||||
|
||||
/// Durable backend-independent identity of one canonical RAW account state.
|
||||
///
|
||||
/// The content hash is part of the identity because one account can be written more than once
|
||||
/// inside the same slot while standard HTTP/WebSocket surfaces do not expose Yellowstone's
|
||||
/// `write_version`. Multiple observations of the same complete state therefore converge on the
|
||||
/// same reference without making a provider-specific write ordinal part of the common model.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct RawAccountStateReference {
|
||||
network: crate::RawNetworkId,
|
||||
pubkey: ksp_core_lib::Pubkey,
|
||||
slot: u64,
|
||||
state_hash: crate::RawContentHash,
|
||||
}
|
||||
|
||||
impl RawAccountStateReference {
|
||||
/// Creates one durable account-state identity from network, account, slot and canonical state digest.
|
||||
#[must_use]
|
||||
pub fn new(network: crate::RawNetworkId, pubkey: ksp_core_lib::Pubkey, slot: u64, state_hash: crate::RawContentHash) -> Self {
|
||||
return Self { network, pubkey, slot, state_hash };
|
||||
}
|
||||
|
||||
/// Returns the logical Solana network/cluster identifier.
|
||||
#[must_use]
|
||||
pub fn network(&self) -> &crate::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns the account public key.
|
||||
#[must_use]
|
||||
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.pubkey;
|
||||
}
|
||||
|
||||
/// Returns the slot associated with this complete account state.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the producer-supplied digest of the complete canonical account state.
|
||||
#[must_use]
|
||||
pub const fn state_hash(&self) -> crate::RawContentHash {
|
||||
return self.state_hash;
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical complete N1 RAW account state independent from HTTP, WebSocket or gRPC acquisition.
|
||||
///
|
||||
/// Only complete raw account bytes are admissible. A transport response using `jsonParsed`, a
|
||||
/// request-side data slice, or a response without a durable slot context must be normalized or
|
||||
/// reacquired before this model is constructed.
|
||||
pub struct RawAccountState {
|
||||
data: std::boxed::Box<[u8]>,
|
||||
executable: bool,
|
||||
lamports: u64,
|
||||
owner: ksp_core_lib::Pubkey,
|
||||
reference: crate::RawAccountStateReference,
|
||||
rent_epoch: u64,
|
||||
}
|
||||
|
||||
impl RawAccountState {
|
||||
/// Creates one complete canonical RAW account state after Store-owned admission checks.
|
||||
pub fn try_new(
|
||||
reference: crate::RawAccountStateReference,
|
||||
lamports: u64,
|
||||
owner: ksp_core_lib::Pubkey,
|
||||
executable: bool,
|
||||
rent_epoch: u64,
|
||||
data: std::boxed::Box<[u8]>,
|
||||
) -> ksp_core_lib::Result<Self> {
|
||||
if data.len() > crate::MAX_RAW_ACCOUNT_DATA_BYTES {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_MODEL_INVALID, "invalid backend-agnostic RAW Store model")
|
||||
.with_context("field", "account_data")
|
||||
.with_context("actual_len", data.len().to_string())
|
||||
.with_context("maximum_len", crate::MAX_RAW_ACCOUNT_DATA_BYTES.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { data, executable, lamports, owner, reference, rent_epoch });
|
||||
}
|
||||
|
||||
/// Returns the exact complete account bytes used by future decoders.
|
||||
#[must_use]
|
||||
pub fn data(&self) -> &[u8] {
|
||||
return self.data.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the complete account-data length in bytes.
|
||||
#[must_use]
|
||||
pub fn data_len(&self) -> usize {
|
||||
return self.data.len();
|
||||
}
|
||||
|
||||
/// Returns whether the account is executable.
|
||||
#[must_use]
|
||||
pub const fn executable(&self) -> bool {
|
||||
return self.executable;
|
||||
}
|
||||
|
||||
/// Returns the account lamport balance.
|
||||
#[must_use]
|
||||
pub const fn lamports(&self) -> u64 {
|
||||
return self.lamports;
|
||||
}
|
||||
|
||||
/// Returns the account owner program public key.
|
||||
#[must_use]
|
||||
pub const fn owner(&self) -> &ksp_core_lib::Pubkey {
|
||||
return &self.owner;
|
||||
}
|
||||
|
||||
/// Returns the durable source-independent account-state identity.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &crate::RawAccountStateReference {
|
||||
return &self.reference;
|
||||
}
|
||||
|
||||
/// Returns the rent epoch reported for this account state.
|
||||
#[must_use]
|
||||
pub const fn rent_epoch(&self) -> u64 {
|
||||
return self.rent_epoch;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RawAccountState {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("RawAccountState")
|
||||
.field("reference", &self.reference)
|
||||
.field("lamports", &self.lamports)
|
||||
.field("owner", &self.owner)
|
||||
.field("executable", &self.executable)
|
||||
.field("rent_epoch", &self.rent_epoch)
|
||||
.field("data_len", &self.data.len())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistable acquisition observation linked to one complete canonical RAW account state.
|
||||
///
|
||||
/// Yellowstone-only metadata remains optional observation detail and never changes the canonical
|
||||
/// account state itself. HTTP/WS acquisitions therefore use the same observation type without
|
||||
/// inventing a `write_version`, transaction signature or startup flag.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawAccountObservation {
|
||||
account: crate::RawAccountStateReference,
|
||||
is_startup: std::option::Option<bool>,
|
||||
observation_key: crate::RawObservationKey,
|
||||
provenance: crate::RawAcquisitionProvenance,
|
||||
transaction_signature: std::option::Option<crate::RawTransactionSignature>,
|
||||
write_version: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl RawAccountObservation {
|
||||
/// Creates one successful observation of a complete canonical RAW account state.
|
||||
#[must_use]
|
||||
pub fn new(observation_key: crate::RawObservationKey, account: crate::RawAccountStateReference, provenance: crate::RawAcquisitionProvenance) -> Self {
|
||||
return Self {
|
||||
account,
|
||||
is_startup: std::option::Option::None,
|
||||
observation_key,
|
||||
provenance,
|
||||
transaction_signature: std::option::Option::None,
|
||||
write_version: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Attaches a provider-reported startup/replay marker when the source exposes one.
|
||||
#[must_use]
|
||||
pub fn with_is_startup(mut self, value: bool) -> Self {
|
||||
self.is_startup = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches the transaction signature associated with the account write when exposed by the source.
|
||||
#[must_use]
|
||||
pub fn with_transaction_signature(mut self, value: crate::RawTransactionSignature) -> Self {
|
||||
self.transaction_signature = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches the source-specific account write version when the source exposes one.
|
||||
#[must_use]
|
||||
pub fn with_write_version(mut self, value: u64) -> Self {
|
||||
self.write_version = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Returns the durable account-state identity observed by this acquisition.
|
||||
#[must_use]
|
||||
pub fn account(&self) -> &crate::RawAccountStateReference {
|
||||
return &self.account;
|
||||
}
|
||||
|
||||
/// Returns the optional source-reported startup/replay marker.
|
||||
#[must_use]
|
||||
pub const fn is_startup(&self) -> std::option::Option<bool> {
|
||||
return self.is_startup;
|
||||
}
|
||||
|
||||
/// Returns the deterministic producer-owned observation idempotence key.
|
||||
#[must_use]
|
||||
pub const fn observation_key(&self) -> crate::RawObservationKey {
|
||||
return self.observation_key;
|
||||
}
|
||||
|
||||
/// Returns safe source-independent acquisition provenance.
|
||||
#[must_use]
|
||||
pub fn provenance(&self) -> &crate::RawAcquisitionProvenance {
|
||||
return &self.provenance;
|
||||
}
|
||||
|
||||
/// Returns the optional transaction signature associated with this account write.
|
||||
#[must_use]
|
||||
pub const fn transaction_signature(&self) -> std::option::Option<crate::RawTransactionSignature> {
|
||||
return self.transaction_signature;
|
||||
}
|
||||
|
||||
/// Returns the optional source-specific account write version.
|
||||
#[must_use]
|
||||
pub const fn write_version(&self) -> std::option::Option<u64> {
|
||||
return self.write_version;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../unit_tests/model/raw_account.rs"]
|
||||
mod tests;
|
||||
55
crates/ksp-store-api/src/model/raw_outcome.rs
Normal file
55
crates/ksp-store-api/src/model/raw_outcome.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// file: crates/ksp-store-api/src/model/raw_outcome.rs
|
||||
// version: 1
|
||||
|
||||
/// Outcome for one canonical RAW entity in an idempotent persistence operation.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawEntityWriteOutcome {
|
||||
/// The canonical RAW entity was inserted for the first time.
|
||||
Inserted,
|
||||
/// Identical canonical content was already durable.
|
||||
AlreadyPresent,
|
||||
/// A previously purged RAW entity was explicitly rehydrated.
|
||||
Rehydrated,
|
||||
/// Normal persistence skipped a durable purged tombstone.
|
||||
SkippedPurged,
|
||||
}
|
||||
|
||||
/// Outcome for one deterministic acquisition observation write.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawObservationWriteOutcome {
|
||||
/// The observation was inserted for the first time.
|
||||
Inserted,
|
||||
/// The same observation key already identified identical durable content.
|
||||
AlreadyPresent,
|
||||
/// No observation was recorded because the associated RAW entity was intentionally skipped.
|
||||
NotRecorded,
|
||||
}
|
||||
|
||||
/// Combined outcome of one atomic canonical RAW entity plus observation acquisition.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct RawAcquisitionWriteOutcome {
|
||||
entity: crate::RawEntityWriteOutcome,
|
||||
observation: crate::RawObservationWriteOutcome,
|
||||
}
|
||||
|
||||
impl RawAcquisitionWriteOutcome {
|
||||
/// Creates one backend-independent atomic acquisition outcome.
|
||||
#[must_use]
|
||||
pub const fn new(entity: crate::RawEntityWriteOutcome, observation: crate::RawObservationWriteOutcome) -> Self {
|
||||
return Self { entity, observation };
|
||||
}
|
||||
|
||||
/// Returns the canonical RAW entity write outcome.
|
||||
#[must_use]
|
||||
pub const fn entity(&self) -> crate::RawEntityWriteOutcome {
|
||||
return self.entity;
|
||||
}
|
||||
|
||||
/// Returns the acquisition observation write outcome.
|
||||
#[must_use]
|
||||
pub const fn observation(&self) -> crate::RawObservationWriteOutcome {
|
||||
return self.observation;
|
||||
}
|
||||
}
|
||||
267
crates/ksp-store-api/src/model/raw_pagination.rs
Normal file
267
crates/ksp-store-api/src/model/raw_pagination.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
// file: crates/ksp-store-api/src/model/raw_pagination.rs
|
||||
// version: 1
|
||||
|
||||
/// Maximum opaque cursor length accepted by Store API queries.
|
||||
///
|
||||
/// This bounds untrusted token material only. It is not a policy limit on the
|
||||
/// number of rows/results a backend may return.
|
||||
pub const MAX_RAW_PAGE_CURSOR_BYTES: usize = 4 * 1024;
|
||||
|
||||
/// Opaque backend-owned cursor returned by one deterministic Store query.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct RawPageCursor(std::boxed::Box<[u8]>);
|
||||
|
||||
impl RawPageCursor {
|
||||
/// Creates one non-empty bounded opaque cursor.
|
||||
pub fn try_new(bytes: std::boxed::Box<[u8]>) -> crate::Result<Self> {
|
||||
if bytes.is_empty() || bytes.len() > crate::MAX_RAW_PAGE_CURSOR_BYTES {
|
||||
return std::result::Result::Err(raw_query_error("cursor"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(bytes));
|
||||
}
|
||||
|
||||
/// Returns the opaque cursor bytes unchanged.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
return self.0.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RawPageCursor {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("RawPageCursor").field("len", &self.0.len()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller-requested page size without an arbitrary KSP policy ceiling.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct RawPageLimit(u64);
|
||||
|
||||
impl RawPageLimit {
|
||||
/// Creates one strictly positive requested page size.
|
||||
///
|
||||
/// `ksp-store-api` deliberately imposes no smaller functional maximum.
|
||||
/// Concrete backends may expose or return their real physical limitations.
|
||||
pub fn new(value: u64) -> crate::Result<Self> {
|
||||
if value == 0 {
|
||||
return std::result::Result::Err(raw_query_error("limit"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the exact caller-requested item count.
|
||||
#[must_use]
|
||||
pub const fn get(&self) -> u64 {
|
||||
return self.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque-cursor page request used by backend-independent list operations.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawPageRequest {
|
||||
cursor: std::option::Option<crate::RawPageCursor>,
|
||||
limit: crate::RawPageLimit,
|
||||
}
|
||||
|
||||
impl RawPageRequest {
|
||||
/// Creates one first-page request.
|
||||
#[must_use]
|
||||
pub const fn first(limit: crate::RawPageLimit) -> Self {
|
||||
return Self { cursor: std::option::Option::None, limit };
|
||||
}
|
||||
|
||||
/// Creates one continuation-page request with an opaque backend cursor.
|
||||
#[must_use]
|
||||
pub const fn after(limit: crate::RawPageLimit, cursor: crate::RawPageCursor) -> Self {
|
||||
return Self { cursor: std::option::Option::Some(cursor), limit };
|
||||
}
|
||||
|
||||
/// Returns the opaque continuation cursor when present.
|
||||
#[must_use]
|
||||
pub fn cursor(&self) -> std::option::Option<&crate::RawPageCursor> {
|
||||
return self.cursor.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the exact caller-requested page size.
|
||||
#[must_use]
|
||||
pub const fn limit(&self) -> crate::RawPageLimit {
|
||||
return self.limit;
|
||||
}
|
||||
}
|
||||
|
||||
/// One deterministic page of backend-independent Store results.
|
||||
#[derive(Debug)]
|
||||
pub struct RawPage<T> {
|
||||
items: std::vec::Vec<T>,
|
||||
next_cursor: std::option::Option<crate::RawPageCursor>,
|
||||
}
|
||||
|
||||
impl<T> RawPage<T> {
|
||||
/// Creates one result page from backend-provided items and optional continuation cursor.
|
||||
#[must_use]
|
||||
pub fn new(items: std::vec::Vec<T>, next_cursor: std::option::Option<crate::RawPageCursor>) -> Self {
|
||||
return Self { items, next_cursor };
|
||||
}
|
||||
|
||||
/// Returns the current page items.
|
||||
#[must_use]
|
||||
pub fn items(&self) -> &[T] {
|
||||
return self.items.as_slice();
|
||||
}
|
||||
|
||||
/// Consumes the page and returns its items.
|
||||
#[must_use]
|
||||
pub fn into_items(self) -> std::vec::Vec<T> {
|
||||
return self.items;
|
||||
}
|
||||
|
||||
/// Returns the next opaque cursor when more results are available.
|
||||
#[must_use]
|
||||
pub fn next_cursor(&self) -> std::option::Option<&crate::RawPageCursor> {
|
||||
return self.next_cursor.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic traversal direction for Store list queries.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawSortDirection {
|
||||
/// Oldest/lower ordered keys first.
|
||||
Ascending,
|
||||
/// Newest/higher ordered keys first.
|
||||
Descending,
|
||||
}
|
||||
|
||||
/// Optional inclusive Solana slot bounds for one Store query.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct RawSlotRange {
|
||||
end_inclusive: std::option::Option<u64>,
|
||||
start_inclusive: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl RawSlotRange {
|
||||
/// Creates one optional inclusive slot range and rejects reversed bounds.
|
||||
pub fn new(start_inclusive: std::option::Option<u64>, end_inclusive: std::option::Option<u64>) -> crate::Result<Self> {
|
||||
if let (std::option::Option::Some(start), std::option::Option::Some(end)) = (start_inclusive, end_inclusive)
|
||||
&& start > end
|
||||
{
|
||||
return std::result::Result::Err(raw_query_error("slot_range"));
|
||||
}
|
||||
return std::result::Result::Ok(Self { end_inclusive, start_inclusive });
|
||||
}
|
||||
|
||||
/// Returns the optional inclusive ending slot.
|
||||
#[must_use]
|
||||
pub const fn end_inclusive(&self) -> std::option::Option<u64> {
|
||||
return self.end_inclusive;
|
||||
}
|
||||
|
||||
/// Returns the optional inclusive starting slot.
|
||||
#[must_use]
|
||||
pub const fn start_inclusive(&self) -> std::option::Option<u64> {
|
||||
return self.start_inclusive;
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-independent list query for canonical RAW transactions.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawTransactionQuery {
|
||||
direction: crate::RawSortDirection,
|
||||
network: crate::RawNetworkId,
|
||||
page: crate::RawPageRequest,
|
||||
slots: crate::RawSlotRange,
|
||||
}
|
||||
|
||||
impl RawTransactionQuery {
|
||||
/// Creates one deterministic transaction-reference query.
|
||||
#[must_use]
|
||||
pub fn new(network: crate::RawNetworkId, slots: crate::RawSlotRange, direction: crate::RawSortDirection, page: crate::RawPageRequest) -> Self {
|
||||
return Self { direction, network, page, slots };
|
||||
}
|
||||
|
||||
/// Returns the requested deterministic traversal direction.
|
||||
#[must_use]
|
||||
pub const fn direction(&self) -> crate::RawSortDirection {
|
||||
return self.direction;
|
||||
}
|
||||
|
||||
/// Returns the required logical network scope.
|
||||
#[must_use]
|
||||
pub fn network(&self) -> &crate::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns pagination inputs without exposing backend cursor contents.
|
||||
#[must_use]
|
||||
pub fn page(&self) -> &crate::RawPageRequest {
|
||||
return &self.page;
|
||||
}
|
||||
|
||||
/// Returns optional inclusive slot bounds.
|
||||
#[must_use]
|
||||
pub const fn slots(&self) -> crate::RawSlotRange {
|
||||
return self.slots;
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-independent list query for complete canonical RAW account states.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawAccountStateQuery {
|
||||
direction: crate::RawSortDirection,
|
||||
network: crate::RawNetworkId,
|
||||
page: crate::RawPageRequest,
|
||||
pubkey: std::option::Option<crate::Pubkey>,
|
||||
slots: crate::RawSlotRange,
|
||||
}
|
||||
|
||||
impl RawAccountStateQuery {
|
||||
/// Creates one deterministic account-state-reference query.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
network: crate::RawNetworkId,
|
||||
pubkey: std::option::Option<crate::Pubkey>,
|
||||
slots: crate::RawSlotRange,
|
||||
direction: crate::RawSortDirection,
|
||||
page: crate::RawPageRequest,
|
||||
) -> Self {
|
||||
return Self { direction, network, page, pubkey, slots };
|
||||
}
|
||||
|
||||
/// Returns the requested deterministic traversal direction.
|
||||
#[must_use]
|
||||
pub const fn direction(&self) -> crate::RawSortDirection {
|
||||
return self.direction;
|
||||
}
|
||||
|
||||
/// Returns the required logical network scope.
|
||||
#[must_use]
|
||||
pub fn network(&self) -> &crate::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns pagination inputs without exposing backend cursor contents.
|
||||
#[must_use]
|
||||
pub fn page(&self) -> &crate::RawPageRequest {
|
||||
return &self.page;
|
||||
}
|
||||
|
||||
/// Returns an optional account-address restriction.
|
||||
#[must_use]
|
||||
pub fn pubkey(&self) -> std::option::Option<&crate::Pubkey> {
|
||||
return self.pubkey.as_ref();
|
||||
}
|
||||
|
||||
/// Returns optional inclusive slot bounds.
|
||||
#[must_use]
|
||||
pub const fn slots(&self) -> crate::RawSlotRange {
|
||||
return self.slots;
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_query_error(field: &'static str) -> crate::Error {
|
||||
return crate::Error::new(crate::ERROR_CODE_RAW_QUERY_INVALID, "invalid backend-agnostic RAW Store query").with_context("field", field);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../unit_tests/model/raw_pagination.rs"]
|
||||
mod tests;
|
||||
453
crates/ksp-store-api/src/model/raw_primitives.rs
Normal file
453
crates/ksp-store-api/src/model/raw_primitives.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
// file: crates/ksp-store-api/src/model/raw_primitives.rs
|
||||
// version: 2
|
||||
|
||||
/// Maximum complete RAW account-data length admitted by the Store API.
|
||||
///
|
||||
/// This is a Store admission guard, not a Solana protocol-size claim.
|
||||
pub const MAX_RAW_ACCOUNT_DATA_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Maximum UTF-8 byte length accepted for one safe logical RAW/provenance code.
|
||||
pub const MAX_RAW_CODE_BYTES: usize = 128;
|
||||
/// Maximum KSP-owned canonical RAW payload admitted by the Store API.
|
||||
///
|
||||
/// This is a Store admission guard, not a Solana protocol-size claim.
|
||||
pub const MAX_RAW_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Maximum source-wire payload size recorded as acquisition metadata.
|
||||
///
|
||||
/// The source payload itself is never retained by this metadata field.
|
||||
pub const MAX_RAW_SOURCE_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Maximum supported Unix millisecond timestamp (`9999-12-31T23:59:59.999Z`).
|
||||
pub const MAX_RAW_UNIX_MILLIS: u64 = 253_402_300_799_999;
|
||||
|
||||
/// Fixed-size digest identifying canonical or source bytes without retaining them.
|
||||
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
|
||||
pub struct RawContentHash([u8; 32]);
|
||||
|
||||
impl RawContentHash {
|
||||
/// Creates one opaque 32-byte KSP content digest.
|
||||
#[must_use]
|
||||
pub const fn new(bytes: [u8; 32]) -> Self {
|
||||
return Self(bytes);
|
||||
}
|
||||
|
||||
/// Returns the exact digest bytes.
|
||||
#[must_use]
|
||||
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||
return &self.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RawContentHash {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("RawContentHash(..)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable deterministic idempotence key for one persisted acquisition observation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct RawObservationKey([u8; 32]);
|
||||
|
||||
impl RawObservationKey {
|
||||
/// Creates one producer-owned deterministic observation key.
|
||||
#[must_use]
|
||||
pub const fn new(bytes: [u8; 32]) -> Self {
|
||||
return Self(bytes);
|
||||
}
|
||||
|
||||
/// Returns the exact observation-key bytes.
|
||||
#[must_use]
|
||||
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||
return &self.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical 64-byte Solana transaction signature used by Store identities.
|
||||
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct RawTransactionSignature([u8; 64]);
|
||||
|
||||
impl RawTransactionSignature {
|
||||
/// Creates one canonical signature from already-decoded Solana signature bytes.
|
||||
#[must_use]
|
||||
pub const fn new(bytes: [u8; 64]) -> Self {
|
||||
return Self(bytes);
|
||||
}
|
||||
|
||||
/// Returns the exact signature bytes.
|
||||
#[must_use]
|
||||
pub const fn as_bytes(&self) -> &[u8; 64] {
|
||||
return &self.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RawTransactionSignature {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("RawTransactionSignature(..)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded logical network/cluster identifier used in backend-independent Store identities.
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct RawNetworkId(std::string::String);
|
||||
|
||||
impl RawNetworkId {
|
||||
/// Creates one safe non-empty network/cluster identifier.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_raw_code(value.as_str()) {
|
||||
return std::result::Result::Err(raw_model_error("network"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the network/cluster identifier.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded logical code used by acquisition provenance fields.
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct RawProvenanceCode(std::string::String);
|
||||
|
||||
impl RawProvenanceCode {
|
||||
/// Creates one safe non-empty provenance code.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_raw_code(value.as_str()) {
|
||||
return std::result::Result::Err(raw_provenance_error("code"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the validated provenance code.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded identifier of one KSP-owned source-independent RAW persistence format.
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct RawFormatId(std::string::String);
|
||||
|
||||
impl RawFormatId {
|
||||
/// Creates one safe non-empty RAW format identifier.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_raw_code(value.as_str()) {
|
||||
return std::result::Result::Err(raw_payload_error("format_id"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the KSP-owned RAW format identifier.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded UTC timestamp represented as whole milliseconds since Unix epoch.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct RawTimestamp {
|
||||
unix_millis: u64,
|
||||
}
|
||||
|
||||
impl RawTimestamp {
|
||||
/// Creates a bounded UTC timestamp from Unix milliseconds.
|
||||
pub fn from_unix_millis(unix_millis: u64) -> ksp_core_lib::Result<Self> {
|
||||
if unix_millis > crate::MAX_RAW_UNIX_MILLIS {
|
||||
return std::result::Result::Err(raw_model_error("timestamp"));
|
||||
}
|
||||
return std::result::Result::Ok(Self { unix_millis });
|
||||
}
|
||||
|
||||
/// Returns whole milliseconds since Unix epoch.
|
||||
#[must_use]
|
||||
pub const fn unix_millis(&self) -> u64 {
|
||||
return self.unix_millis;
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin category describing why one acquisition was performed.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawAcquisitionOrigin {
|
||||
/// Historical acquisition intended to fill missing durable data.
|
||||
Backfill,
|
||||
/// Explicit import from a non-live source controlled by the caller.
|
||||
Import,
|
||||
/// Live acquisition from a currently active transport/session.
|
||||
Live,
|
||||
/// Explicit repair or reconciliation of previously known data.
|
||||
Repair,
|
||||
/// Explicit replay of an already-known source or archived acquisition.
|
||||
Replay,
|
||||
}
|
||||
|
||||
/// Safe source-independent acquisition provenance attached to one persisted observation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawAcquisitionProvenance {
|
||||
acquisition_method: crate::RawProvenanceCode,
|
||||
capture_session_id: std::option::Option<crate::RawProvenanceCode>,
|
||||
commitment: std::option::Option<crate::RawProvenanceCode>,
|
||||
endpoint_id: std::option::Option<crate::RawProvenanceCode>,
|
||||
filter_id: std::option::Option<crate::RawProvenanceCode>,
|
||||
observed_at: std::option::Option<crate::RawTimestamp>,
|
||||
origin: crate::RawAcquisitionOrigin,
|
||||
protocol: crate::RawProvenanceCode,
|
||||
provider: crate::RawProvenanceCode,
|
||||
received_at: crate::RawTimestamp,
|
||||
source_payload_hash: std::option::Option<crate::RawContentHash>,
|
||||
source_payload_size_bytes: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl RawAcquisitionProvenance {
|
||||
/// Creates one successful acquisition provenance record with only mandatory safe metadata.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
provider: crate::RawProvenanceCode,
|
||||
protocol: crate::RawProvenanceCode,
|
||||
acquisition_method: crate::RawProvenanceCode,
|
||||
origin: crate::RawAcquisitionOrigin,
|
||||
received_at: crate::RawTimestamp,
|
||||
) -> Self {
|
||||
return Self {
|
||||
acquisition_method,
|
||||
capture_session_id: std::option::Option::None,
|
||||
commitment: std::option::Option::None,
|
||||
endpoint_id: std::option::Option::None,
|
||||
filter_id: std::option::Option::None,
|
||||
observed_at: std::option::Option::None,
|
||||
origin,
|
||||
protocol,
|
||||
provider,
|
||||
received_at,
|
||||
source_payload_hash: std::option::Option::None,
|
||||
source_payload_size_bytes: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Attaches one safe logical capture/session identifier.
|
||||
#[must_use]
|
||||
pub fn with_capture_session_id(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||
self.capture_session_id = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches one safe commitment code captured at acquisition.
|
||||
#[must_use]
|
||||
pub fn with_commitment(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||
self.commitment = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches one Config-owned logical endpoint identifier.
|
||||
#[must_use]
|
||||
pub fn with_endpoint_id(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||
self.endpoint_id = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches one safe logical filter identifier.
|
||||
#[must_use]
|
||||
pub fn with_filter_id(mut self, value: crate::RawProvenanceCode) -> Self {
|
||||
self.filter_id = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches the source observation timestamp when it does not follow local receipt.
|
||||
pub fn try_with_observed_at(mut self, value: crate::RawTimestamp) -> ksp_core_lib::Result<Self> {
|
||||
if value > self.received_at {
|
||||
return std::result::Result::Err(raw_provenance_error("observed_at"));
|
||||
}
|
||||
self.observed_at = std::option::Option::Some(value);
|
||||
return std::result::Result::Ok(self);
|
||||
}
|
||||
|
||||
/// Attaches the digest of source-specific bytes without retaining those bytes.
|
||||
#[must_use]
|
||||
pub fn with_source_payload_hash(mut self, value: crate::RawContentHash) -> Self {
|
||||
self.source_payload_hash = std::option::Option::Some(value);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Attaches the bounded source-wire payload size.
|
||||
pub fn try_with_source_payload_size_bytes(mut self, value: u64) -> ksp_core_lib::Result<Self> {
|
||||
if value > crate::MAX_RAW_SOURCE_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(raw_provenance_error("source_payload_size_bytes"));
|
||||
}
|
||||
self.source_payload_size_bytes = std::option::Option::Some(value);
|
||||
return std::result::Result::Ok(self);
|
||||
}
|
||||
|
||||
/// Returns the logical acquisition method code.
|
||||
#[must_use]
|
||||
pub const fn acquisition_method(&self) -> &crate::RawProvenanceCode {
|
||||
return &self.acquisition_method;
|
||||
}
|
||||
|
||||
/// Returns the optional logical capture/session identifier.
|
||||
#[must_use]
|
||||
pub fn capture_session_id(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||
return self.capture_session_id.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the optional commitment code captured at acquisition.
|
||||
#[must_use]
|
||||
pub fn commitment(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||
return self.commitment.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the optional Config-owned logical endpoint identifier.
|
||||
#[must_use]
|
||||
pub fn endpoint_id(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||
return self.endpoint_id.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the optional logical filter identifier.
|
||||
#[must_use]
|
||||
pub fn filter_id(&self) -> std::option::Option<&crate::RawProvenanceCode> {
|
||||
return self.filter_id.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the optional source observation timestamp when the source supplies one.
|
||||
#[must_use]
|
||||
pub const fn observed_at(&self) -> std::option::Option<crate::RawTimestamp> {
|
||||
return self.observed_at;
|
||||
}
|
||||
|
||||
/// Returns the acquisition origin category.
|
||||
#[must_use]
|
||||
pub const fn origin(&self) -> crate::RawAcquisitionOrigin {
|
||||
return self.origin;
|
||||
}
|
||||
|
||||
/// Returns the logical transport/protocol code.
|
||||
#[must_use]
|
||||
pub fn protocol(&self) -> &crate::RawProvenanceCode {
|
||||
return &self.protocol;
|
||||
}
|
||||
|
||||
/// Returns the safe provider code.
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &crate::RawProvenanceCode {
|
||||
return &self.provider;
|
||||
}
|
||||
|
||||
/// Returns the local receipt timestamp.
|
||||
#[must_use]
|
||||
pub const fn received_at(&self) -> crate::RawTimestamp {
|
||||
return self.received_at;
|
||||
}
|
||||
|
||||
/// Returns the optional digest of source-specific bytes without retaining those bytes.
|
||||
#[must_use]
|
||||
pub const fn source_payload_hash(&self) -> std::option::Option<crate::RawContentHash> {
|
||||
return self.source_payload_hash;
|
||||
}
|
||||
|
||||
/// Returns the optional source-wire payload size.
|
||||
#[must_use]
|
||||
pub const fn source_payload_size_bytes(&self) -> std::option::Option<u64> {
|
||||
return self.source_payload_size_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded source-independent KSP RAW persistence payload.
|
||||
pub struct RawPayload {
|
||||
bytes: std::boxed::Box<[u8]>,
|
||||
content_hash: crate::RawContentHash,
|
||||
format_id: crate::RawFormatId,
|
||||
format_version: u32,
|
||||
}
|
||||
|
||||
impl RawPayload {
|
||||
/// Creates one canonical RAW payload after enforcing Store-owned admission invariants.
|
||||
///
|
||||
/// The supplied bytes must already use the KSP-owned source-independent format identified
|
||||
/// by `format_id` and `format_version`; this constructor performs no transport conversion.
|
||||
pub fn try_new(
|
||||
format_id: crate::RawFormatId,
|
||||
format_version: u32,
|
||||
bytes: std::boxed::Box<[u8]>,
|
||||
content_hash: crate::RawContentHash,
|
||||
) -> ksp_core_lib::Result<Self> {
|
||||
if format_version == 0 {
|
||||
return std::result::Result::Err(raw_payload_error("format_version"));
|
||||
}
|
||||
if bytes.is_empty() || bytes.len() > crate::MAX_RAW_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(
|
||||
raw_payload_error("bytes")
|
||||
.with_context("actual_len", bytes.len().to_string())
|
||||
.with_context("maximum_len", crate::MAX_RAW_PAYLOAD_BYTES.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { bytes, content_hash, format_id, format_version });
|
||||
}
|
||||
|
||||
/// Returns the canonical RAW bytes without transport/provider interpretation.
|
||||
#[must_use]
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
return self.bytes.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the deterministic content digest supplied for these exact canonical bytes.
|
||||
#[must_use]
|
||||
pub const fn content_hash(&self) -> crate::RawContentHash {
|
||||
return self.content_hash;
|
||||
}
|
||||
|
||||
/// Returns the KSP-owned source-independent format identifier.
|
||||
#[must_use]
|
||||
pub fn format_id(&self) -> &crate::RawFormatId {
|
||||
return &self.format_id;
|
||||
}
|
||||
|
||||
/// Returns the KSP-owned format version.
|
||||
#[must_use]
|
||||
pub const fn format_version(&self) -> u32 {
|
||||
return self.format_version;
|
||||
}
|
||||
|
||||
/// Returns the canonical payload length in bytes.
|
||||
#[must_use]
|
||||
pub fn byte_len(&self) -> usize {
|
||||
return self.bytes.len();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RawPayload {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("RawPayload")
|
||||
.field("format_id", &self.format_id)
|
||||
.field("format_version", &self.format_version)
|
||||
.field("len", &self.bytes.len())
|
||||
.field("content_hash", &self.content_hash)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_model_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_MODEL_INVALID, "invalid backend-agnostic RAW Store model").with_context("field", field);
|
||||
}
|
||||
|
||||
fn raw_payload_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_PAYLOAD_INVALID, "invalid KSP RAW persistence payload").with_context("field", field);
|
||||
}
|
||||
|
||||
fn raw_provenance_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_PROVENANCE_INVALID, "invalid RAW acquisition provenance").with_context("field", field);
|
||||
}
|
||||
|
||||
fn valid_raw_code(value: &str) -> bool {
|
||||
if value.is_empty() || value.len() > crate::MAX_RAW_CODE_BYTES {
|
||||
return false;
|
||||
}
|
||||
return value.bytes().all(|byte| return byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../unit_tests/model/raw_primitives.rs"]
|
||||
mod tests;
|
||||
157
crates/ksp-store-api/src/model/raw_retention.rs
Normal file
157
crates/ksp-store-api/src/model/raw_retention.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
// file: crates/ksp-store-api/src/model/raw_retention.rs
|
||||
// version: 2
|
||||
|
||||
/// Logical availability state of one canonical RAW payload.
|
||||
///
|
||||
/// These states describe payload retention only. They never prove that a
|
||||
/// STRUCTURAL/DECODED/DOMAIN processor has completed.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawRetentionState {
|
||||
/// Complete canonical RAW payload is retained in ordinary Store availability.
|
||||
Full,
|
||||
/// Complete logical RAW content is retained in a compacted representation.
|
||||
Compacted,
|
||||
/// RAW content has been moved to an archive managed outside the hot Store path.
|
||||
Archived,
|
||||
/// RAW payload is absent while a durable tombstone remains.
|
||||
Purged,
|
||||
}
|
||||
|
||||
/// Explicit write mode for canonical RAW transaction acquisitions.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawTransactionAcquisitionMode {
|
||||
/// Normal acquisition must skip an existing purged tombstone.
|
||||
Normal,
|
||||
/// Explicitly allow an existing purged transaction to be rehydrated.
|
||||
ForceRehydrate,
|
||||
}
|
||||
|
||||
/// Minimal durable identity retained after a canonical RAW transaction payload is purged.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawTransactionTombstone {
|
||||
content_hash: crate::RawContentHash,
|
||||
format_id: crate::RawFormatId,
|
||||
format_version: u32,
|
||||
reference: crate::RawTransactionReference,
|
||||
slot: u64,
|
||||
}
|
||||
|
||||
impl RawTransactionTombstone {
|
||||
/// Creates one minimal transaction tombstone from canonical RAW identity metadata.
|
||||
pub fn try_new(
|
||||
reference: crate::RawTransactionReference,
|
||||
slot: u64,
|
||||
format_id: crate::RawFormatId,
|
||||
format_version: u32,
|
||||
content_hash: crate::RawContentHash,
|
||||
) -> crate::Result<Self> {
|
||||
if format_version == 0 {
|
||||
return std::result::Result::Err(raw_retention_error("format_version"));
|
||||
}
|
||||
return std::result::Result::Ok(Self { content_hash, format_id, format_version, reference, slot });
|
||||
}
|
||||
|
||||
/// Returns the canonical content hash retained after purge.
|
||||
#[must_use]
|
||||
pub const fn content_hash(&self) -> crate::RawContentHash {
|
||||
return self.content_hash;
|
||||
}
|
||||
|
||||
/// Returns the KSP-owned canonical RAW format identifier retained after purge.
|
||||
#[must_use]
|
||||
pub fn format_id(&self) -> &crate::RawFormatId {
|
||||
return &self.format_id;
|
||||
}
|
||||
|
||||
/// Returns the KSP-owned canonical RAW format version retained after purge.
|
||||
#[must_use]
|
||||
pub const fn format_version(&self) -> u32 {
|
||||
return self.format_version;
|
||||
}
|
||||
|
||||
/// Returns the durable backend-independent transaction identity.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &crate::RawTransactionReference {
|
||||
return &self.reference;
|
||||
}
|
||||
|
||||
/// Returns the known transaction slot retained after purge.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
}
|
||||
|
||||
/// Requested compare-and-transition operation for one RAW transaction retention state.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawTransactionRetentionTransition {
|
||||
expected: crate::RawRetentionState,
|
||||
reference: crate::RawTransactionReference,
|
||||
target: crate::RawRetentionState,
|
||||
}
|
||||
|
||||
impl RawTransactionRetentionTransition {
|
||||
/// Creates one allowed forward retention transition.
|
||||
///
|
||||
/// Rehydration from `Purged` is deliberately excluded and uses
|
||||
/// [`crate::RawTransactionAcquisitionMode::ForceRehydrate`] instead.
|
||||
pub fn try_new(reference: crate::RawTransactionReference, expected: crate::RawRetentionState, target: crate::RawRetentionState) -> crate::Result<Self> {
|
||||
if !allowed_transition(expected, target) {
|
||||
return std::result::Result::Err(raw_retention_error("transition"));
|
||||
}
|
||||
return std::result::Result::Ok(Self { expected, reference, target });
|
||||
}
|
||||
|
||||
/// Returns the state the caller expects before applying the transition.
|
||||
#[must_use]
|
||||
pub const fn expected(&self) -> crate::RawRetentionState {
|
||||
return self.expected;
|
||||
}
|
||||
|
||||
/// Returns the durable transaction identity targeted by the transition.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &crate::RawTransactionReference {
|
||||
return &self.reference;
|
||||
}
|
||||
|
||||
/// Returns the requested forward retention state.
|
||||
#[must_use]
|
||||
pub const fn target(&self) -> crate::RawRetentionState {
|
||||
return self.target;
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of one atomic RAW retention transition.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum RawRetentionWriteOutcome {
|
||||
/// The requested forward transition was applied.
|
||||
Applied,
|
||||
/// The entity was already at the requested target state.
|
||||
AlreadyAtTarget,
|
||||
/// The current state matched neither the caller's expected state nor the requested target.
|
||||
///
|
||||
/// This outcome makes a lost compare-and-transition race observable without
|
||||
/// treating concurrent policy progress as malformed input.
|
||||
ExpectedStateMismatch,
|
||||
}
|
||||
|
||||
fn allowed_transition(expected: crate::RawRetentionState, target: crate::RawRetentionState) -> bool {
|
||||
return matches!(
|
||||
(expected, target),
|
||||
(crate::RawRetentionState::Full, crate::RawRetentionState::Compacted)
|
||||
| (crate::RawRetentionState::Full, crate::RawRetentionState::Archived)
|
||||
| (crate::RawRetentionState::Compacted, crate::RawRetentionState::Archived)
|
||||
| (crate::RawRetentionState::Archived, crate::RawRetentionState::Purged)
|
||||
);
|
||||
}
|
||||
|
||||
fn raw_retention_error(field: &'static str) -> crate::Error {
|
||||
return crate::Error::new(crate::ERROR_CODE_RAW_RETENTION_INVALID, "invalid RAW retention lifecycle request").with_context("field", field);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../unit_tests/model/raw_retention.rs"]
|
||||
mod tests;
|
||||
113
crates/ksp-store-api/src/model/raw_transaction.rs
Normal file
113
crates/ksp-store-api/src/model/raw_transaction.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
// file: crates/ksp-store-api/src/model/raw_transaction.rs
|
||||
// version: 1
|
||||
|
||||
/// Durable backend-independent identity of one canonical RAW transaction.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct RawTransactionReference {
|
||||
network: crate::RawNetworkId,
|
||||
signature: crate::RawTransactionSignature,
|
||||
}
|
||||
|
||||
impl RawTransactionReference {
|
||||
/// Creates one durable transaction identity from network and canonical Solana signature.
|
||||
#[must_use]
|
||||
pub fn new(network: crate::RawNetworkId, signature: crate::RawTransactionSignature) -> Self {
|
||||
return Self { network, signature };
|
||||
}
|
||||
|
||||
/// Returns the logical Solana network/cluster identifier.
|
||||
#[must_use]
|
||||
pub fn network(&self) -> &crate::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns the canonical transaction signature.
|
||||
#[must_use]
|
||||
pub const fn signature(&self) -> crate::RawTransactionSignature {
|
||||
return self.signature;
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical source-independent N1 RAW transaction persisted by Store backends.
|
||||
///
|
||||
/// The payload must contain the complete KSP canonical transaction representation required
|
||||
/// for future STRUCTURAL replay, including transaction execution metadata and transaction
|
||||
/// log messages when the canonical format defines them. Provider/transport provenance is
|
||||
/// deliberately kept in [`crate::RawTransactionObservation`] instead.
|
||||
#[derive(Debug)]
|
||||
pub struct RawTransaction {
|
||||
block_time: std::option::Option<crate::RawTimestamp>,
|
||||
payload: crate::RawPayload,
|
||||
reference: crate::RawTransactionReference,
|
||||
slot: u64,
|
||||
}
|
||||
|
||||
impl RawTransaction {
|
||||
/// Creates one complete canonical RAW transaction.
|
||||
#[must_use]
|
||||
pub fn new(reference: crate::RawTransactionReference, slot: u64, block_time: std::option::Option<crate::RawTimestamp>, payload: crate::RawPayload) -> Self {
|
||||
return Self { block_time, payload, reference, slot };
|
||||
}
|
||||
|
||||
/// Returns the optional canonical block timestamp.
|
||||
#[must_use]
|
||||
pub const fn block_time(&self) -> std::option::Option<crate::RawTimestamp> {
|
||||
return self.block_time;
|
||||
}
|
||||
|
||||
/// Returns the complete KSP-owned canonical RAW payload.
|
||||
#[must_use]
|
||||
pub fn payload(&self) -> &crate::RawPayload {
|
||||
return &self.payload;
|
||||
}
|
||||
|
||||
/// Returns the durable backend-independent transaction identity.
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> &crate::RawTransactionReference {
|
||||
return &self.reference;
|
||||
}
|
||||
|
||||
/// Returns the Solana slot containing the transaction.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistable acquisition observation linked to one canonical RAW transaction.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RawTransactionObservation {
|
||||
observation_key: crate::RawObservationKey,
|
||||
provenance: crate::RawAcquisitionProvenance,
|
||||
transaction: crate::RawTransactionReference,
|
||||
}
|
||||
|
||||
impl RawTransactionObservation {
|
||||
/// Creates one successful observation of a complete canonical RAW transaction.
|
||||
#[must_use]
|
||||
pub fn new(observation_key: crate::RawObservationKey, transaction: crate::RawTransactionReference, provenance: crate::RawAcquisitionProvenance) -> Self {
|
||||
return Self { observation_key, provenance, transaction };
|
||||
}
|
||||
|
||||
/// Returns the deterministic producer-owned observation idempotence key.
|
||||
#[must_use]
|
||||
pub const fn observation_key(&self) -> crate::RawObservationKey {
|
||||
return self.observation_key;
|
||||
}
|
||||
|
||||
/// Returns safe source-independent acquisition provenance.
|
||||
#[must_use]
|
||||
pub fn provenance(&self) -> &crate::RawAcquisitionProvenance {
|
||||
return &self.provenance;
|
||||
}
|
||||
|
||||
/// Returns the durable transaction identity observed by this acquisition.
|
||||
#[must_use]
|
||||
pub fn transaction(&self) -> &crate::RawTransactionReference {
|
||||
return &self.transaction;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../unit_tests/model/raw_transaction.rs"]
|
||||
mod tests;
|
||||
152
crates/ksp-store-api/tests/dependency_boundary.rs
Normal file
152
crates/ksp-store-api/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
// file: crates/ksp-store-api/tests/dependency_boundary.rs
|
||||
// version: 5
|
||||
|
||||
//! Dependency canaries for the Store API RAW foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_006_manifest_keeps_exact_core_only_runtime_dependency() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||
assert!(dependencies_tail.is_some(), "Store API dependencies section must exist");
|
||||
let dependencies_tail = match dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib"]);
|
||||
for forbidden in [
|
||||
"ksp-config-lib",
|
||||
"ksp-interface-lib",
|
||||
"ksp-logging-lib",
|
||||
"ksp-materializer-api",
|
||||
"ksp-offchain-transport-lib",
|
||||
"ksp-onchain-transport-lib",
|
||||
"ksp-program-api",
|
||||
"ksp-program-lib",
|
||||
"ksp-store-lib",
|
||||
"ksp-store-postgres-lib",
|
||||
"ksp-wallet-lib",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"chrono",
|
||||
"postgres",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"wincode",
|
||||
] {
|
||||
assert!(!dependencies.contains(forbidden), "forbidden Store API dependency detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_source_boundary_keeps_models_and_capabilities_backend_free() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let model_home = include_str!("../src/model.rs");
|
||||
let raw_account = include_str!("../src/model/raw_account.rs");
|
||||
let raw_outcome = include_str!("../src/model/raw_outcome.rs");
|
||||
let raw_pagination = include_str!("../src/model/raw_pagination.rs");
|
||||
let raw_primitives = include_str!("../src/model/raw_primitives.rs");
|
||||
let raw_retention = include_str!("../src/model/raw_retention.rs");
|
||||
let raw_transaction = include_str!("../src/model/raw_transaction.rs");
|
||||
let capability_home = include_str!("../src/capability.rs");
|
||||
let raw_account_capability = include_str!("../src/capability/raw_account.rs");
|
||||
let raw_retention_capability = include_str!("../src/capability/raw_retention.rs");
|
||||
let raw_transaction_capability = include_str!("../src/capability/raw_transaction.rs");
|
||||
assert!(crate_root.contains("mod capability;"));
|
||||
assert!(crate_root.contains("mod error;"));
|
||||
assert!(crate_root.contains("mod model;"));
|
||||
assert!(model_home.contains("raw_account"));
|
||||
assert!(model_home.contains("raw_outcome"));
|
||||
assert!(model_home.contains("raw_pagination"));
|
||||
assert!(model_home.contains("raw_primitives"));
|
||||
assert!(model_home.contains("raw_retention"));
|
||||
assert!(model_home.contains("raw_transaction"));
|
||||
assert!(capability_home.contains("raw_account"));
|
||||
assert!(capability_home.contains("raw_retention"));
|
||||
assert!(capability_home.contains("raw_transaction"));
|
||||
for source in [
|
||||
crate_root,
|
||||
model_home,
|
||||
raw_account,
|
||||
raw_outcome,
|
||||
raw_pagination,
|
||||
raw_primitives,
|
||||
raw_retention,
|
||||
raw_transaction,
|
||||
capability_home,
|
||||
raw_account_capability,
|
||||
raw_retention_capability,
|
||||
raw_transaction_capability,
|
||||
] {
|
||||
for forbidden in [
|
||||
"ksp_store_lib",
|
||||
"ksp_store_postgres_lib",
|
||||
"ksp_onchain_transport_lib",
|
||||
"ksp_program_api",
|
||||
"serde::",
|
||||
"sqlx::",
|
||||
"tokio::",
|
||||
"tokio_postgres::",
|
||||
"std::env::",
|
||||
"std::fs::",
|
||||
"std::net::",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden Store API dependency/runtime path detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
assert!(!raw_transaction.contains("RawLog"));
|
||||
for forbidden in ["TransactionStatusObservation", "RawLogNotification", "RawSlotEvent", "RawVoteEvent", "RawBlock", "YellowstoneEntry"] {
|
||||
assert!(!crate_root.contains(forbidden), "deferred pre.006 model leaked into Store API surface: {forbidden}");
|
||||
}
|
||||
assert!(raw_transaction_capability.contains("trait RawTransactionRead"));
|
||||
assert!(raw_transaction_capability.contains("trait RawTransactionWrite"));
|
||||
assert!(raw_transaction_capability.contains("trait RawTransactionObservationRead"));
|
||||
assert!(raw_transaction_capability.contains("trait RawTransactionObservationWrite"));
|
||||
assert!(raw_retention_capability.contains("trait RawTransactionRetentionRead"));
|
||||
assert!(raw_retention_capability.contains("trait RawTransactionRetentionWrite"));
|
||||
assert!(raw_account_capability.contains("trait RawAccountStateRead"));
|
||||
assert!(raw_account_capability.contains("trait RawAccountStateWrite"));
|
||||
assert!(raw_account_capability.contains("trait RawAccountObservationRead"));
|
||||
assert!(raw_account_capability.contains("trait RawAccountObservationWrite"));
|
||||
for forbidden in ["trait StoreBackend", "trait Store", "PostgresStore", "MySqlStore", "Arc<dyn"] {
|
||||
assert!(!capability_home.contains(forbidden));
|
||||
assert!(!raw_account_capability.contains(forbidden));
|
||||
assert!(!raw_retention_capability.contains(forbidden));
|
||||
assert!(!raw_transaction_capability.contains(forbidden));
|
||||
}
|
||||
assert!(!raw_pagination.contains("u64::MAX"));
|
||||
assert!(!raw_pagination.contains("MAX_RAW_PAGE_ITEMS"));
|
||||
return;
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in section.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
194
crates/ksp-store-api/tests/external_backend.rs
Normal file
194
crates/ksp-store-api/tests/external_backend.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
// file: crates/ksp-store-api/tests/external_backend.rs
|
||||
// version: 2
|
||||
|
||||
//! External-implementation canary for object-safe Store API capabilities.
|
||||
|
||||
struct ExternalMemoryBackend;
|
||||
|
||||
impl ksp_store_api::RawTransactionRead for ExternalMemoryBackend {
|
||||
fn get_raw_transaction<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawTransactionReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransaction>>> {
|
||||
let _ = reference;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
});
|
||||
}
|
||||
|
||||
fn list_raw_transactions<'a>(
|
||||
&'a self,
|
||||
query: &'a ksp_store_api::RawTransactionQuery,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>>> {
|
||||
let _ = query;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawPage::new(std::vec::Vec::new(), std::option::Option::None));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionWrite for ExternalMemoryBackend {
|
||||
fn persist_raw_transaction_acquisition<'a>(
|
||||
&'a self,
|
||||
transaction: ksp_store_api::RawTransaction,
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
mode: ksp_store_api::RawTransactionAcquisitionMode,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawAcquisitionWriteOutcome>> {
|
||||
let _ = transaction;
|
||||
let _ = observation;
|
||||
let _ = mode;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawAcquisitionWriteOutcome::new(
|
||||
ksp_store_api::RawEntityWriteOutcome::Inserted,
|
||||
ksp_store_api::RawObservationWriteOutcome::Inserted,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionObservationRead for ExternalMemoryBackend {
|
||||
fn get_raw_transaction_observation<'a>(
|
||||
&'a self,
|
||||
observation_key: &'a ksp_store_api::RawObservationKey,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionObservation>>> {
|
||||
let _ = observation_key;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionObservationWrite for ExternalMemoryBackend {
|
||||
fn record_raw_transaction_observation<'a>(
|
||||
&'a self,
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawObservationWriteOutcome>> {
|
||||
let _ = observation;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountStateRead for ExternalMemoryBackend {
|
||||
fn get_raw_account_state<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawAccountStateReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawAccountState>>> {
|
||||
let _ = reference;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
});
|
||||
}
|
||||
|
||||
fn list_raw_account_states<'a>(
|
||||
&'a self,
|
||||
query: &'a ksp_store_api::RawAccountStateQuery,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawPage<ksp_store_api::RawAccountStateReference>>> {
|
||||
let _ = query;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawPage::new(std::vec::Vec::new(), std::option::Option::None));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountStateWrite for ExternalMemoryBackend {
|
||||
fn persist_raw_account_acquisition<'a>(
|
||||
&'a self,
|
||||
state: ksp_store_api::RawAccountState,
|
||||
observation: ksp_store_api::RawAccountObservation,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawAcquisitionWriteOutcome>> {
|
||||
let _ = state;
|
||||
let _ = observation;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawAcquisitionWriteOutcome::new(
|
||||
ksp_store_api::RawEntityWriteOutcome::Inserted,
|
||||
ksp_store_api::RawObservationWriteOutcome::Inserted,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountObservationRead for ExternalMemoryBackend {
|
||||
fn get_raw_account_observation<'a>(
|
||||
&'a self,
|
||||
observation_key: &'a ksp_store_api::RawObservationKey,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawAccountObservation>>> {
|
||||
let _ = observation_key;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountObservationWrite for ExternalMemoryBackend {
|
||||
fn record_raw_account_observation<'a>(
|
||||
&'a self,
|
||||
observation: ksp_store_api::RawAccountObservation,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawObservationWriteOutcome>> {
|
||||
let _ = observation;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionRetentionRead for ExternalMemoryBackend {
|
||||
fn get_raw_transaction_retention_state<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawTransactionReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawRetentionState>>> {
|
||||
let _ = reference;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
});
|
||||
}
|
||||
|
||||
fn get_raw_transaction_tombstone<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawTransactionReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionTombstone>>> {
|
||||
let _ = reference;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionRetentionWrite for ExternalMemoryBackend {
|
||||
fn transition_raw_transaction_retention<'a>(
|
||||
&'a self,
|
||||
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawRetentionWriteOutcome>> {
|
||||
let _ = transition;
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::result::Result::Ok(ksp_store_api::RawRetentionWriteOutcome::Applied);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_006_external_backend_implements_each_capability_without_store_runtime_crate() {
|
||||
let backend = ExternalMemoryBackend;
|
||||
let transaction_read: &dyn ksp_store_api::RawTransactionRead = &backend;
|
||||
let transaction_write: &dyn ksp_store_api::RawTransactionWrite = &backend;
|
||||
let transaction_observation_read: &dyn ksp_store_api::RawTransactionObservationRead = &backend;
|
||||
let transaction_observation_write: &dyn ksp_store_api::RawTransactionObservationWrite = &backend;
|
||||
let account_read: &dyn ksp_store_api::RawAccountStateRead = &backend;
|
||||
let account_write: &dyn ksp_store_api::RawAccountStateWrite = &backend;
|
||||
let account_observation_read: &dyn ksp_store_api::RawAccountObservationRead = &backend;
|
||||
let account_observation_write: &dyn ksp_store_api::RawAccountObservationWrite = &backend;
|
||||
let retention_read: &dyn ksp_store_api::RawTransactionRetentionRead = &backend;
|
||||
let retention_write: &dyn ksp_store_api::RawTransactionRetentionWrite = &backend;
|
||||
let _ = transaction_read;
|
||||
let _ = transaction_write;
|
||||
let _ = transaction_observation_read;
|
||||
let _ = transaction_observation_write;
|
||||
let _ = account_read;
|
||||
let _ = account_write;
|
||||
let _ = account_observation_read;
|
||||
let _ = account_observation_write;
|
||||
let _ = retention_read;
|
||||
let _ = retention_write;
|
||||
return;
|
||||
}
|
||||
188
crates/ksp-store-api/tests/public_api.rs
Normal file
188
crates/ksp-store-api/tests/public_api.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
// file: crates/ksp-store-api/tests/public_api.rs
|
||||
// version: 6
|
||||
|
||||
//! Integration canaries for the public `ksp-store-api` surface.
|
||||
|
||||
fn consume_result(value: ksp_store_api::Result<ksp_store_api::Pubkey>) -> ksp_store_api::Result<ksp_store_api::Pubkey> {
|
||||
return value;
|
||||
}
|
||||
|
||||
fn code(value: &str) -> std::option::Option<ksp_store_api::RawProvenanceCode> {
|
||||
return match ksp_store_api::RawProvenanceCode::new(value.to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_002_core_facade_remains_available_from_crate_root() {
|
||||
let pubkey = ksp_store_api::Pubkey::new_from_array([0x31_u8; 32]);
|
||||
let forwarded = consume_result(std::result::Result::Ok(pubkey));
|
||||
assert!(forwarded.is_ok());
|
||||
let error_code_type: std::option::Option<ksp_store_api::ErrorCode> = std::option::Option::None;
|
||||
let error_context_type: std::option::Option<ksp_store_api::ErrorContext> = std::option::Option::None;
|
||||
let error_type: std::option::Option<ksp_store_api::Error> = std::option::Option::None;
|
||||
assert!(error_code_type.is_none());
|
||||
assert!(error_context_type.is_none());
|
||||
assert!(error_type.is_none());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_raw_transaction_and_observation_are_constructible_from_crate_root() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let reference = ksp_store_api::RawTransactionReference::new(network, ksp_store_api::RawTransactionSignature::new([1_u8; 64]));
|
||||
let format = match ksp_store_api::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let payload = match ksp_store_api::RawPayload::try_new(format, 1, vec![1_u8, 2_u8, 3_u8].into_boxed_slice(), ksp_store_api::RawContentHash::new([2_u8; 32]))
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let transaction = ksp_store_api::RawTransaction::new(reference.clone(), 123, std::option::Option::None, payload);
|
||||
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let provider = match code("provider") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let protocol = match code("solana_http") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let method = match code("getTransaction") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provenance = ksp_store_api::RawAcquisitionProvenance::new(provider, protocol, method, ksp_store_api::RawAcquisitionOrigin::Backfill, received_at);
|
||||
let observation = ksp_store_api::RawTransactionObservation::new(ksp_store_api::RawObservationKey::new([3_u8; 32]), reference, provenance);
|
||||
assert_eq!(transaction.slot(), 123);
|
||||
assert_eq!(transaction.payload().bytes(), &[1_u8, 2_u8, 3_u8]);
|
||||
assert_eq!(observation.transaction().network().as_str(), "mainnet-beta");
|
||||
assert_eq!(observation.provenance().acquisition_method().as_str(), "getTransaction");
|
||||
assert_eq!(ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID.domain(), "store_api");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_003_surface_keeps_backend_and_structural_types_out() {
|
||||
let source = include_str!("../src/lib.rs");
|
||||
assert!(!source.contains("pub mod "));
|
||||
for forbidden in ["Postgres", "Sql", "Migration", "StructuralTransaction", "RawLog", "ProgramInstruction"] {
|
||||
assert!(!source.contains(forbidden), "forbidden pre.003 Store API public concept detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_004_raw_account_state_and_observation_are_constructible_from_crate_root() {
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let reference = ksp_store_api::RawAccountStateReference::new(
|
||||
network,
|
||||
ksp_store_api::Pubkey::new_from_array([21_u8; 32]),
|
||||
55,
|
||||
ksp_store_api::RawContentHash::new([22_u8; 32]),
|
||||
);
|
||||
let state = ksp_store_api::RawAccountState::try_new(
|
||||
reference.clone(),
|
||||
123,
|
||||
ksp_store_api::Pubkey::new_from_array([23_u8; 32]),
|
||||
false,
|
||||
9,
|
||||
vec![1_u8, 2_u8].into_boxed_slice(),
|
||||
);
|
||||
assert!(state.is_ok());
|
||||
let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(2_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let provider = match code("provider") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let protocol = match code("solana_http") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let method = match code("getAccountInfo") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provenance = ksp_store_api::RawAcquisitionProvenance::new(provider, protocol, method, ksp_store_api::RawAcquisitionOrigin::Backfill, received_at);
|
||||
let observation = ksp_store_api::RawAccountObservation::new(ksp_store_api::RawObservationKey::new([24_u8; 32]), reference, provenance);
|
||||
assert_eq!(observation.account().slot(), 55);
|
||||
assert!(observation.write_version().is_none());
|
||||
assert_eq!(ksp_store_api::MAX_RAW_ACCOUNT_DATA_BYTES, 16 * 1024 * 1024);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_005_capabilities_are_available_from_crate_root_and_dyn_compatible() {
|
||||
let transaction_read: std::option::Option<&dyn ksp_store_api::RawTransactionRead> = std::option::Option::None;
|
||||
let transaction_write: std::option::Option<&dyn ksp_store_api::RawTransactionWrite> = std::option::Option::None;
|
||||
let transaction_observation_read: std::option::Option<&dyn ksp_store_api::RawTransactionObservationRead> = std::option::Option::None;
|
||||
let transaction_observation_write: std::option::Option<&dyn ksp_store_api::RawTransactionObservationWrite> = std::option::Option::None;
|
||||
let account_read: std::option::Option<&dyn ksp_store_api::RawAccountStateRead> = std::option::Option::None;
|
||||
let account_write: std::option::Option<&dyn ksp_store_api::RawAccountStateWrite> = std::option::Option::None;
|
||||
let account_observation_read: std::option::Option<&dyn ksp_store_api::RawAccountObservationRead> = std::option::Option::None;
|
||||
let account_observation_write: std::option::Option<&dyn ksp_store_api::RawAccountObservationWrite> = std::option::Option::None;
|
||||
assert!(transaction_read.is_none());
|
||||
assert!(transaction_write.is_none());
|
||||
assert!(transaction_observation_read.is_none());
|
||||
assert!(transaction_observation_write.is_none());
|
||||
assert!(account_read.is_none());
|
||||
assert!(account_write.is_none());
|
||||
assert!(account_observation_read.is_none());
|
||||
assert!(account_observation_write.is_none());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_006_query_outcome_and_retention_contracts_are_available_from_crate_root() {
|
||||
let limit = match ksp_store_api::RawPageLimit::new(5_000_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(limit.get(), 5_000_000);
|
||||
assert!(ksp_store_api::RawPageLimit::new(u64::MAX).is_ok());
|
||||
let slots = match ksp_store_api::RawSlotRange::new(std::option::Option::Some(1), std::option::Option::Some(2)) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let query =
|
||||
ksp_store_api::RawTransactionQuery::new(network, slots, ksp_store_api::RawSortDirection::Ascending, ksp_store_api::RawPageRequest::first(limit));
|
||||
assert_eq!(query.page().limit().get(), 5_000_000);
|
||||
let outcome =
|
||||
ksp_store_api::RawAcquisitionWriteOutcome::new(ksp_store_api::RawEntityWriteOutcome::Inserted, ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||
assert_eq!(outcome.entity(), ksp_store_api::RawEntityWriteOutcome::Inserted);
|
||||
assert_eq!(outcome.observation(), ksp_store_api::RawObservationWriteOutcome::Inserted);
|
||||
let retention_read: std::option::Option<&dyn ksp_store_api::RawTransactionRetentionRead> = std::option::Option::None;
|
||||
let retention_write: std::option::Option<&dyn ksp_store_api::RawTransactionRetentionWrite> = std::option::Option::None;
|
||||
assert!(retention_read.is_none());
|
||||
assert!(retention_write.is_none());
|
||||
assert_eq!(ksp_store_api::ERROR_CODE_RAW_CONFLICT.domain(), "store_api");
|
||||
assert_eq!(ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID.domain(), "store_api");
|
||||
assert_eq!(ksp_store_api::ERROR_CODE_RAW_RETENTION_INVALID.domain(), "store_api");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_007_retention_race_outcome_is_available_from_crate_root() {
|
||||
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::Applied, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::AlreadyAtTarget, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||
return;
|
||||
}
|
||||
256
crates/ksp-store-api/tests/release_completeness.rs
Normal file
256
crates/ksp-store-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
// file: crates/ksp-store-api/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
//! Release-level boundary and completeness canaries for the `0.3.1` Store API RAW surface.
|
||||
|
||||
#[test]
|
||||
fn pre_007_exact_crate_root_export_inventory_is_stable() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let mut actual = std::vec::Vec::new();
|
||||
for line in crate_root.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub use ") {
|
||||
actual.push(trimmed);
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
let mut expected = std::vec![
|
||||
"pub use ksp_core_lib::Error;",
|
||||
"pub use ksp_core_lib::ErrorCode;",
|
||||
"pub use ksp_core_lib::ErrorContext;",
|
||||
"pub use ksp_core_lib::Pubkey;",
|
||||
"pub use ksp_core_lib::Result;",
|
||||
"pub use self::capability::StoreApiFuture;",
|
||||
"pub use self::capability::raw_account::RawAccountObservationRead;",
|
||||
"pub use self::capability::raw_account::RawAccountObservationWrite;",
|
||||
"pub use self::capability::raw_account::RawAccountStateRead;",
|
||||
"pub use self::capability::raw_account::RawAccountStateWrite;",
|
||||
"pub use self::capability::raw_retention::RawTransactionRetentionRead;",
|
||||
"pub use self::capability::raw_retention::RawTransactionRetentionWrite;",
|
||||
"pub use self::capability::raw_transaction::RawTransactionObservationRead;",
|
||||
"pub use self::capability::raw_transaction::RawTransactionObservationWrite;",
|
||||
"pub use self::capability::raw_transaction::RawTransactionRead;",
|
||||
"pub use self::capability::raw_transaction::RawTransactionWrite;",
|
||||
"pub use self::error::ERROR_CODE_RAW_CONFLICT;",
|
||||
"pub use self::error::ERROR_CODE_RAW_MODEL_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_RAW_PAYLOAD_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_RAW_PROVENANCE_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_RAW_QUERY_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_RAW_RETENTION_INVALID;",
|
||||
"pub use self::model::raw_account::RawAccountObservation;",
|
||||
"pub use self::model::raw_account::RawAccountState;",
|
||||
"pub use self::model::raw_account::RawAccountStateReference;",
|
||||
"pub use self::model::raw_outcome::RawAcquisitionWriteOutcome;",
|
||||
"pub use self::model::raw_outcome::RawEntityWriteOutcome;",
|
||||
"pub use self::model::raw_outcome::RawObservationWriteOutcome;",
|
||||
"pub use self::model::raw_pagination::MAX_RAW_PAGE_CURSOR_BYTES;",
|
||||
"pub use self::model::raw_pagination::RawAccountStateQuery;",
|
||||
"pub use self::model::raw_pagination::RawPage;",
|
||||
"pub use self::model::raw_pagination::RawPageCursor;",
|
||||
"pub use self::model::raw_pagination::RawPageLimit;",
|
||||
"pub use self::model::raw_pagination::RawPageRequest;",
|
||||
"pub use self::model::raw_pagination::RawSlotRange;",
|
||||
"pub use self::model::raw_pagination::RawSortDirection;",
|
||||
"pub use self::model::raw_pagination::RawTransactionQuery;",
|
||||
"pub use self::model::raw_primitives::MAX_RAW_ACCOUNT_DATA_BYTES;",
|
||||
"pub use self::model::raw_primitives::MAX_RAW_CODE_BYTES;",
|
||||
"pub use self::model::raw_primitives::MAX_RAW_PAYLOAD_BYTES;",
|
||||
"pub use self::model::raw_primitives::MAX_RAW_SOURCE_PAYLOAD_BYTES;",
|
||||
"pub use self::model::raw_primitives::MAX_RAW_UNIX_MILLIS;",
|
||||
"pub use self::model::raw_primitives::RawAcquisitionOrigin;",
|
||||
"pub use self::model::raw_primitives::RawAcquisitionProvenance;",
|
||||
"pub use self::model::raw_primitives::RawContentHash;",
|
||||
"pub use self::model::raw_primitives::RawFormatId;",
|
||||
"pub use self::model::raw_primitives::RawNetworkId;",
|
||||
"pub use self::model::raw_primitives::RawObservationKey;",
|
||||
"pub use self::model::raw_primitives::RawPayload;",
|
||||
"pub use self::model::raw_primitives::RawProvenanceCode;",
|
||||
"pub use self::model::raw_primitives::RawTimestamp;",
|
||||
"pub use self::model::raw_primitives::RawTransactionSignature;",
|
||||
"pub use self::model::raw_retention::RawRetentionState;",
|
||||
"pub use self::model::raw_retention::RawRetentionWriteOutcome;",
|
||||
"pub use self::model::raw_retention::RawTransactionAcquisitionMode;",
|
||||
"pub use self::model::raw_retention::RawTransactionRetentionTransition;",
|
||||
"pub use self::model::raw_retention::RawTransactionTombstone;",
|
||||
"pub use self::model::raw_transaction::RawTransaction;",
|
||||
"pub use self::model::raw_transaction::RawTransactionObservation;",
|
||||
"pub use self::model::raw_transaction::RawTransactionReference;",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_exact_production_module_inventory_is_raw_only() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let root_names = rust_file_names(root.as_path());
|
||||
assert!(root_names.is_ok());
|
||||
let root_names = match root_names {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(root_names, std::vec!["capability.rs", "error.rs", "lib.rs", "model.rs"]);
|
||||
let model_names = rust_file_names(root.join("model").as_path());
|
||||
assert!(model_names.is_ok());
|
||||
let model_names = match model_names {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(
|
||||
model_names,
|
||||
std::vec!["raw_account.rs", "raw_outcome.rs", "raw_pagination.rs", "raw_primitives.rs", "raw_retention.rs", "raw_transaction.rs"]
|
||||
);
|
||||
let capability_names = rust_file_names(root.join("capability").as_path());
|
||||
assert!(capability_names.is_ok());
|
||||
let capability_names = match capability_names {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(capability_names, std::vec!["raw_account.rs", "raw_retention.rs", "raw_transaction.rs"]);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_public_evolutive_enums_remain_non_exhaustive() {
|
||||
let sources = [
|
||||
include_str!("../src/model/raw_outcome.rs"),
|
||||
include_str!("../src/model/raw_pagination.rs"),
|
||||
include_str!("../src/model/raw_primitives.rs"),
|
||||
include_str!("../src/model/raw_retention.rs"),
|
||||
];
|
||||
for enum_name in [
|
||||
"RawAcquisitionOrigin",
|
||||
"RawEntityWriteOutcome",
|
||||
"RawObservationWriteOutcome",
|
||||
"RawRetentionState",
|
||||
"RawRetentionWriteOutcome",
|
||||
"RawSortDirection",
|
||||
"RawTransactionAcquisitionMode",
|
||||
] {
|
||||
assert_non_exhaustive(&sources, enum_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_interface_store_ownership_and_negative_scope_remain_explicit() {
|
||||
let interface_root = include_str!("../../ksp-interface-lib/src/lib.rs");
|
||||
let store_sources = [
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/model.rs"),
|
||||
include_str!("../src/model/raw_account.rs"),
|
||||
include_str!("../src/model/raw_outcome.rs"),
|
||||
include_str!("../src/model/raw_pagination.rs"),
|
||||
include_str!("../src/model/raw_primitives.rs"),
|
||||
include_str!("../src/model/raw_retention.rs"),
|
||||
include_str!("../src/model/raw_transaction.rs"),
|
||||
include_str!("../src/capability.rs"),
|
||||
include_str!("../src/capability/raw_account.rs"),
|
||||
include_str!("../src/capability/raw_retention.rs"),
|
||||
include_str!("../src/capability/raw_transaction.rs"),
|
||||
];
|
||||
for persistent_name in ["RawTransaction", "RawAccountState", "RawRetentionState", "RawTransactionTombstone"] {
|
||||
assert!(!interface_root.contains(persistent_name), "persistent Store model leaked into Interface crate root: {persistent_name}");
|
||||
}
|
||||
for source in store_sources {
|
||||
for forbidden in [
|
||||
"RawLogNotification",
|
||||
"RawSlotEvent",
|
||||
"RawVoteEvent",
|
||||
"YellowstoneEntry",
|
||||
"StructuralInstruction",
|
||||
"StructuralTransaction",
|
||||
"DecodedInstruction",
|
||||
"DomainRecord",
|
||||
"ProgramInstructionDecoder",
|
||||
"PostgresStore",
|
||||
"MySqlStore",
|
||||
"StoreBackend",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "deferred or backend-specific Store surface detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_capability_inventory_stays_fine_grained_without_runtime_facade() {
|
||||
let sources = [
|
||||
include_str!("../src/capability/raw_account.rs"),
|
||||
include_str!("../src/capability/raw_retention.rs"),
|
||||
include_str!("../src/capability/raw_transaction.rs"),
|
||||
];
|
||||
let mut traits = std::vec::Vec::new();
|
||||
for source in sources {
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub trait ") {
|
||||
traits.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
traits.sort_unstable();
|
||||
let mut expected = std::vec![
|
||||
"pub trait RawAccountObservationRead: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawAccountObservationWrite: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawAccountStateRead: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawAccountStateWrite: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawTransactionObservationRead: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawTransactionObservationWrite: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawTransactionRead: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawTransactionRetentionRead: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawTransactionRetentionWrite: std::marker::Send + std::marker::Sync {",
|
||||
"pub trait RawTransactionWrite: std::marker::Send + std::marker::Sync {",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(traits, expected);
|
||||
return;
|
||||
}
|
||||
|
||||
fn assert_non_exhaustive(sources: &[&str], enum_name: &str) {
|
||||
let needle = "#[non_exhaustive]\n";
|
||||
let declaration = std::format!("pub enum {enum_name}");
|
||||
let mut found = false;
|
||||
for source in sources {
|
||||
if let std::option::Option::Some(position) = source.find(declaration.as_str()) {
|
||||
let prefix = &source[..position];
|
||||
let attribute_position = prefix.rfind(needle);
|
||||
assert!(attribute_position.is_some(), "public evolutive enum is missing #[non_exhaustive]: {enum_name}");
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assert!(found, "public evolutive enum not found: {enum_name}");
|
||||
return;
|
||||
}
|
||||
|
||||
fn rust_file_names(directory: &std::path::Path) -> std::io::Result<std::vec::Vec<std::string::String>> {
|
||||
let entries = match std::fs::read_dir(directory) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match entry.file_name().into_string() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if name.ends_with(".rs") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return std::result::Result::Ok(names);
|
||||
}
|
||||
124
crates/ksp-store-api/tests/security_hardening.rs
Normal file
124
crates/ksp-store-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
// file: crates/ksp-store-api/tests/security_hardening.rs
|
||||
// version: 1
|
||||
|
||||
//! Adversarial and retention-race canaries for the Store API RAW foundation.
|
||||
|
||||
const HOSTILE_MARKER: &str = "STORE-RAW-SECRET-CANARY";
|
||||
|
||||
#[test]
|
||||
fn pre_007_hostile_model_query_and_provenance_errors_never_echo_input_material() {
|
||||
let hostile_code = std::format!("https://example.invalid/{HOSTILE_MARKER}");
|
||||
let network = ksp_store_api::RawNetworkId::new(hostile_code.clone());
|
||||
assert!(network.is_err());
|
||||
let network_error = match network {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(network_error.code(), ksp_store_api::ERROR_CODE_RAW_MODEL_INVALID);
|
||||
assert!(!std::format!("{network_error}").contains(HOSTILE_MARKER));
|
||||
assert!(!std::format!("{network_error:?}").contains(HOSTILE_MARKER));
|
||||
let provenance = ksp_store_api::RawProvenanceCode::new(hostile_code.clone());
|
||||
assert!(provenance.is_err());
|
||||
let provenance_error = match provenance {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(provenance_error.code(), ksp_store_api::ERROR_CODE_RAW_PROVENANCE_INVALID);
|
||||
assert!(!std::format!("{provenance_error}").contains(HOSTILE_MARKER));
|
||||
assert!(!std::format!("{provenance_error:?}").contains(HOSTILE_MARKER));
|
||||
let format = ksp_store_api::RawFormatId::new(hostile_code);
|
||||
assert!(format.is_err());
|
||||
let format_error = match format {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(format_error.code(), ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID);
|
||||
assert!(!std::format!("{format_error}").contains(HOSTILE_MARKER));
|
||||
assert!(!std::format!("{format_error:?}").contains(HOSTILE_MARKER));
|
||||
let cursor = ksp_store_api::RawPageCursor::try_new(std::vec![0x41_u8; ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES + 1].into_boxed_slice());
|
||||
assert!(cursor.is_err());
|
||||
let cursor_error = match cursor {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(cursor_error.code(), ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID);
|
||||
assert!(!std::format!("{cursor_error}").contains(HOSTILE_MARKER));
|
||||
assert!(!std::format!("{cursor_error:?}").contains(HOSTILE_MARKER));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_raw_debug_surfaces_do_not_render_payload_hash_signature_or_account_bytes() {
|
||||
let marker_bytes = HOSTILE_MARKER.as_bytes().to_vec().into_boxed_slice();
|
||||
let format = match ksp_store_api::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let payload = ksp_store_api::RawPayload::try_new(format, 1, marker_bytes, ksp_store_api::RawContentHash::new([0x53_u8; 32]));
|
||||
assert!(payload.is_ok());
|
||||
let payload = match payload {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(!std::format!("{payload:?}").contains(HOSTILE_MARKER));
|
||||
assert_eq!(std::format!("{:?}", payload.content_hash()), "RawContentHash(..)");
|
||||
let network = match ksp_store_api::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let signature = ksp_store_api::RawTransactionSignature::new([0x54_u8; 64]);
|
||||
assert_eq!(std::format!("{signature:?}"), "RawTransactionSignature(..)");
|
||||
let reference = ksp_store_api::RawTransactionReference::new(network.clone(), signature);
|
||||
let transaction = ksp_store_api::RawTransaction::new(reference, 7, std::option::Option::None, payload);
|
||||
assert!(!std::format!("{transaction:?}").contains(HOSTILE_MARKER));
|
||||
let account_reference = ksp_store_api::RawAccountStateReference::new(
|
||||
network,
|
||||
ksp_store_api::Pubkey::new_from_array([0x55_u8; 32]),
|
||||
8,
|
||||
ksp_store_api::RawContentHash::new([0x56_u8; 32]),
|
||||
);
|
||||
let account = ksp_store_api::RawAccountState::try_new(
|
||||
account_reference,
|
||||
1,
|
||||
ksp_store_api::Pubkey::new_from_array([0x57_u8; 32]),
|
||||
false,
|
||||
0,
|
||||
HOSTILE_MARKER.as_bytes().to_vec().into_boxed_slice(),
|
||||
);
|
||||
assert!(account.is_ok());
|
||||
let account = match account {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(!std::format!("{account:?}").contains(HOSTILE_MARKER));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_cursor_boundary_and_page_limit_preserve_security_without_executor_policy() {
|
||||
let cursor = ksp_store_api::RawPageCursor::try_new(std::vec![0x5A_u8; ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES].into_boxed_slice());
|
||||
assert!(cursor.is_ok());
|
||||
let cursor = match cursor {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(cursor.as_bytes().len(), ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES);
|
||||
assert!(!std::format!("{cursor:?}").contains("ZZZZ"));
|
||||
assert!(ksp_store_api::RawPageCursor::try_new(std::vec![0x5A_u8; ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES + 1].into_boxed_slice()).is_err());
|
||||
assert!(ksp_store_api::RawPageLimit::new(0).is_err());
|
||||
let unlimited_policy = ksp_store_api::RawPageLimit::new(u64::MAX);
|
||||
assert!(unlimited_policy.is_ok());
|
||||
let unlimited_policy = match unlimited_policy {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(unlimited_policy.get(), u64::MAX);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_retention_outcome_distinguishes_lost_compare_and_transition_race() {
|
||||
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::Applied, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||
assert_ne!(ksp_store_api::RawRetentionWriteOutcome::AlreadyAtTarget, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||
return;
|
||||
}
|
||||
108
crates/ksp-store-api/unit_tests/model/raw_account.rs
Normal file
108
crates/ksp-store-api/unit_tests/model/raw_account.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_account.rs
|
||||
// version: 1
|
||||
|
||||
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn provenance() -> std::option::Option<crate::RawAcquisitionProvenance> {
|
||||
let provider = match crate::RawProvenanceCode::new("publicnode".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let protocol = match crate::RawProvenanceCode::new("yellowstone_grpc".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let method = match crate::RawProvenanceCode::new("accounts".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let received_at = match crate::RawTimestamp::from_unix_millis(1_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(crate::RawAcquisitionProvenance::new(provider, protocol, method, crate::RawAcquisitionOrigin::Live, received_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_account_state_preserves_complete_common_fields_and_redacts_data_debug() {
|
||||
let network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let pubkey = ksp_core_lib::Pubkey::new_from_array([1_u8; 32]);
|
||||
let owner = ksp_core_lib::Pubkey::new_from_array([2_u8; 32]);
|
||||
let reference = crate::RawAccountStateReference::new(network, pubkey, 42, crate::RawContentHash::new([3_u8; 32]));
|
||||
let data = b"ACCOUNT_DATA_SENTINEL_NEVER_RENDER".to_vec().into_boxed_slice();
|
||||
let state_result = crate::RawAccountState::try_new(reference.clone(), 500, owner, false, 7, data);
|
||||
assert!(state_result.is_ok());
|
||||
let state = match state_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(state.reference(), &reference);
|
||||
assert_eq!(state.lamports(), 500);
|
||||
assert!(!state.executable());
|
||||
assert_eq!(state.rent_epoch(), 7);
|
||||
assert_eq!(state.data(), b"ACCOUNT_DATA_SENTINEL_NEVER_RENDER");
|
||||
let debug = format!("{state:?}");
|
||||
assert!(!debug.contains("ACCOUNT_DATA_SENTINEL_NEVER_RENDER"));
|
||||
assert!(debug.contains("data_len"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_account_state_rejects_only_oversized_data_and_allows_empty_accounts() {
|
||||
let first_network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let first_reference =
|
||||
crate::RawAccountStateReference::new(first_network, ksp_core_lib::Pubkey::new_from_array([4_u8; 32]), 1, crate::RawContentHash::new([5_u8; 32]));
|
||||
let empty = crate::RawAccountState::try_new(
|
||||
first_reference,
|
||||
0,
|
||||
ksp_core_lib::Pubkey::new_from_array([6_u8; 32]),
|
||||
false,
|
||||
0,
|
||||
std::vec::Vec::new().into_boxed_slice(),
|
||||
);
|
||||
assert!(empty.is_ok());
|
||||
let second_network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let second_reference =
|
||||
crate::RawAccountStateReference::new(second_network, ksp_core_lib::Pubkey::new_from_array([7_u8; 32]), 2, crate::RawContentHash::new([8_u8; 32]));
|
||||
let oversized = vec![0_u8; crate::MAX_RAW_ACCOUNT_DATA_BYTES + 1].into_boxed_slice();
|
||||
let rejected = crate::RawAccountState::try_new(second_reference, 0, ksp_core_lib::Pubkey::new_from_array([9_u8; 32]), false, 0, oversized);
|
||||
assert!(rejected.is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_account_observation_keeps_yellowstone_specific_metadata_optional() {
|
||||
let network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let reference =
|
||||
crate::RawAccountStateReference::new(network, ksp_core_lib::Pubkey::new_from_array([10_u8; 32]), 99, crate::RawContentHash::new([11_u8; 32]));
|
||||
let provenance = match provenance() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let observation = crate::RawAccountObservation::new(crate::RawObservationKey::new([12_u8; 32]), reference.clone(), provenance)
|
||||
.with_write_version(17)
|
||||
.with_transaction_signature(crate::RawTransactionSignature::new([13_u8; 64]))
|
||||
.with_is_startup(false);
|
||||
assert_eq!(observation.account(), &reference);
|
||||
assert_eq!(observation.write_version(), std::option::Option::Some(17));
|
||||
assert_eq!(observation.is_startup(), std::option::Option::Some(false));
|
||||
assert_eq!(observation.transaction_signature(), std::option::Option::Some(crate::RawTransactionSignature::new([13_u8; 64])));
|
||||
return;
|
||||
}
|
||||
54
crates/ksp-store-api/unit_tests/model/raw_pagination.rs
Normal file
54
crates/ksp-store-api/unit_tests/model/raw_pagination.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_pagination.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn page_limit_rejects_only_zero_and_keeps_no_ksp_policy_maximum() {
|
||||
assert!(crate::RawPageLimit::new(0).is_err());
|
||||
let maximum = crate::RawPageLimit::new(u64::MAX);
|
||||
assert!(maximum.is_ok());
|
||||
let maximum = match maximum {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(maximum.get(), u64::MAX);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_is_opaque_bounded_and_debug_does_not_render_bytes() {
|
||||
assert!(crate::RawPageCursor::try_new(std::boxed::Box::new([])).is_err());
|
||||
assert!(crate::RawPageCursor::try_new(vec![7_u8; crate::MAX_RAW_PAGE_CURSOR_BYTES + 1].into_boxed_slice()).is_err());
|
||||
let cursor = crate::RawPageCursor::try_new(vec![0xAB_u8; 8].into_boxed_slice());
|
||||
assert!(cursor.is_ok());
|
||||
let cursor = match cursor {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let debug = format!("{cursor:?}");
|
||||
assert!(debug.contains("len"));
|
||||
assert!(!debug.contains("171"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_query_preserves_caller_limit_range_direction_and_network() {
|
||||
let network = match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let slots = match crate::RawSlotRange::new(std::option::Option::Some(10), std::option::Option::Some(20)) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let limit = match crate::RawPageLimit::new(5_000_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let query = crate::RawTransactionQuery::new(network, slots, crate::RawSortDirection::Ascending, crate::RawPageRequest::first(limit));
|
||||
assert_eq!(query.network().as_str(), "mainnet-beta");
|
||||
assert_eq!(query.slots().start_inclusive(), std::option::Option::Some(10));
|
||||
assert_eq!(query.page().limit().get(), 5_000_000);
|
||||
assert_eq!(query.direction(), crate::RawSortDirection::Ascending);
|
||||
assert!(crate::RawSlotRange::new(std::option::Option::Some(20), std::option::Option::Some(10)).is_err());
|
||||
return;
|
||||
}
|
||||
197
crates/ksp-store-api/unit_tests/model/raw_primitives.rs
Normal file
197
crates/ksp-store-api/unit_tests/model/raw_primitives.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_primitives.rs
|
||||
// version: 1
|
||||
|
||||
fn code(value: &str) -> std::option::Option<crate::RawProvenanceCode> {
|
||||
return match crate::RawProvenanceCode::new(value.to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn timestamp(unix_millis: u64) -> std::option::Option<crate::RawTimestamp> {
|
||||
return match crate::RawTimestamp::from_unix_millis(unix_millis) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_codes_are_bounded_nonempty_and_reject_url_like_or_control_values() {
|
||||
let network = crate::RawNetworkId::new("mainnet-beta".to_owned());
|
||||
assert!(network.is_ok());
|
||||
let format = crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned());
|
||||
assert!(format.is_ok());
|
||||
let maximum = "a".repeat(crate::MAX_RAW_CODE_BYTES);
|
||||
assert!(crate::RawProvenanceCode::new(maximum).is_ok());
|
||||
assert!(crate::RawProvenanceCode::new(std::string::String::new()).is_err());
|
||||
assert!(crate::RawProvenanceCode::new("bad value".to_owned()).is_err());
|
||||
assert!(crate::RawProvenanceCode::new("https://secret.example".to_owned()).is_err());
|
||||
assert!(crate::RawProvenanceCode::new("bad\nvalue".to_owned()).is_err());
|
||||
assert!(crate::RawProvenanceCode::new("a".repeat(crate::MAX_RAW_CODE_BYTES + 1)).is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_timestamp_accepts_documented_maximum_and_rejects_larger_values() {
|
||||
let maximum = crate::RawTimestamp::from_unix_millis(crate::MAX_RAW_UNIX_MILLIS);
|
||||
assert!(maximum.is_ok());
|
||||
let too_large = crate::RawTimestamp::from_unix_millis(crate::MAX_RAW_UNIX_MILLIS + 1);
|
||||
assert!(too_large.is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_payload_is_nonempty_versioned_bounded_and_debug_omits_bytes() {
|
||||
let format_result = crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned());
|
||||
assert!(format_result.is_ok());
|
||||
let format = match format_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let sentinel = b"RAW_PAYLOAD_SENTINEL_NEVER_RENDER".to_vec().into_boxed_slice();
|
||||
let payload_result = crate::RawPayload::try_new(format, 1, sentinel, crate::RawContentHash::new([7_u8; 32]));
|
||||
assert!(payload_result.is_ok());
|
||||
let payload = match payload_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(payload.bytes(), b"RAW_PAYLOAD_SENTINEL_NEVER_RENDER");
|
||||
assert_eq!(payload.byte_len(), b"RAW_PAYLOAD_SENTINEL_NEVER_RENDER".len());
|
||||
assert_eq!(payload.format_version(), 1);
|
||||
let debug = format!("{payload:?}");
|
||||
assert!(!debug.contains("RAW_PAYLOAD_SENTINEL_NEVER_RENDER"));
|
||||
assert!(debug.contains("len"));
|
||||
let empty_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(crate::RawPayload::try_new(empty_format, 1, std::vec::Vec::new().into_boxed_slice(), crate::RawContentHash::new([0_u8; 32])).is_err());
|
||||
let zero_version_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(crate::RawPayload::try_new(zero_version_format, 0, vec![1_u8].into_boxed_slice(), crate::RawContentHash::new([0_u8; 32])).is_err());
|
||||
let oversized_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let oversized = vec![0_u8; crate::MAX_RAW_PAYLOAD_BYTES + 1].into_boxed_slice();
|
||||
let error = crate::RawPayload::try_new(oversized_format, 1, oversized, crate::RawContentHash::new([0_u8; 32]));
|
||||
assert!(error.is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquisition_provenance_preserves_safe_metadata_and_validates_time_and_size() {
|
||||
let received_at = match timestamp(2_000) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let observed_at = match timestamp(1_000) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provider = match code("publicnode") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let protocol = match code("yellowstone_grpc") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let method = match code("transactions") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let endpoint = match code("main") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let commitment = match code("confirmed") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let session = match code("session_1") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let filter = match code("all_transactions") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provenance = crate::RawAcquisitionProvenance::new(provider, protocol, method, crate::RawAcquisitionOrigin::Live, received_at)
|
||||
.with_endpoint_id(endpoint)
|
||||
.with_commitment(commitment)
|
||||
.with_capture_session_id(session)
|
||||
.with_filter_id(filter)
|
||||
.with_source_payload_hash(crate::RawContentHash::new([3_u8; 32]));
|
||||
let provenance_result = provenance.try_with_observed_at(observed_at);
|
||||
assert!(provenance_result.is_ok());
|
||||
let provenance = match provenance_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let provenance_result = provenance.try_with_source_payload_size_bytes(4_096);
|
||||
assert!(provenance_result.is_ok());
|
||||
let provenance = match provenance_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(provenance.provider().as_str(), "publicnode");
|
||||
assert_eq!(provenance.protocol().as_str(), "yellowstone_grpc");
|
||||
assert_eq!(provenance.acquisition_method().as_str(), "transactions");
|
||||
assert_eq!(provenance.origin(), crate::RawAcquisitionOrigin::Live);
|
||||
assert_eq!(provenance.observed_at(), std::option::Option::Some(observed_at));
|
||||
assert_eq!(provenance.received_at(), received_at);
|
||||
assert_eq!(provenance.source_payload_size_bytes(), std::option::Option::Some(4_096));
|
||||
let reversed_provider = match code("provider") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let reversed_protocol = match code("solana_websocket") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let reversed_method = match code("transactionSubscribe") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let reversed_received = match timestamp(1_000) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let reversed_observed = match timestamp(2_000) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let reversed =
|
||||
crate::RawAcquisitionProvenance::new(reversed_provider, reversed_protocol, reversed_method, crate::RawAcquisitionOrigin::Live, reversed_received)
|
||||
.try_with_observed_at(reversed_observed);
|
||||
assert!(reversed.is_err());
|
||||
let oversized_provider = match code("provider") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let oversized_protocol = match code("solana_http") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let oversized_method = match code("getTransaction") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let oversized_received = match timestamp(2_000) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let oversized_source = crate::RawAcquisitionProvenance::new(
|
||||
oversized_provider,
|
||||
oversized_protocol,
|
||||
oversized_method,
|
||||
crate::RawAcquisitionOrigin::Backfill,
|
||||
oversized_received,
|
||||
)
|
||||
.try_with_source_payload_size_bytes(crate::MAX_RAW_SOURCE_PAYLOAD_BYTES + 1);
|
||||
assert!(oversized_source.is_err());
|
||||
return;
|
||||
}
|
||||
60
crates/ksp-store-api/unit_tests/model/raw_retention.rs
Normal file
60
crates/ksp-store-api/unit_tests/model/raw_retention.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_retention.rs
|
||||
// version: 2
|
||||
|
||||
fn reference() -> std::option::Option<crate::RawTransactionReference> {
|
||||
let network = match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(crate::RawTransactionReference::new(network, crate::RawTransactionSignature::new([3_u8; 64])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_transitions_are_forward_only_and_force_rehydrate_is_separate() {
|
||||
let reference = match reference() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Full, crate::RawRetentionState::Compacted).is_ok());
|
||||
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Full, crate::RawRetentionState::Archived).is_ok());
|
||||
assert!(
|
||||
crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Compacted, crate::RawRetentionState::Archived).is_ok()
|
||||
);
|
||||
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Archived, crate::RawRetentionState::Purged).is_ok());
|
||||
assert!(crate::RawTransactionRetentionTransition::try_new(reference.clone(), crate::RawRetentionState::Purged, crate::RawRetentionState::Full).is_err());
|
||||
assert!(crate::RawTransactionRetentionTransition::try_new(reference, crate::RawRetentionState::Full, crate::RawRetentionState::Purged).is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_keeps_minimal_identity_without_raw_payload() {
|
||||
let reference = match reference() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let tombstone = crate::RawTransactionTombstone::try_new(reference, 99, format, 1, crate::RawContentHash::new([4_u8; 32]));
|
||||
assert!(tombstone.is_ok());
|
||||
let tombstone = match tombstone {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(tombstone.slot(), 99);
|
||||
assert_eq!(tombstone.format_version(), 1);
|
||||
let second_format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(crate::RawTransactionTombstone::try_new(tombstone.reference().clone(), tombstone.slot(), second_format, 0, tombstone.content_hash()).is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_outcome_distinguishes_target_idempotence_from_lost_compare_and_transition() {
|
||||
assert_ne!(crate::RawRetentionWriteOutcome::AlreadyAtTarget, crate::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||
assert_ne!(crate::RawRetentionWriteOutcome::Applied, crate::RawRetentionWriteOutcome::ExpectedStateMismatch);
|
||||
return;
|
||||
}
|
||||
85
crates/ksp-store-api/unit_tests/model/raw_transaction.rs
Normal file
85
crates/ksp-store-api/unit_tests/model/raw_transaction.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
// file: crates/ksp-store-api/unit_tests/model/raw_transaction.rs
|
||||
// version: 1
|
||||
|
||||
fn network() -> std::option::Option<crate::RawNetworkId> {
|
||||
return match crate::RawNetworkId::new("mainnet-beta".to_owned()) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn payload() -> std::option::Option<crate::RawPayload> {
|
||||
let format = match crate::RawFormatId::new("ksp.solana.raw_transaction".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return match crate::RawPayload::try_new(
|
||||
format,
|
||||
1,
|
||||
b"canonical transaction including logs".to_vec().into_boxed_slice(),
|
||||
crate::RawContentHash::new([5_u8; 32]),
|
||||
) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn provenance() -> std::option::Option<crate::RawAcquisitionProvenance> {
|
||||
let provider = match crate::RawProvenanceCode::new("publicnode".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let protocol = match crate::RawProvenanceCode::new("yellowstone_grpc".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let method = match crate::RawProvenanceCode::new("transactions".to_owned()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let received_at = match crate::RawTimestamp::from_unix_millis(1_000) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(crate::RawAcquisitionProvenance::new(provider, protocol, method, crate::RawAcquisitionOrigin::Backfill, received_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_transaction_identity_is_network_plus_signature_and_payload_remains_whole() {
|
||||
let network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let payload = match payload() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let signature = crate::RawTransactionSignature::new([9_u8; 64]);
|
||||
let reference = crate::RawTransactionReference::new(network, signature);
|
||||
let transaction = crate::RawTransaction::new(reference, 42, std::option::Option::None, payload);
|
||||
assert_eq!(transaction.reference().network().as_str(), "mainnet-beta");
|
||||
assert_eq!(transaction.reference().signature(), signature);
|
||||
assert_eq!(transaction.slot(), 42);
|
||||
assert!(transaction.block_time().is_none());
|
||||
assert_eq!(transaction.payload().bytes(), b"canonical transaction including logs");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_observation_is_separate_from_canonical_raw_payload() {
|
||||
let network = match network() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let provenance = match provenance() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let signature = crate::RawTransactionSignature::new([11_u8; 64]);
|
||||
let reference = crate::RawTransactionReference::new(network, signature);
|
||||
let observation = crate::RawTransactionObservation::new(crate::RawObservationKey::new([12_u8; 32]), reference, provenance);
|
||||
assert_eq!(observation.transaction().signature(), signature);
|
||||
assert_eq!(observation.observation_key().as_bytes(), &[12_u8; 32]);
|
||||
assert_eq!(observation.provenance().provider().as_str(), "publicnode");
|
||||
return;
|
||||
}
|
||||
20
crates/ksp-store-lib/Cargo.toml
Normal file
20
crates/ksp-store-lib/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
# file: crates/ksp-store-lib/Cargo.toml
|
||||
# version: 2
|
||||
|
||||
[package]
|
||||
name = "ksp-store-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["postgres"]
|
||||
postgres = ["dep:ksp-store-postgres-lib"]
|
||||
|
||||
[dependencies]
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-store-api = { path = "../ksp-store-api" }
|
||||
ksp-store-postgres-lib = { path = "../ksp-store-postgres-lib", optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
115
crates/ksp-store-lib/README.md
Normal file
115
crates/ksp-store-lib/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<!-- file: crates/ksp-store-lib/README.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# 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 dix capabilities RAW de `ksp-store-api` dispatchées vers le backend compilé : six `RawTransaction*` et quatre `RawAccount*` ;
|
||||
- une validation réseau backend-neutral avant dispatch pour toutes les opérations qui portent explicitement un réseau ;
|
||||
- les réexports crate-root de `ksp-store-api` nécessaires aux consumers ordinaires.
|
||||
|
||||
## Une instance = un réseau
|
||||
|
||||
Une instance `Store` représente exactement :
|
||||
|
||||
```text
|
||||
1 Store = 1 RawNetworkId + 1 backend physique sélectionné
|
||||
```
|
||||
|
||||
Le runtime Store n'est pas un multiplexeur multi-database ou multi-réseau. La sélection d'un target nommé appartient à Config. Le document `std.store` peut donc définir plusieurs targets indépendants, mais un appel à `Store::open` reçoit les settings d'un seul target.
|
||||
|
||||
Cette séparation permet d'utiliser des bases PostgreSQL distinctes par réseau tout en conservant le réseau dans l'identité logique des données RAW.
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Avec la feature par défaut :
|
||||
|
||||
```text
|
||||
ksp-store-lib
|
||||
-> ksp-store-api
|
||||
-> ksp-logging-lib
|
||||
-> ksp-store-postgres-lib
|
||||
```
|
||||
|
||||
`ksp-store-lib` ne réexporte aucun type `tokio-postgres`, Deadpool ou Rustls.
|
||||
|
||||
Les modes TLS publics sont volontairement limités à :
|
||||
|
||||
```text
|
||||
Disabled
|
||||
VerifyFull
|
||||
```
|
||||
|
||||
`VerifyFull` impose TLS avec vérification de la chaîne et de l'identité serveur. La policy typée Store prime sur les paramètres TLS présents dans l'URI.
|
||||
|
||||
## Config et secrets
|
||||
|
||||
Store ne lit ni `.env`, ni variables `KSP_*` / `KSPB_*`, ni variables/fichiers implicites libpq (`PG*`, `.pgpass`, fichiers TLS PostgreSQL).
|
||||
|
||||
`ksp-config-lib` possède `std.store`, la résolution des secrets et la sélection du target. Il construit ensuite un `StoreSettings` backend-neutral. L'URI PostgreSQL reste nécessaire au runtime mais n'a aucun getter public dans `ksp-store-lib` et son `Debug` est redacted.
|
||||
|
||||
## Surface RAW
|
||||
|
||||
`Store` implémente exactement dix capabilities RAW backend-neutral :
|
||||
|
||||
```text
|
||||
RawTransactionRead
|
||||
RawTransactionWrite
|
||||
RawTransactionObservationRead
|
||||
RawTransactionObservationWrite
|
||||
RawTransactionRetentionRead
|
||||
RawTransactionRetentionWrite
|
||||
RawAccountStateRead
|
||||
RawAccountStateWrite
|
||||
RawAccountObservationRead
|
||||
RawAccountObservationWrite
|
||||
```
|
||||
|
||||
Pour `RawTransaction`, la façade fournit la lecture canonique et des observations, l'acquisition atomique transaction+observation, l'ajout idempotent d'observations, la pagination keyset et les transitions de rétention demandées par le caller.
|
||||
|
||||
Pour `RawAccountState`, elle fournit :
|
||||
|
||||
- lecture d'un état complet par référence durable `(network, pubkey, slot, state_hash)` ;
|
||||
- pagination keyset des références dans l'ordre total `(slot, pubkey, state_hash)`, avec filtre pubkey optionnel ;
|
||||
- écriture atomique état+observation avec idempotence exacte et conflit sur contenu divergent ;
|
||||
- lecture et ajout d'observations account ;
|
||||
- validation réseau avant dispatch pour les opérations dont l'input porte explicitement un réseau.
|
||||
|
||||
Les cursors restent opaques et propres à leur famille. Store ne leur attribue aucune sémantique de batch, priorité ou scheduling.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
Sont volontairement hors de cette surface :
|
||||
|
||||
- rétention, archivage, purge, delete ou compaction de `RawAccountState` ;
|
||||
- batch-size, priorité, backlog ou policy de worker/job ;
|
||||
- transport d'acquisition, Program decoding et materialization ;
|
||||
- exposition publique de SQL, pool, client, row, statement ou transaction PostgreSQL.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — guide pratique de construction, lifecycle et capabilities Store ;
|
||||
- [`../ksp-store-postgres-lib/README.md`](../ksp-store-postgres-lib/README.md) — responsabilité du backend PostgreSQL physique ;
|
||||
- [`../../config/std.store.json`](../../config/std.store.json) — targets Store committed ;
|
||||
- [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) — architecture durable Store ;
|
||||
- [`../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md`](../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md) — plan de fondation ;
|
||||
- [`../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md`](../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md) — validation de fondation ;
|
||||
- [`../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md`](../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md) — plan `RawTransaction` ;
|
||||
- [`../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md`](../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md) — validation `RawTransaction` ;
|
||||
- [`../../docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md`](../../docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md) — design `RawAccountState` et complétude RAW ;
|
||||
- [`../../docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md`](../../docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md) — validation `RawAccountState` et conformance 10/10.
|
||||
384
crates/ksp-store-lib/USAGE.md
Normal file
384
crates/ksp-store-lib/USAGE.md
Normal file
@@ -0,0 +1,384 @@
|
||||
<!-- file: crates/ksp-store-lib/USAGE.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# Utilisation de ksp-store-lib
|
||||
|
||||
## 1. Dépendance et backend compilé
|
||||
|
||||
Le consumer runtime dépend de la façade commune :
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ksp-store-lib = { path = "../ksp-store-lib" }
|
||||
```
|
||||
|
||||
La feature par défaut compile le backend PostgreSQL :
|
||||
|
||||
```text
|
||||
postgres
|
||||
```
|
||||
|
||||
Pour compiler la façade sans backend physique :
|
||||
|
||||
```toml
|
||||
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||
```
|
||||
|
||||
Dans ce mode, les settings PostgreSQL restent représentables, mais `Store::open` retourne `ERROR_CODE_BACKEND_NOT_COMPILED` avant toute I/O si PostgreSQL est sélectionné.
|
||||
|
||||
Un consumer applicatif ordinaire ne dépend pas directement de `ksp-store-postgres-lib`.
|
||||
|
||||
## 2. Obtenir les settings depuis Config
|
||||
|
||||
Le chemin applicatif recommandé passe par `ksp-config-lib`, propriétaire de `std.store`, de la résolution `.env` et des secrets.
|
||||
|
||||
```rust
|
||||
fn resolve_store_settings(
|
||||
engine: &ksp_config_lib::ConfigDocumentEngine,
|
||||
environment: &ksp_config_lib::ConfigEnvironment,
|
||||
target: std::option::Option<&str>,
|
||||
) -> ksp_core_lib::Result<ksp_store_lib::StoreSettings> {
|
||||
let resolved = engine.load_resolved_store_config(target, environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
return std::result::Result::Ok(resolved.into_settings());
|
||||
}
|
||||
```
|
||||
|
||||
Chaque `StoreSettings` sélectionne exactement un réseau logique et un backend physique. La sélection d'un target nommé appartient à Config ; `Store` ne route pas automatiquement entre plusieurs targets.
|
||||
|
||||
## 3. Construire des settings programmatiquement
|
||||
|
||||
La construction directe est utile pour les tests et outils qui ne passent pas par Config.
|
||||
|
||||
```rust
|
||||
fn programmatic_store_settings(connection_uri: std::string::String) -> ksp_store_lib::Result<ksp_store_lib::StoreSettings> {
|
||||
let network = ksp_store_lib::RawNetworkId::new("devnet");
|
||||
let network = match network {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let postgres = ksp_store_lib::PostgresStoreSettings::new(
|
||||
connection_uri,
|
||||
ksp_store_lib::PostgresPoolSettings::default(),
|
||||
ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||
ksp_store_lib::PostgresBootstrapSettings::default(),
|
||||
);
|
||||
|
||||
let settings = ksp_store_lib::StoreSettings::with_default_shutdown(
|
||||
network,
|
||||
ksp_store_lib::StoreBackendSettings::Postgres(postgres),
|
||||
);
|
||||
|
||||
if let std::result::Result::Err(error) = settings.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
return std::result::Result::Ok(settings);
|
||||
}
|
||||
```
|
||||
|
||||
`PostgresStoreSettings` ne fournit aucun getter public de l'URI. Son `Debug` remplace cette valeur par `<redacted>`.
|
||||
|
||||
## 4. Ouvrir, sonder et fermer un Store
|
||||
|
||||
`Store::open` est async et ne retourne un succès qu'après validation des settings, ouverture du backend compilé et bootstrap requis.
|
||||
|
||||
```rust
|
||||
async fn use_store(settings: ksp_store_lib::StoreSettings) -> ksp_store_lib::Result<()> {
|
||||
let store = ksp_store_lib::Store::open(settings).await;
|
||||
let store = match store {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let runtime = store.runtime_snapshot();
|
||||
let _network = runtime.network();
|
||||
let _capacity = runtime.pool_capacity();
|
||||
let _size = runtime.pool_size();
|
||||
let _available = runtime.pool_available();
|
||||
let _waiting = runtime.pool_waiting();
|
||||
|
||||
let health = store.health().await;
|
||||
match health.state() {
|
||||
ksp_store_lib::StoreHealthState::Ready => {}
|
||||
ksp_store_lib::StoreHealthState::NotReady => {
|
||||
let _safe_error_code = health.last_error_code();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
return store.close().await;
|
||||
}
|
||||
```
|
||||
|
||||
`Store::close(self)` consomme l'instance. Une fermeture explicite ne peut donc pas être suivie d'une nouvelle opération via la même valeur.
|
||||
|
||||
## 5. Lire une transaction RAW
|
||||
|
||||
Importer le trait correspondant suffit pour utiliser la façade :
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawTransactionRead;
|
||||
|
||||
async fn read_transaction(
|
||||
store: &ksp_store_lib::Store,
|
||||
reference: &ksp_store_lib::RawTransactionReference,
|
||||
) -> ksp_store_lib::Result<std::option::Option<ksp_store_lib::RawTransaction>> {
|
||||
return store.get_raw_transaction(reference).await;
|
||||
}
|
||||
```
|
||||
|
||||
Une transaction absente retourne `None`. Une transaction `Purged` retourne également `None` pour le payload canonique ; son tombstone reste accessible via la capability de rétention.
|
||||
|
||||
## 6. Paginer les références de transactions
|
||||
|
||||
La pagination est keyset et utilise un cursor opaque. Le consumer ne doit pas interpréter ses bytes.
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawTransactionRead;
|
||||
|
||||
async fn first_transaction_page(
|
||||
store: &ksp_store_lib::Store,
|
||||
network: ksp_store_lib::RawNetworkId,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawPage<ksp_store_lib::RawTransactionReference>> {
|
||||
let limit = ksp_store_lib::RawPageLimit::new(100);
|
||||
let limit = match limit {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let slots = ksp_store_lib::RawSlotRange::new(std::option::Option::None, std::option::Option::None);
|
||||
let slots = match slots {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let query = ksp_store_lib::RawTransactionQuery::new(
|
||||
network,
|
||||
slots,
|
||||
ksp_store_lib::RawSortDirection::Ascending,
|
||||
ksp_store_lib::RawPageRequest::first(limit),
|
||||
);
|
||||
|
||||
return store.list_raw_transactions(&query).await;
|
||||
}
|
||||
```
|
||||
|
||||
Pour continuer, recopier le cursor retourné par `RawPage::next_cursor()` dans `RawPageRequest::after`. Le réseau, la direction et les bornes de slots doivent rester identiques à ceux de la query ayant produit le cursor.
|
||||
|
||||
## 7. Persister une acquisition canonique
|
||||
|
||||
La transaction canonique et son observation initiale forment une seule opération atomique.
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawTransactionWrite;
|
||||
|
||||
async fn persist_acquisition(
|
||||
store: &ksp_store_lib::Store,
|
||||
transaction: ksp_store_lib::RawTransaction,
|
||||
observation: ksp_store_lib::RawTransactionObservation,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawAcquisitionWriteOutcome> {
|
||||
return store
|
||||
.persist_raw_transaction_acquisition(
|
||||
transaction,
|
||||
observation,
|
||||
ksp_store_lib::RawTransactionAcquisitionMode::Normal,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
Le mode `Normal` respecte un tombstone `Purged`. `ForceRehydrate` doit être choisi explicitement lorsqu'un caller veut restaurer un payload purgé et que l'identité retenue est compatible.
|
||||
|
||||
Un contenu divergent sous la même identité produit `ERROR_CODE_RAW_CONFLICT`; Store ne remplace jamais silencieusement le contenu gagnant.
|
||||
|
||||
## 8. Lire et ajouter une observation
|
||||
|
||||
Une observation supplémentaire référence une transaction canonique déjà durable.
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawTransactionObservationRead;
|
||||
use ksp_store_lib::RawTransactionObservationWrite;
|
||||
|
||||
async fn use_observation(
|
||||
store: &ksp_store_lib::Store,
|
||||
key: &ksp_store_lib::RawObservationKey,
|
||||
observation: ksp_store_lib::RawTransactionObservation,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawObservationWriteOutcome> {
|
||||
let existing = store.get_raw_transaction_observation(key).await;
|
||||
if let std::result::Result::Err(error) = existing {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
return store.record_raw_transaction_observation(observation).await;
|
||||
}
|
||||
```
|
||||
|
||||
`record_raw_transaction_observation` ne crée pas implicitement le canonique. Une référence absente est signalée par `ERROR_CODE_RAW_REFERENCE_NOT_FOUND`.
|
||||
|
||||
## 9. Lire la rétention et le tombstone
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawTransactionRetentionRead;
|
||||
|
||||
async fn read_retention(
|
||||
store: &ksp_store_lib::Store,
|
||||
reference: &ksp_store_lib::RawTransactionReference,
|
||||
) -> ksp_store_lib::Result<std::option::Option<ksp_store_lib::RawRetentionState>> {
|
||||
let tombstone = store.get_raw_transaction_tombstone(reference).await;
|
||||
if let std::result::Result::Err(error) = tombstone {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
return store.get_raw_transaction_retention_state(reference).await;
|
||||
}
|
||||
```
|
||||
|
||||
Le tombstone minimal est utile uniquement lorsque le payload a été purgé ; il ne remplace pas le modèle canonique lorsqu'un payload est encore disponible.
|
||||
|
||||
## 10. Appliquer une transition de rétention
|
||||
|
||||
La policy qui décide qu'une transition est autorisée appartient au caller. Store applique uniquement la transition demandée de manière atomique.
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawTransactionRetentionWrite;
|
||||
|
||||
async fn archive_transaction(
|
||||
store: &ksp_store_lib::Store,
|
||||
reference: ksp_store_lib::RawTransactionReference,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawRetentionWriteOutcome> {
|
||||
let transition = ksp_store_lib::RawTransactionRetentionTransition::try_new(
|
||||
reference,
|
||||
ksp_store_lib::RawRetentionState::Full,
|
||||
ksp_store_lib::RawRetentionState::Archived,
|
||||
);
|
||||
let transition = match transition {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
return store.transition_raw_transaction_retention(transition).await;
|
||||
}
|
||||
```
|
||||
|
||||
Le backend PostgreSQL supporte physiquement `Full -> Archived -> Purged`. Une transition impliquant `Compacted` est rejetée par ce backend tant qu'aucune représentation compactée réelle n'est implémentée.
|
||||
|
||||
## 11. Lire et paginer les états account RAW
|
||||
|
||||
Importer `RawAccountStateRead` donne accès à la lecture par référence et à la navigation déterministe.
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawAccountStateRead;
|
||||
|
||||
async fn read_account_state(
|
||||
store: &ksp_store_lib::Store,
|
||||
reference: &ksp_store_lib::RawAccountStateReference,
|
||||
) -> ksp_store_lib::Result<std::option::Option<ksp_store_lib::RawAccountState>> {
|
||||
return store.get_raw_account_state(reference).await;
|
||||
}
|
||||
```
|
||||
|
||||
Pour une première page :
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawAccountStateRead;
|
||||
|
||||
async fn first_account_page(
|
||||
store: &ksp_store_lib::Store,
|
||||
network: ksp_store_lib::RawNetworkId,
|
||||
pubkey: std::option::Option<ksp_store_lib::Pubkey>,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawPage<ksp_store_lib::RawAccountStateReference>> {
|
||||
let limit = ksp_store_lib::RawPageLimit::new(100);
|
||||
let limit = match limit {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let slots = ksp_store_lib::RawSlotRange::new(std::option::Option::None, std::option::Option::None);
|
||||
let slots = match slots {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let query = ksp_store_lib::RawAccountStateQuery::new(
|
||||
network,
|
||||
pubkey,
|
||||
slots,
|
||||
ksp_store_lib::RawSortDirection::Ascending,
|
||||
ksp_store_lib::RawPageRequest::first(limit),
|
||||
);
|
||||
|
||||
return store.list_raw_account_states(&query).await;
|
||||
}
|
||||
```
|
||||
|
||||
La continuation utilise uniquement `RawPage::next_cursor()` puis `RawPageRequest::after`. Le cursor account est opaque et lié au réseau, au filtre pubkey, à la direction et aux bornes de slots de la query qui l'a produit.
|
||||
|
||||
## 12. Persister une acquisition account
|
||||
|
||||
L'état canonique et son observation initiale sont une seule opération atomique.
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawAccountStateWrite;
|
||||
|
||||
async fn persist_account_acquisition(
|
||||
store: &ksp_store_lib::Store,
|
||||
state: ksp_store_lib::RawAccountState,
|
||||
observation: ksp_store_lib::RawAccountObservation,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawAcquisitionWriteOutcome> {
|
||||
return store.persist_raw_account_acquisition(state, observation).await;
|
||||
}
|
||||
```
|
||||
|
||||
`state.reference()` et `observation.account()` doivent désigner exactement le même état et le réseau du `Store`. Une répétition byte-identique est idempotente ; un contenu divergent sous la même identité retourne `ERROR_CODE_RAW_CONFLICT`.
|
||||
|
||||
## 13. Lire et ajouter une observation account
|
||||
|
||||
```rust
|
||||
use ksp_store_lib::RawAccountObservationRead;
|
||||
use ksp_store_lib::RawAccountObservationWrite;
|
||||
|
||||
async fn use_account_observation(
|
||||
store: &ksp_store_lib::Store,
|
||||
key: &ksp_store_lib::RawObservationKey,
|
||||
observation: ksp_store_lib::RawAccountObservation,
|
||||
) -> ksp_store_lib::Result<ksp_store_lib::RawObservationWriteOutcome> {
|
||||
let existing = store.get_raw_account_observation(key).await;
|
||||
if let std::result::Result::Err(error) = existing {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
return store.record_raw_account_observation(observation).await;
|
||||
}
|
||||
```
|
||||
|
||||
`record_raw_account_observation` ne crée jamais implicitement l'état canonique ; une référence absente retourne `ERROR_CODE_RAW_REFERENCE_NOT_FOUND`. `RawObservationKey` ne contient pas de réseau : la lecture par clé reste liée au backend mono-réseau déjà ouvert.
|
||||
|
||||
## 14. Diagnostics et erreurs
|
||||
|
||||
Les snapshots et erreurs de façade n'exposent ni URI, host, user, database, SQL, handle backend, valeur de bind ni texte d'erreur PostgreSQL.
|
||||
|
||||
Les codes Store utiles incluent notamment :
|
||||
|
||||
```text
|
||||
store.wrong_network
|
||||
store.raw_reference_not_found
|
||||
store.postgres_read_failed
|
||||
store.postgres_write_failed
|
||||
store.postgres_data_invalid
|
||||
store.postgres_page_limit_unsupported
|
||||
store.postgres_retention_compaction_unsupported
|
||||
```
|
||||
|
||||
Les conflits et queries invalides utilisent les codes backend-neutral `store_api.raw_conflict` et `store_api.raw_query_invalid`.
|
||||
|
||||
## 15. Limites de la façade
|
||||
|
||||
La façade ne fournit pas d'accès public au SQL, au pool, aux clients ou transactions PostgreSQL. Elle dispatch les dix capabilities RAW de l'API commune, mais ne fournit aucune capability de rétention, archivage, purge, delete ou compaction account.
|
||||
|
||||
La taille de page est une primitive de navigation. Les décisions de batch, priorité, backlog et scheduling appartiennent aux workers/jobs, pas à Store.
|
||||
7
crates/ksp-store-lib/src/constants.rs
Normal file
7
crates/ksp-store-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: crates/ksp-store-lib/src/constants.rs
|
||||
// version: 2
|
||||
|
||||
//! Store facade-owned constants.
|
||||
|
||||
/// Owning tracing target reserved for events emitted by the common Store runtime facade.
|
||||
pub(crate) const TRACING_TARGET: &str = "ksp-store-lib";
|
||||
44
crates/ksp-store-lib/src/error.rs
Normal file
44
crates/ksp-store-lib/src/error.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// file: crates/ksp-store-lib/src/error.rs
|
||||
// version: 6
|
||||
|
||||
/// Error code reserved for operations attempted after a Store backend has entered its closed state.
|
||||
pub const ERROR_CODE_BACKEND_CLOSED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_closed");
|
||||
/// Error code used when a known Store backend was selected but its Cargo feature is not compiled.
|
||||
pub const ERROR_CODE_BACKEND_NOT_COMPILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_not_compiled");
|
||||
/// Error code used when a compiled Store backend cannot complete its bounded opening lifecycle.
|
||||
pub const ERROR_CODE_BACKEND_OPEN_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_open_failed");
|
||||
/// Error code used when the PostgreSQL backend rejects or cannot normalize its physical connection configuration.
|
||||
pub const ERROR_CODE_POSTGRES_CONFIG_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_config_invalid");
|
||||
/// Error code used when PostgreSQL physical connection establishment fails without exposing remote or credential details.
|
||||
pub const ERROR_CODE_POSTGRES_CONNECT_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_connect_failed");
|
||||
/// Error code used when PostgreSQL returns persisted RAW data incompatible with the stable Store contract.
|
||||
pub const ERROR_CODE_POSTGRES_DATA_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_data_invalid");
|
||||
/// Error code used when a lightweight PostgreSQL health/readiness probe fails safely.
|
||||
pub const ERROR_CODE_POSTGRES_HEALTH_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_health_failed");
|
||||
/// Error code used when PostgreSQL migration/bootstrap execution fails without exposing server text or SQL.
|
||||
pub const ERROR_CODE_POSTGRES_MIGRATION_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_failed");
|
||||
/// Error code used when persisted PostgreSQL migration history diverges from the embedded immutable KSP history.
|
||||
pub const ERROR_CODE_POSTGRES_MIGRATION_MISMATCH: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_mismatch");
|
||||
/// Error code used when the requested RAW page size exceeds the exact PostgreSQL LIMIT representation boundary.
|
||||
pub const ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_page_limit_unsupported");
|
||||
/// Error code used when a bounded PostgreSQL pool wait, create or recycle operation reaches its deadline.
|
||||
pub const ERROR_CODE_POSTGRES_POOL_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_pool_timeout");
|
||||
/// Error code used when a PostgreSQL RAW read fails without exposing SQL, bind values or server text.
|
||||
pub const ERROR_CODE_POSTGRES_READ_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_read_failed");
|
||||
/// Error code used when PostgreSQL cannot represent a requested RAW retention compaction state.
|
||||
pub const ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED: ksp_store_api::ErrorCode =
|
||||
ksp_store_api::ErrorCode::new("store", "postgres_retention_compaction_unsupported");
|
||||
/// Error code used when PostgreSQL history contains a migration newer than this Store runtime understands.
|
||||
pub const ERROR_CODE_POSTGRES_SCHEMA_NEWER: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_schema_newer");
|
||||
/// Error code used when verified PostgreSQL TLS setup or negotiation cannot be completed safely.
|
||||
pub const ERROR_CODE_POSTGRES_TLS_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_tls_failed");
|
||||
/// Error code used when a PostgreSQL RAW write fails without exposing SQL, bind values or server text.
|
||||
pub const ERROR_CODE_POSTGRES_WRITE_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_write_failed");
|
||||
/// Error code used when a RAW write requires a canonical reference that is not durable.
|
||||
pub const ERROR_CODE_RAW_REFERENCE_NOT_FOUND: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "raw_reference_not_found");
|
||||
/// Error code used when backend-neutral Store settings violate runtime bounds or invariants.
|
||||
pub const ERROR_CODE_SETTINGS_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "settings_invalid");
|
||||
/// Error code used when a Store cannot complete its explicit shutdown inside the configured bound.
|
||||
pub const ERROR_CODE_SHUTDOWN_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "shutdown_timeout");
|
||||
/// Error code used when a network-scoped Store operation targets a network different from the opened Store binding.
|
||||
pub const ERROR_CODE_WRONG_NETWORK: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "wrong_network");
|
||||
132
crates/ksp-store-lib/src/health.rs
Normal file
132
crates/ksp-store-lib/src/health.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
// file: crates/ksp-store-lib/src/health.rs
|
||||
// version: 1
|
||||
|
||||
/// Portable Store health state independent from the selected physical backend.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum StoreHealthState {
|
||||
/// The selected Store backend answered the bounded readiness probe and its migration foundation is current.
|
||||
Ready,
|
||||
/// The Store instance exists but its latest bounded readiness probe could not prove readiness.
|
||||
NotReady,
|
||||
}
|
||||
|
||||
/// Safe synchronous Store runtime snapshot without performing backend I/O.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StoreRuntimeSnapshot {
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
pool_available: u32,
|
||||
pool_capacity: u32,
|
||||
pool_size: u32,
|
||||
pool_waiting: u32,
|
||||
}
|
||||
|
||||
impl StoreRuntimeSnapshot {
|
||||
/// Creates one portable runtime projection from backend-owned safe counters.
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
pool_capacity: u32,
|
||||
pool_size: u32,
|
||||
pool_available: u32,
|
||||
pool_waiting: u32,
|
||||
) -> Self {
|
||||
return Self { backend_kind, network, pool_available, pool_capacity, pool_size, pool_waiting };
|
||||
}
|
||||
|
||||
/// Returns the selected backend identity.
|
||||
#[must_use]
|
||||
pub const fn backend_kind(&self) -> crate::StoreBackendKind {
|
||||
return self.backend_kind;
|
||||
}
|
||||
|
||||
/// Returns the one logical network bound to this Store instance.
|
||||
#[must_use]
|
||||
pub const fn network(&self) -> &ksp_store_api::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns the number of currently available pooled backend objects.
|
||||
#[must_use]
|
||||
pub const fn pool_available(&self) -> u32 {
|
||||
return self.pool_available;
|
||||
}
|
||||
|
||||
/// Returns the configured maximum pooled backend object count.
|
||||
#[must_use]
|
||||
pub const fn pool_capacity(&self) -> u32 {
|
||||
return self.pool_capacity;
|
||||
}
|
||||
|
||||
/// Returns the current pooled backend object count.
|
||||
#[must_use]
|
||||
pub const fn pool_size(&self) -> u32 {
|
||||
return self.pool_size;
|
||||
}
|
||||
|
||||
/// Returns the number of tasks currently waiting for a pooled backend object.
|
||||
#[must_use]
|
||||
pub const fn pool_waiting(&self) -> u32 {
|
||||
return self.pool_waiting;
|
||||
}
|
||||
}
|
||||
|
||||
/// Portable Store readiness projection containing only safe runtime and migration diagnostics.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StoreHealthSnapshot {
|
||||
last_error_code: std::option::Option<ksp_store_api::ErrorCode>,
|
||||
migration_version: std::option::Option<u64>,
|
||||
pending_migration_count: u32,
|
||||
runtime: StoreRuntimeSnapshot,
|
||||
state: StoreHealthState,
|
||||
}
|
||||
|
||||
impl StoreHealthSnapshot {
|
||||
/// Creates one safe health projection from already classified backend diagnostics.
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
state: StoreHealthState,
|
||||
runtime: StoreRuntimeSnapshot,
|
||||
migration_version: std::option::Option<u64>,
|
||||
pending_migration_count: u32,
|
||||
last_error_code: std::option::Option<ksp_store_api::ErrorCode>,
|
||||
) -> Self {
|
||||
return Self { last_error_code, migration_version, pending_migration_count, runtime, state };
|
||||
}
|
||||
|
||||
/// Returns the latest safe error code when readiness could not be proven.
|
||||
#[must_use]
|
||||
pub const fn last_error_code(&self) -> std::option::Option<ksp_store_api::ErrorCode> {
|
||||
return self.last_error_code;
|
||||
}
|
||||
|
||||
/// Returns the migration version observed by the readiness probe when available.
|
||||
#[must_use]
|
||||
pub const fn migration_version(&self) -> std::option::Option<u64> {
|
||||
return self.migration_version;
|
||||
}
|
||||
|
||||
/// Returns the number of embedded migrations newer than the observed applied version.
|
||||
#[must_use]
|
||||
pub const fn pending_migration_count(&self) -> u32 {
|
||||
return self.pending_migration_count;
|
||||
}
|
||||
|
||||
/// Returns the safe synchronous runtime projection captured for this health probe.
|
||||
#[must_use]
|
||||
pub const fn runtime(&self) -> &StoreRuntimeSnapshot {
|
||||
return &self.runtime;
|
||||
}
|
||||
|
||||
/// Returns whether this probe proved the Store ready.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> StoreHealthState {
|
||||
return self.state;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/health.rs"]
|
||||
mod tests;
|
||||
214
crates/ksp-store-lib/src/lib.rs
Normal file
214
crates/ksp-store-lib/src/lib.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
// file: crates/ksp-store-lib/src/lib.rs
|
||||
// version: 9
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Common backend-neutral Store runtime facade for KSP.
|
||||
//!
|
||||
//! The runtime facade owns backend selection, lifecycle, safe diagnostics and
|
||||
//! backend-neutral capability dispatch. `0.3.3-pre.008` completed the six PostgreSQL
|
||||
//! `RawTransaction` capabilities. `0.3.4-pre.008` adds the four `RawAccount*` capabilities
|
||||
//! on both the physical backend and this common facade, completing the RAW inventory at
|
||||
//! ten capabilities without exposing physical types.
|
||||
//!
|
||||
//! The default `postgres` feature compiles the official PostgreSQL backend as
|
||||
//! an optional implementation dependency. No backend implementation type is
|
||||
//! part of this crate's public surface.
|
||||
|
||||
mod constants;
|
||||
mod error;
|
||||
mod health;
|
||||
mod settings;
|
||||
mod store;
|
||||
|
||||
/// Error code reserved for operations attempted after a Store backend is closed.
|
||||
pub use self::error::ERROR_CODE_BACKEND_CLOSED;
|
||||
/// Error code used when a known Store backend is selected without its compiled feature.
|
||||
pub use self::error::ERROR_CODE_BACKEND_NOT_COMPILED;
|
||||
/// Error code used when a compiled Store backend cannot complete opening.
|
||||
pub use self::error::ERROR_CODE_BACKEND_OPEN_FAILED;
|
||||
/// Error code used when PostgreSQL physical configuration is malformed or unsupported.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_CONFIG_INVALID;
|
||||
/// Error code used when PostgreSQL physical connection establishment fails.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_CONNECT_FAILED;
|
||||
/// Error code used when PostgreSQL returns persisted RAW data incompatible with the Store contract.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_DATA_INVALID;
|
||||
/// Error code used when a lightweight PostgreSQL health/readiness probe fails safely.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_HEALTH_FAILED;
|
||||
/// Error code used when PostgreSQL migration/bootstrap execution fails safely.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_FAILED;
|
||||
/// Error code used when PostgreSQL migration history diverges from the embedded immutable KSP history.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH;
|
||||
/// Error code used when a requested RAW page size exceeds PostgreSQL's exact physical LIMIT boundary.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED;
|
||||
/// Error code used when a bounded PostgreSQL pool operation reaches its deadline.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_POOL_TIMEOUT;
|
||||
/// Error code used when a PostgreSQL RAW read statement fails safely.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_READ_FAILED;
|
||||
/// Error code used when PostgreSQL cannot represent a requested RAW retention compaction state.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED;
|
||||
/// Error code used when PostgreSQL schema history is newer than this Store runtime.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_SCHEMA_NEWER;
|
||||
/// Error code used when PostgreSQL verified TLS setup or negotiation fails.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_TLS_FAILED;
|
||||
/// Error code used when a PostgreSQL RAW write statement or transaction fails safely.
|
||||
pub use self::error::ERROR_CODE_POSTGRES_WRITE_FAILED;
|
||||
/// Error code used when a RAW write requires a canonical reference that is not durable.
|
||||
pub use self::error::ERROR_CODE_RAW_REFERENCE_NOT_FOUND;
|
||||
/// Error code used when Store settings violate backend-neutral bounds or invariants.
|
||||
pub use self::error::ERROR_CODE_SETTINGS_INVALID;
|
||||
/// Error code used when explicit Store shutdown exceeds its configured deadline.
|
||||
pub use self::error::ERROR_CODE_SHUTDOWN_TIMEOUT;
|
||||
/// Error code used when a network-scoped operation targets a network different from the Store binding.
|
||||
pub use self::error::ERROR_CODE_WRONG_NETWORK;
|
||||
/// Portable Store health/readiness projection containing only safe diagnostics.
|
||||
pub use self::health::StoreHealthSnapshot;
|
||||
/// Portable Store health state independent from physical backend types.
|
||||
pub use self::health::StoreHealthState;
|
||||
/// Safe synchronous Store runtime snapshot containing backend-neutral pool counters.
|
||||
pub use self::health::StoreRuntimeSnapshot;
|
||||
/// Bounded PostgreSQL bootstrap and migration settings owned by the Store facade.
|
||||
pub use self::settings::PostgresBootstrapSettings;
|
||||
/// Bounded PostgreSQL connection-pool settings owned by the Store facade.
|
||||
pub use self::settings::PostgresPoolSettings;
|
||||
/// PostgreSQL settings owned by the Store facade without exposing backend implementation types.
|
||||
pub use self::settings::PostgresStoreSettings;
|
||||
/// TLS policy accepted by the backend-neutral PostgreSQL settings surface.
|
||||
pub use self::settings::PostgresTlsMode;
|
||||
/// Backend identity understood independently from compiled Cargo features.
|
||||
pub use self::settings::StoreBackendKind;
|
||||
/// Backend-specific settings selected through the common Store facade.
|
||||
pub use self::settings::StoreBackendSettings;
|
||||
/// Complete backend-neutral settings consumed by the common Store runtime facade.
|
||||
pub use self::settings::StoreSettings;
|
||||
/// Opaque common Store runtime facade with consuming async shutdown.
|
||||
pub use self::store::Store;
|
||||
/// Error code used when a RAW write collides with divergent content for the same logical identity.
|
||||
pub use ksp_store_api::ERROR_CODE_RAW_CONFLICT;
|
||||
/// Error code used when a RAW Store model violates one of its backend-agnostic invariants.
|
||||
pub use ksp_store_api::ERROR_CODE_RAW_MODEL_INVALID;
|
||||
/// Error code used when a KSP-owned RAW persistence payload violates its format or admission contract.
|
||||
pub use ksp_store_api::ERROR_CODE_RAW_PAYLOAD_INVALID;
|
||||
/// Error code used when acquisition provenance is malformed, unsafe or internally inconsistent.
|
||||
pub use ksp_store_api::ERROR_CODE_RAW_PROVENANCE_INVALID;
|
||||
/// Error code used when one RAW query or cursor violates backend-agnostic query invariants.
|
||||
pub use ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID;
|
||||
/// Error code used when a RAW retention transition violates the logical lifecycle contract.
|
||||
pub use ksp_store_api::ERROR_CODE_RAW_RETENTION_INVALID;
|
||||
/// Common KSP error type used by Store-facing contracts.
|
||||
pub use ksp_store_api::Error;
|
||||
/// Stable structured code identifying a KSP error category and condition.
|
||||
pub use ksp_store_api::ErrorCode;
|
||||
/// Structured contextual field attached to a KSP error.
|
||||
pub use ksp_store_api::ErrorContext;
|
||||
/// Maximum complete RAW account-data length admitted by the Store API.
|
||||
pub use ksp_store_api::MAX_RAW_ACCOUNT_DATA_BYTES;
|
||||
/// Maximum UTF-8 byte length accepted for one safe logical RAW/provenance code.
|
||||
pub use ksp_store_api::MAX_RAW_CODE_BYTES;
|
||||
/// Maximum opaque query cursor length admitted by the Store API.
|
||||
pub use ksp_store_api::MAX_RAW_PAGE_CURSOR_BYTES;
|
||||
/// Maximum KSP-owned canonical RAW payload admitted by the Store API.
|
||||
pub use ksp_store_api::MAX_RAW_PAYLOAD_BYTES;
|
||||
/// Maximum source-wire payload size recorded as acquisition metadata.
|
||||
pub use ksp_store_api::MAX_RAW_SOURCE_PAYLOAD_BYTES;
|
||||
/// Maximum supported Unix millisecond timestamp.
|
||||
pub use ksp_store_api::MAX_RAW_UNIX_MILLIS;
|
||||
/// Canonical Solana account address primitive shared by persistent models.
|
||||
pub use ksp_store_api::Pubkey;
|
||||
/// Persistable acquisition observation linked to one complete canonical RAW account state.
|
||||
pub use ksp_store_api::RawAccountObservation;
|
||||
/// Read capability for persisted RAW account-state observations.
|
||||
pub use ksp_store_api::RawAccountObservationRead;
|
||||
/// Write capability for additional observations of already persisted RAW account states.
|
||||
pub use ksp_store_api::RawAccountObservationWrite;
|
||||
/// Canonical complete N1 RAW account state independent from acquisition transport.
|
||||
pub use ksp_store_api::RawAccountState;
|
||||
/// Backend-independent list query for complete canonical RAW account states.
|
||||
pub use ksp_store_api::RawAccountStateQuery;
|
||||
/// Read capability for complete canonical RAW account states.
|
||||
pub use ksp_store_api::RawAccountStateRead;
|
||||
/// Durable backend-independent identity of one canonical RAW account state.
|
||||
pub use ksp_store_api::RawAccountStateReference;
|
||||
/// Write capability for complete canonical RAW account-state acquisitions.
|
||||
pub use ksp_store_api::RawAccountStateWrite;
|
||||
/// Origin category describing why one acquisition was performed.
|
||||
pub use ksp_store_api::RawAcquisitionOrigin;
|
||||
/// Safe source-independent acquisition provenance attached to one persisted observation.
|
||||
pub use ksp_store_api::RawAcquisitionProvenance;
|
||||
/// Combined outcome of one atomic canonical RAW entity plus observation acquisition.
|
||||
pub use ksp_store_api::RawAcquisitionWriteOutcome;
|
||||
/// Fixed-size digest identifying canonical or source bytes without retaining them.
|
||||
pub use ksp_store_api::RawContentHash;
|
||||
/// Outcome for one canonical RAW entity in an idempotent persistence operation.
|
||||
pub use ksp_store_api::RawEntityWriteOutcome;
|
||||
/// Bounded identifier of one KSP-owned source-independent RAW persistence format.
|
||||
pub use ksp_store_api::RawFormatId;
|
||||
/// Bounded logical network/cluster identifier used in backend-independent Store identities.
|
||||
pub use ksp_store_api::RawNetworkId;
|
||||
/// Stable deterministic idempotence key for one persisted acquisition observation.
|
||||
pub use ksp_store_api::RawObservationKey;
|
||||
/// Outcome for one deterministic acquisition observation write.
|
||||
pub use ksp_store_api::RawObservationWriteOutcome;
|
||||
/// One deterministic page of backend-independent Store results.
|
||||
pub use ksp_store_api::RawPage;
|
||||
/// Opaque backend-owned cursor returned by one deterministic Store query.
|
||||
pub use ksp_store_api::RawPageCursor;
|
||||
/// Caller-requested page size without an arbitrary KSP policy ceiling.
|
||||
pub use ksp_store_api::RawPageLimit;
|
||||
/// Opaque-cursor page request used by backend-independent list operations.
|
||||
pub use ksp_store_api::RawPageRequest;
|
||||
/// Bounded source-independent KSP RAW persistence payload.
|
||||
pub use ksp_store_api::RawPayload;
|
||||
/// Bounded logical code used by acquisition provenance fields.
|
||||
pub use ksp_store_api::RawProvenanceCode;
|
||||
/// Logical availability state of one canonical RAW payload.
|
||||
pub use ksp_store_api::RawRetentionState;
|
||||
/// Outcome of one atomic RAW retention transition.
|
||||
pub use ksp_store_api::RawRetentionWriteOutcome;
|
||||
/// Optional inclusive Solana slot bounds for one Store query.
|
||||
pub use ksp_store_api::RawSlotRange;
|
||||
/// Deterministic traversal direction for Store list queries.
|
||||
pub use ksp_store_api::RawSortDirection;
|
||||
/// Bounded UTC timestamp represented as whole milliseconds since Unix epoch.
|
||||
pub use ksp_store_api::RawTimestamp;
|
||||
/// Canonical source-independent N1 RAW transaction persisted by Store backends.
|
||||
pub use ksp_store_api::RawTransaction;
|
||||
/// Explicit write mode for canonical RAW transaction acquisitions.
|
||||
pub use ksp_store_api::RawTransactionAcquisitionMode;
|
||||
/// Persistable acquisition observation linked to one canonical RAW transaction.
|
||||
pub use ksp_store_api::RawTransactionObservation;
|
||||
/// Read capability for persisted RAW transaction observations.
|
||||
pub use ksp_store_api::RawTransactionObservationRead;
|
||||
/// Write capability for additional observations of already persisted RAW transactions.
|
||||
pub use ksp_store_api::RawTransactionObservationWrite;
|
||||
/// Backend-independent list query for canonical RAW transactions.
|
||||
pub use ksp_store_api::RawTransactionQuery;
|
||||
/// Read capability for canonical RAW transactions.
|
||||
pub use ksp_store_api::RawTransactionRead;
|
||||
/// Durable backend-independent identity of one canonical RAW transaction.
|
||||
pub use ksp_store_api::RawTransactionReference;
|
||||
/// Read capability for canonical RAW transaction retention metadata.
|
||||
pub use ksp_store_api::RawTransactionRetentionRead;
|
||||
/// Requested compare-and-transition operation for one RAW transaction retention state.
|
||||
pub use ksp_store_api::RawTransactionRetentionTransition;
|
||||
/// Write capability for policy-authorized RAW transaction retention transitions.
|
||||
pub use ksp_store_api::RawTransactionRetentionWrite;
|
||||
/// Canonical 64-byte Solana transaction signature used by Store identities.
|
||||
pub use ksp_store_api::RawTransactionSignature;
|
||||
/// Minimal durable identity retained after a canonical RAW transaction payload is purged.
|
||||
pub use ksp_store_api::RawTransactionTombstone;
|
||||
/// Write capability for canonical RAW transaction acquisitions.
|
||||
pub use ksp_store_api::RawTransactionWrite;
|
||||
/// Common KSP result alias using [`Error`].
|
||||
pub use ksp_store_api::Result;
|
||||
/// Boxed async operation returned by object-safe Store capability contracts.
|
||||
pub use ksp_store_api::StoreApiFuture;
|
||||
|
||||
/// Crate-owned tracing target reserved for Store runtime behavior.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
|
||||
// Keep the mandatory crate-owned tracing target part of the compiled scaffold
|
||||
// without inventing runtime logging before the first behavioral log site.
|
||||
const _: &str = crate::TRACING_TARGET;
|
||||
423
crates/ksp-store-lib/src/settings.rs
Normal file
423
crates/ksp-store-lib/src/settings.rs
Normal file
@@ -0,0 +1,423 @@
|
||||
// file: crates/ksp-store-lib/src/settings.rs
|
||||
// version: 4
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||||
const DEFAULT_MAX_CONNECTIONS: u32 = 8;
|
||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS: u64 = 10_000;
|
||||
const DEFAULT_MIGRATION_TIMEOUT_MS: u64 = 30_000;
|
||||
const DEFAULT_POOL_CREATE_TIMEOUT_MS: u64 = 10_000;
|
||||
const DEFAULT_POOL_RECYCLE_TIMEOUT_MS: u64 = 5_000;
|
||||
const DEFAULT_POOL_WAIT_TIMEOUT_MS: u64 = 5_000;
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 5_000;
|
||||
const MAX_CONNECTIONS: u32 = 64;
|
||||
const MAX_CONNECT_TIMEOUT_MS: u64 = 60_000;
|
||||
const MAX_MIGRATION_LOCK_TIMEOUT_MS: u64 = 120_000;
|
||||
const MAX_MIGRATION_TIMEOUT_MS: u64 = 300_000;
|
||||
const MAX_POOL_CREATE_TIMEOUT_MS: u64 = 60_000;
|
||||
const MAX_POOL_RECYCLE_TIMEOUT_MS: u64 = 60_000;
|
||||
const MAX_POOL_WAIT_TIMEOUT_MS: u64 = 60_000;
|
||||
const MAX_SHUTDOWN_TIMEOUT_MS: u64 = 30_000;
|
||||
const MIN_CONNECTIONS: u32 = 1;
|
||||
const MIN_CONNECT_TIMEOUT_MS: u64 = 100;
|
||||
const MIN_MIGRATION_LOCK_TIMEOUT_MS: u64 = 100;
|
||||
const MIN_MIGRATION_TIMEOUT_MS: u64 = 1_000;
|
||||
const MIN_POOL_CREATE_TIMEOUT_MS: u64 = 100;
|
||||
const MIN_POOL_RECYCLE_TIMEOUT_MS: u64 = 100;
|
||||
const MIN_POOL_WAIT_TIMEOUT_MS: u64 = 100;
|
||||
const MIN_SHUTDOWN_TIMEOUT_MS: u64 = 100;
|
||||
|
||||
/// Backend identity understood by the common Store runtime independently from compiled Cargo features.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum StoreBackendKind {
|
||||
/// Official PostgreSQL Store backend.
|
||||
Postgres,
|
||||
}
|
||||
|
||||
impl StoreBackendKind {
|
||||
/// Returns the stable safe backend code used in diagnostics and configuration mapping.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Postgres => "postgres",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// TLS policy accepted by the backend-neutral PostgreSQL settings surface.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum PostgresTlsMode {
|
||||
/// Connect without TLS.
|
||||
Disabled,
|
||||
/// Require TLS and verify both the certificate chain and requested server identity.
|
||||
VerifyFull,
|
||||
}
|
||||
|
||||
/// Bounded PostgreSQL connection-pool settings owned by the Store facade.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresPoolSettings {
|
||||
connect_timeout: std::time::Duration,
|
||||
create_timeout: std::time::Duration,
|
||||
max_connections: u32,
|
||||
recycle_timeout: std::time::Duration,
|
||||
wait_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl PostgresPoolSettings {
|
||||
/// Creates explicit PostgreSQL pool bounds without performing any I/O.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
max_connections: u32,
|
||||
connect_timeout: std::time::Duration,
|
||||
wait_timeout: std::time::Duration,
|
||||
create_timeout: std::time::Duration,
|
||||
recycle_timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
return Self { connect_timeout, create_timeout, max_connections, recycle_timeout, wait_timeout };
|
||||
}
|
||||
|
||||
/// Returns the timeout for establishing one physical PostgreSQL connection.
|
||||
#[must_use]
|
||||
pub const fn connect_timeout(&self) -> std::time::Duration {
|
||||
return self.connect_timeout;
|
||||
}
|
||||
|
||||
/// Returns the timeout for creating one pooled PostgreSQL object.
|
||||
#[must_use]
|
||||
pub const fn create_timeout(&self) -> std::time::Duration {
|
||||
return self.create_timeout;
|
||||
}
|
||||
|
||||
/// Returns the maximum number of physical PostgreSQL connections owned by the pool.
|
||||
#[must_use]
|
||||
pub const fn max_connections(&self) -> u32 {
|
||||
return self.max_connections;
|
||||
}
|
||||
|
||||
/// Returns the timeout for recycling one pooled PostgreSQL object.
|
||||
#[must_use]
|
||||
pub const fn recycle_timeout(&self) -> std::time::Duration {
|
||||
return self.recycle_timeout;
|
||||
}
|
||||
|
||||
/// Returns the maximum time one acquisition can wait for pool capacity.
|
||||
#[must_use]
|
||||
pub const fn wait_timeout(&self) -> std::time::Duration {
|
||||
return self.wait_timeout;
|
||||
}
|
||||
|
||||
/// Validates all pool bounds without opening a connection.
|
||||
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||
if self.max_connections < MIN_CONNECTIONS || self.max_connections > MAX_CONNECTIONS {
|
||||
return std::result::Result::Err(
|
||||
ksp_store_api::Error::new(crate::ERROR_CODE_SETTINGS_INVALID, "PostgreSQL pool connection bound is invalid")
|
||||
.with_context("field", "postgres.pool.max_connections")
|
||||
.with_context("minimum", MIN_CONNECTIONS.to_string())
|
||||
.with_context("maximum", MAX_CONNECTIONS.to_string()),
|
||||
);
|
||||
}
|
||||
let connect_validation = validate_duration("postgres.pool.connect_timeout", self.connect_timeout, MIN_CONNECT_TIMEOUT_MS, MAX_CONNECT_TIMEOUT_MS);
|
||||
if let std::result::Result::Err(error) = connect_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let wait_validation = validate_duration("postgres.pool.wait_timeout", self.wait_timeout, MIN_POOL_WAIT_TIMEOUT_MS, MAX_POOL_WAIT_TIMEOUT_MS);
|
||||
if let std::result::Result::Err(error) = wait_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let create_validation = validate_duration("postgres.pool.create_timeout", self.create_timeout, MIN_POOL_CREATE_TIMEOUT_MS, MAX_POOL_CREATE_TIMEOUT_MS);
|
||||
if let std::result::Result::Err(error) = create_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let recycle_validation =
|
||||
validate_duration("postgres.pool.recycle_timeout", self.recycle_timeout, MIN_POOL_RECYCLE_TIMEOUT_MS, MAX_POOL_RECYCLE_TIMEOUT_MS);
|
||||
if let std::result::Result::Err(error) = recycle_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for PostgresPoolSettings {
|
||||
fn default() -> Self {
|
||||
return Self::new(
|
||||
DEFAULT_MAX_CONNECTIONS,
|
||||
std::time::Duration::from_millis(DEFAULT_CONNECT_TIMEOUT_MS),
|
||||
std::time::Duration::from_millis(DEFAULT_POOL_WAIT_TIMEOUT_MS),
|
||||
std::time::Duration::from_millis(DEFAULT_POOL_CREATE_TIMEOUT_MS),
|
||||
std::time::Duration::from_millis(DEFAULT_POOL_RECYCLE_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded PostgreSQL bootstrap settings owned by the Store facade.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresBootstrapSettings {
|
||||
migration_lock_timeout: std::time::Duration,
|
||||
migration_timeout: std::time::Duration,
|
||||
schema_autocreate: bool,
|
||||
schema_autoupdate: bool,
|
||||
}
|
||||
|
||||
impl PostgresBootstrapSettings {
|
||||
/// Creates bootstrap settings using the legacy single migration switch for source compatibility.
|
||||
///
|
||||
/// The supplied value is mapped to both schema auto-creation and schema auto-update. New code should prefer
|
||||
/// [`Self::with_schema_policy`] when these policies need to differ.
|
||||
#[must_use]
|
||||
pub const fn new(auto_migrate: bool, migration_timeout: std::time::Duration, migration_lock_timeout: std::time::Duration) -> Self {
|
||||
return Self::with_schema_policy(auto_migrate, auto_migrate, migration_timeout, migration_lock_timeout);
|
||||
}
|
||||
|
||||
/// Creates explicit schema creation/update policy and migration deadlines.
|
||||
#[must_use]
|
||||
pub const fn with_schema_policy(
|
||||
schema_autocreate: bool,
|
||||
schema_autoupdate: bool,
|
||||
migration_timeout: std::time::Duration,
|
||||
migration_lock_timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
return Self { migration_lock_timeout, migration_timeout, schema_autocreate, schema_autoupdate };
|
||||
}
|
||||
|
||||
/// Returns the legacy pending-migration switch, mapped to the schema auto-update policy.
|
||||
#[must_use]
|
||||
pub const fn auto_migrate(&self) -> bool {
|
||||
return self.schema_autoupdate;
|
||||
}
|
||||
|
||||
/// Returns whether an absent KSP-managed schema may be created or adopted during Store opening.
|
||||
#[must_use]
|
||||
pub const fn schema_autocreate(&self) -> bool {
|
||||
return self.schema_autocreate;
|
||||
}
|
||||
|
||||
/// Returns whether pending migrations and safe additive schema repairs may be applied during Store opening.
|
||||
#[must_use]
|
||||
pub const fn schema_autoupdate(&self) -> bool {
|
||||
return self.schema_autoupdate;
|
||||
}
|
||||
|
||||
/// Returns the bounded wait allowed for the private PostgreSQL migration lock.
|
||||
#[must_use]
|
||||
pub const fn migration_lock_timeout(&self) -> std::time::Duration {
|
||||
return self.migration_lock_timeout;
|
||||
}
|
||||
|
||||
/// Returns the bounded duration allowed for one migration/bootstrap run.
|
||||
#[must_use]
|
||||
pub const fn migration_timeout(&self) -> std::time::Duration {
|
||||
return self.migration_timeout;
|
||||
}
|
||||
|
||||
/// Validates bootstrap and migration deadlines without contacting PostgreSQL.
|
||||
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||
let migration_validation =
|
||||
validate_duration("postgres.bootstrap.migration_timeout", self.migration_timeout, MIN_MIGRATION_TIMEOUT_MS, MAX_MIGRATION_TIMEOUT_MS);
|
||||
if let std::result::Result::Err(error) = migration_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let lock_validation = validate_duration(
|
||||
"postgres.bootstrap.migration_lock_timeout",
|
||||
self.migration_lock_timeout,
|
||||
MIN_MIGRATION_LOCK_TIMEOUT_MS,
|
||||
MAX_MIGRATION_LOCK_TIMEOUT_MS,
|
||||
);
|
||||
if let std::result::Result::Err(error) = lock_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for PostgresBootstrapSettings {
|
||||
fn default() -> Self {
|
||||
return Self::with_schema_policy(
|
||||
true,
|
||||
true,
|
||||
std::time::Duration::from_millis(DEFAULT_MIGRATION_TIMEOUT_MS),
|
||||
std::time::Duration::from_millis(DEFAULT_MIGRATION_LOCK_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL settings owned by the Store facade and independent from Config or backend implementation types.
|
||||
pub struct PostgresStoreSettings {
|
||||
bootstrap: PostgresBootstrapSettings,
|
||||
connection_uri: std::string::String,
|
||||
pool: PostgresPoolSettings,
|
||||
tls_mode: PostgresTlsMode,
|
||||
}
|
||||
|
||||
impl PostgresStoreSettings {
|
||||
/// Creates PostgreSQL Store settings from an explicitly supplied sensitive connection URI and typed runtime bounds.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
connection_uri: impl std::convert::Into<std::string::String>,
|
||||
pool: PostgresPoolSettings,
|
||||
tls_mode: PostgresTlsMode,
|
||||
bootstrap: PostgresBootstrapSettings,
|
||||
) -> Self {
|
||||
return Self { bootstrap, connection_uri: connection_uri.into(), pool, tls_mode };
|
||||
}
|
||||
|
||||
/// Returns the PostgreSQL bootstrap settings without exposing the sensitive connection URI.
|
||||
#[must_use]
|
||||
pub const fn bootstrap(&self) -> PostgresBootstrapSettings {
|
||||
return self.bootstrap;
|
||||
}
|
||||
|
||||
/// Returns the sensitive PostgreSQL connection URI only to the compiled backend bridge.
|
||||
#[cfg(feature = "postgres")]
|
||||
#[must_use]
|
||||
pub(crate) fn connection_uri(&self) -> &str {
|
||||
return self.connection_uri.as_str();
|
||||
}
|
||||
|
||||
/// Returns the PostgreSQL pool settings without exposing the sensitive connection URI.
|
||||
#[must_use]
|
||||
pub const fn pool(&self) -> PostgresPoolSettings {
|
||||
return self.pool;
|
||||
}
|
||||
|
||||
/// Returns the selected PostgreSQL TLS policy without exposing the sensitive connection URI.
|
||||
#[must_use]
|
||||
pub const fn tls_mode(&self) -> PostgresTlsMode {
|
||||
return self.tls_mode;
|
||||
}
|
||||
|
||||
/// Validates backend-neutral PostgreSQL settings without parsing the URI or performing I/O.
|
||||
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||
if self.connection_uri.is_empty() {
|
||||
return std::result::Result::Err(
|
||||
ksp_store_api::Error::new(crate::ERROR_CODE_SETTINGS_INVALID, "PostgreSQL connection URI is required")
|
||||
.with_context("field", "postgres.connection_uri"),
|
||||
);
|
||||
}
|
||||
let pool_validation = self.pool.validate();
|
||||
if let std::result::Result::Err(error) = pool_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let bootstrap_validation = self.bootstrap.validate();
|
||||
if let std::result::Result::Err(error) = bootstrap_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PostgresStoreSettings {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("PostgresStoreSettings")
|
||||
.field("connection_uri", &"<redacted>")
|
||||
.field("pool", &self.pool)
|
||||
.field("tls_mode", &self.tls_mode)
|
||||
.field("bootstrap", &self.bootstrap)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-specific settings selected through the common Store facade.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum StoreBackendSettings {
|
||||
/// Settings for the known PostgreSQL backend, whether or not its Cargo feature is compiled.
|
||||
Postgres(PostgresStoreSettings),
|
||||
}
|
||||
|
||||
impl StoreBackendSettings {
|
||||
/// Returns the stable backend identity represented by these settings.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> StoreBackendKind {
|
||||
return match self {
|
||||
Self::Postgres(_) => StoreBackendKind::Postgres,
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates backend-specific settings without performing I/O.
|
||||
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||
return match self {
|
||||
Self::Postgres(settings) => settings.validate(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete backend-neutral settings consumed by the common Store runtime facade.
|
||||
#[derive(Debug)]
|
||||
pub struct StoreSettings {
|
||||
backend: StoreBackendSettings,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
shutdown_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl StoreSettings {
|
||||
/// Creates complete Store runtime settings for one explicit logical network, backend and shutdown bound.
|
||||
#[must_use]
|
||||
pub fn new(network: ksp_store_api::RawNetworkId, backend: StoreBackendSettings, shutdown_timeout: std::time::Duration) -> Self {
|
||||
return Self { backend, network, shutdown_timeout };
|
||||
}
|
||||
|
||||
/// Returns the selected backend settings.
|
||||
#[must_use]
|
||||
pub const fn backend(&self) -> &StoreBackendSettings {
|
||||
return &self.backend;
|
||||
}
|
||||
|
||||
/// Returns the selected stable backend identity.
|
||||
#[must_use]
|
||||
pub const fn backend_kind(&self) -> StoreBackendKind {
|
||||
return self.backend.kind();
|
||||
}
|
||||
|
||||
/// Returns the single logical network bound to this Store instance.
|
||||
#[must_use]
|
||||
pub const fn network(&self) -> &ksp_store_api::RawNetworkId {
|
||||
return &self.network;
|
||||
}
|
||||
|
||||
/// Returns the maximum duration allowed for explicit Store shutdown.
|
||||
#[must_use]
|
||||
pub const fn shutdown_timeout(&self) -> std::time::Duration {
|
||||
return self.shutdown_timeout;
|
||||
}
|
||||
|
||||
/// Validates all backend-neutral Store settings before any backend I/O can start.
|
||||
pub fn validate(&self) -> ksp_store_api::Result<()> {
|
||||
let shutdown_validation = validate_duration("shutdown_timeout", self.shutdown_timeout, MIN_SHUTDOWN_TIMEOUT_MS, MAX_SHUTDOWN_TIMEOUT_MS);
|
||||
if let std::result::Result::Err(error) = shutdown_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let backend_validation = self.backend.validate();
|
||||
if let std::result::Result::Err(error) = backend_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreSettings {
|
||||
/// Creates settings using the common default shutdown bound while keeping network and backend construction explicit.
|
||||
#[must_use]
|
||||
pub fn with_default_shutdown(network: ksp_store_api::RawNetworkId, backend: StoreBackendSettings) -> Self {
|
||||
return Self::new(network, backend, std::time::Duration::from_millis(DEFAULT_SHUTDOWN_TIMEOUT_MS));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_duration(field: &'static str, value: std::time::Duration, minimum_ms: u64, maximum_ms: u64) -> ksp_store_api::Result<()> {
|
||||
let minimum = std::time::Duration::from_millis(minimum_ms);
|
||||
let maximum = std::time::Duration::from_millis(maximum_ms);
|
||||
if value < minimum || value > maximum {
|
||||
return std::result::Result::Err(
|
||||
ksp_store_api::Error::new(crate::ERROR_CODE_SETTINGS_INVALID, "Store runtime duration is outside the supported resource bound")
|
||||
.with_context("field", field)
|
||||
.with_context("minimum_ms", minimum_ms.to_string())
|
||||
.with_context("maximum_ms", maximum_ms.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/settings.rs"]
|
||||
mod tests;
|
||||
651
crates/ksp-store-lib/src/store.rs
Normal file
651
crates/ksp-store-lib/src/store.rs
Normal file
@@ -0,0 +1,651 @@
|
||||
// file: crates/ksp-store-lib/src/store.rs
|
||||
// version: 7
|
||||
|
||||
/// Opaque common Store runtime facade.
|
||||
///
|
||||
/// A successful value is returned only after the selected compiled backend has completed its bounded physical opening path. PostgreSQL pool, client, TLS and
|
||||
/// driver types remain private to the backend crate.
|
||||
pub struct Store {
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
#[cfg(feature = "postgres")]
|
||||
runtime: StoreRuntime,
|
||||
shutdown_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Validates settings, selects the requested backend and opens one ready Store instance for exactly one logical network.
|
||||
///
|
||||
/// A known backend whose Cargo feature is absent is rejected before any I/O. A successful PostgreSQL result proves that one physical pooled connection has
|
||||
/// been established under the typed TLS and timeout policy.
|
||||
pub async fn open(settings: crate::StoreSettings) -> ksp_store_api::Result<Self> {
|
||||
let validation = settings.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let backend_kind = settings.backend_kind();
|
||||
let network = settings.network().clone();
|
||||
let shutdown_timeout = settings.shutdown_timeout();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
backend = backend_kind.code(),
|
||||
network = network.as_str(),
|
||||
"opening Store runtime"
|
||||
);
|
||||
return match settings.backend() {
|
||||
crate::StoreBackendSettings::Postgres(postgres) => open_postgres(backend_kind, network, shutdown_timeout, postgres).await,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns a safe synchronous runtime snapshot without performing backend I/O.
|
||||
#[must_use]
|
||||
pub fn runtime_snapshot(&self) -> crate::StoreRuntimeSnapshot {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => map_postgres_runtime_snapshot(backend.runtime_snapshot(), self.backend_kind, self.network.clone()),
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
return crate::StoreRuntimeSnapshot::new(self.backend_kind, self.network.clone(), 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the selected backend's lightweight bounded readiness probe and returns only portable redacted diagnostics.
|
||||
pub async fn health(&self) -> crate::StoreHealthSnapshot {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let snapshot = backend.health().await;
|
||||
map_postgres_health_snapshot(snapshot, self.backend_kind, self.network.clone())
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
return crate::StoreHealthSnapshot::new(
|
||||
crate::StoreHealthState::NotReady,
|
||||
self.runtime_snapshot(),
|
||||
std::option::Option::None,
|
||||
0,
|
||||
std::option::Option::Some(crate::ERROR_CODE_BACKEND_NOT_COMPILED),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly closes the Store runtime, consumes its facade handle and applies the configured bounded shutdown deadline.
|
||||
pub async fn close(self) -> ksp_store_api::Result<()> {
|
||||
let backend_kind = self.backend_kind;
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
let network = self.network;
|
||||
let shutdown_timeout = self.shutdown_timeout;
|
||||
let result = match self.runtime {
|
||||
StoreRuntime::Postgres(backend) => backend.close(shutdown_timeout).await,
|
||||
};
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => {
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
backend = backend_kind.code(),
|
||||
network = network.as_str(),
|
||||
"Store runtime closed"
|
||||
);
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(map_postgres_error(error, backend_kind, network.as_str())),
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
return std::result::Result::Err(unavailable_runtime_error(backend_kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Store {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("Store")
|
||||
.field("backend_kind", &self.backend_kind)
|
||||
.field("network", &self.network)
|
||||
.field("shutdown_timeout", &self.shutdown_timeout)
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountObservationRead for Store {
|
||||
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>>> {
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.get_raw_account_observation(observation_key).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = observation_key;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountObservationWrite for Store {
|
||||
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 network_check = validate_operation_network(&self.network, observation.account().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.record_raw_account_observation(observation).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = observation;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountStateRead for Store {
|
||||
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 network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.get_raw_account_state(reference).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = reference;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn 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 network_check = validate_operation_network(&self.network, query.network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.list_raw_account_states(query).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = query;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawAccountStateWrite for Store {
|
||||
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_network = validate_operation_network(&self.network, state.reference().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = state_network {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
let observation_network = validate_operation_network(&self.network, observation.account().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = observation_network {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.persist_raw_account_acquisition(state, observation).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = state;
|
||||
let _ = observation;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionRead for Store {
|
||||
fn get_raw_transaction<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawTransactionReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransaction>>> {
|
||||
let network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.get_raw_transaction(reference).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = reference;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn list_raw_transactions<'a>(
|
||||
&'a self,
|
||||
query: &'a ksp_store_api::RawTransactionQuery,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>>> {
|
||||
let network_check = validate_operation_network(&self.network, query.network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.list_raw_transactions(query).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = query;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionWrite for Store {
|
||||
fn persist_raw_transaction_acquisition<'a>(
|
||||
&'a self,
|
||||
transaction: ksp_store_api::RawTransaction,
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
mode: ksp_store_api::RawTransactionAcquisitionMode,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawAcquisitionWriteOutcome>> {
|
||||
let transaction_network = validate_operation_network(&self.network, transaction.reference().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = transaction_network {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
let observation_network = validate_operation_network(&self.network, observation.transaction().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = observation_network {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.persist_raw_transaction_acquisition(transaction, observation, mode).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = transaction;
|
||||
let _ = observation;
|
||||
let _ = mode;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionObservationRead for Store {
|
||||
fn get_raw_transaction_observation<'a>(
|
||||
&'a self,
|
||||
observation_key: &'a ksp_store_api::RawObservationKey,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionObservation>>> {
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.get_raw_transaction_observation(observation_key).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = observation_key;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionObservationWrite for Store {
|
||||
fn record_raw_transaction_observation<'a>(
|
||||
&'a self,
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawObservationWriteOutcome>> {
|
||||
let network_check = validate_operation_network(&self.network, observation.transaction().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.record_raw_transaction_observation(observation).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = observation;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionRetentionRead for Store {
|
||||
fn get_raw_transaction_retention_state<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawTransactionReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawRetentionState>>> {
|
||||
let network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.get_raw_transaction_retention_state(reference).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = reference;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn get_raw_transaction_tombstone<'a>(
|
||||
&'a self,
|
||||
reference: &'a ksp_store_api::RawTransactionReference,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<std::option::Option<ksp_store_api::RawTransactionTombstone>>> {
|
||||
let network_check = validate_operation_network(&self.network, reference.network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.get_raw_transaction_tombstone(reference).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = reference;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_store_api::RawTransactionRetentionWrite for Store {
|
||||
fn transition_raw_transaction_retention<'a>(
|
||||
&'a self,
|
||||
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||
) -> ksp_store_api::StoreApiFuture<'a, ksp_store_api::Result<ksp_store_api::RawRetentionWriteOutcome>> {
|
||||
let network_check = validate_operation_network(&self.network, transition.reference().network(), self.backend_kind);
|
||||
if let std::result::Result::Err(error) = network_check {
|
||||
return std::boxed::Box::pin(async move {
|
||||
return std::result::Result::Err(error);
|
||||
});
|
||||
}
|
||||
return std::boxed::Box::pin(async move {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
return match &self.runtime {
|
||||
StoreRuntime::Postgres(backend) => {
|
||||
let result = backend.transition_raw_transaction_retention(transition).await;
|
||||
result.map_err(|error| return map_postgres_error(error, self.backend_kind, self.network.as_str()))
|
||||
},
|
||||
};
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
{
|
||||
let _ = transition;
|
||||
return std::result::Result::Err(unavailable_runtime_error(self.backend_kind));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_operation_network(
|
||||
store_network: &ksp_store_api::RawNetworkId,
|
||||
operation_network: &ksp_store_api::RawNetworkId,
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
) -> ksp_store_api::Result<()> {
|
||||
if store_network != operation_network {
|
||||
return std::result::Result::Err(
|
||||
ksp_store_api::Error::new(crate::ERROR_CODE_WRONG_NETWORK, "Store operation targeted a different logical network")
|
||||
.with_context("backend", backend_kind.code())
|
||||
.with_context("network", store_network.as_str()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
enum StoreRuntime {
|
||||
Postgres(ksp_store_postgres_lib::PostgresBackend),
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
async fn open_postgres(
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
shutdown_timeout: std::time::Duration,
|
||||
settings: &crate::PostgresStoreSettings,
|
||||
) -> ksp_store_api::Result<Store> {
|
||||
let bootstrap = settings.bootstrap();
|
||||
let pool = settings.pool();
|
||||
let tls_mode = match settings.tls_mode() {
|
||||
crate::PostgresTlsMode::Disabled => ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled,
|
||||
crate::PostgresTlsMode::VerifyFull => ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||
};
|
||||
let backend_settings = ksp_store_postgres_lib::PostgresBackendSettings::with_schema_policy(
|
||||
network.clone(),
|
||||
settings.connection_uri(),
|
||||
pool.max_connections(),
|
||||
pool.connect_timeout(),
|
||||
pool.wait_timeout(),
|
||||
pool.create_timeout(),
|
||||
pool.recycle_timeout(),
|
||||
tls_mode,
|
||||
bootstrap.schema_autocreate(),
|
||||
bootstrap.schema_autoupdate(),
|
||||
bootstrap.migration_timeout(),
|
||||
bootstrap.migration_lock_timeout(),
|
||||
);
|
||||
let opened = ksp_store_postgres_lib::PostgresBackend::open(backend_settings).await;
|
||||
return match opened {
|
||||
std::result::Result::Ok(backend) => {
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
backend = backend_kind.code(),
|
||||
network = network.as_str(),
|
||||
"Store backend is physically ready"
|
||||
);
|
||||
std::result::Result::Ok(Store { backend_kind, network, runtime: StoreRuntime::Postgres(backend), shutdown_timeout })
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(map_postgres_error(error, backend_kind, network.as_str())),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
async fn open_postgres(
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
_network: ksp_store_api::RawNetworkId,
|
||||
_shutdown_timeout: std::time::Duration,
|
||||
_settings: &crate::PostgresStoreSettings,
|
||||
) -> ksp_store_api::Result<Store> {
|
||||
return std::result::Result::Err(unavailable_runtime_error(backend_kind));
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn map_postgres_error(error: ksp_store_postgres_lib::PostgresBackendError, backend_kind: crate::StoreBackendKind, network: &str) -> ksp_store_api::Error {
|
||||
let code = postgres_error_code(error.kind());
|
||||
return ksp_store_api::Error::new(code, "PostgreSQL Store backend operation failed")
|
||||
.with_context("backend", backend_kind.code())
|
||||
.with_context("network", network)
|
||||
.with_context("phase", error.phase());
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn map_postgres_runtime_snapshot(
|
||||
snapshot: ksp_store_postgres_lib::PostgresBackendRuntimeSnapshot,
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
) -> crate::StoreRuntimeSnapshot {
|
||||
return crate::StoreRuntimeSnapshot::new(
|
||||
backend_kind,
|
||||
network,
|
||||
snapshot.pool_capacity(),
|
||||
snapshot.pool_size(),
|
||||
snapshot.pool_available(),
|
||||
snapshot.pool_waiting(),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn map_postgres_health_snapshot(
|
||||
snapshot: ksp_store_postgres_lib::PostgresBackendHealthSnapshot,
|
||||
backend_kind: crate::StoreBackendKind,
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
) -> crate::StoreHealthSnapshot {
|
||||
let state = if snapshot.is_ready() { crate::StoreHealthState::Ready } else { crate::StoreHealthState::NotReady };
|
||||
let error_code = snapshot.error_kind().map(|kind| return postgres_error_code(kind));
|
||||
let runtime = map_postgres_runtime_snapshot(snapshot.runtime().clone(), backend_kind, network);
|
||||
return crate::StoreHealthSnapshot::new(state, runtime, snapshot.migration_version(), snapshot.pending_migration_count(), error_code);
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn postgres_error_code(kind: ksp_store_postgres_lib::PostgresBackendErrorKind) -> ksp_store_api::ErrorCode {
|
||||
return match kind {
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => crate::ERROR_CODE_POSTGRES_CONFIG_INVALID,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => crate::ERROR_CODE_POSTGRES_CONNECT_FAILED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict => ksp_store_api::ERROR_CODE_RAW_CONFLICT,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid => crate::ERROR_CODE_POSTGRES_DATA_INVALID,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed => crate::ERROR_CODE_POSTGRES_HEALTH_FAILED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed => crate::ERROR_CODE_POSTGRES_MIGRATION_FAILED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => crate::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported => crate::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid => ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed => crate::ERROR_CODE_POSTGRES_READ_FAILED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound => crate::ERROR_CODE_RAW_REFERENCE_NOT_FOUND,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported => crate::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => crate::ERROR_CODE_POSTGRES_SCHEMA_NEWER,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => crate::ERROR_CODE_SHUTDOWN_TIMEOUT,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => crate::ERROR_CODE_POSTGRES_TLS_FAILED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed => crate::ERROR_CODE_POSTGRES_WRITE_FAILED,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork => crate::ERROR_CODE_WRONG_NETWORK,
|
||||
_ => crate::ERROR_CODE_BACKEND_OPEN_FAILED,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
fn unavailable_runtime_error(backend_kind: crate::StoreBackendKind) -> ksp_store_api::Error {
|
||||
return ksp_store_api::Error::new(crate::ERROR_CODE_BACKEND_NOT_COMPILED, "Selected Store backend is not compiled")
|
||||
.with_context("backend", backend_kind.code());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/store.rs"]
|
||||
mod tests;
|
||||
87
crates/ksp-store-lib/tests/dependency_boundary.rs
Normal file
87
crates/ksp-store-lib/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
// file: crates/ksp-store-lib/tests/dependency_boundary.rs
|
||||
// version: 8
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Cargo-feature and dependency-boundary canaries for the common Store runtime facade.
|
||||
|
||||
#[test]
|
||||
fn pre_005_manifest_keeps_backend_physical_dependencies_out_of_facade() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
assert!(manifest.contains("default = [\"postgres\"]"));
|
||||
assert!(manifest.contains("postgres = [\"dep:ksp-store-postgres-lib\"]"));
|
||||
assert!(manifest.contains("ksp-logging-lib = { path = \"../ksp-logging-lib\" }"));
|
||||
assert!(manifest.contains("ksp-store-api = { path = \"../ksp-store-api\" }"));
|
||||
assert!(manifest.contains("ksp-store-postgres-lib = { path = \"../ksp-store-postgres-lib\", optional = true }"));
|
||||
for forbidden in [
|
||||
"ksp-config-lib",
|
||||
"ksp-materializer",
|
||||
"ksp-program",
|
||||
"ksp-onchain-transport-lib",
|
||||
"ksp-offchain-transport-lib",
|
||||
"tokio-postgres",
|
||||
"deadpool-postgres",
|
||||
"rustls",
|
||||
] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden Store facade dependency detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_facade_exposes_no_physical_postgres_types_or_environment_bypass() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
assert!(crate_root.contains("pub use self::settings::StoreSettings;"));
|
||||
assert!(crate_root.contains("pub use self::health::StoreHealthSnapshot;"));
|
||||
assert!(crate_root.contains("pub use self::health::StoreRuntimeSnapshot;"));
|
||||
assert!(crate_root.contains("pub use self::store::Store;"));
|
||||
assert!(crate_root.contains("pub use ksp_store_api::RawTransaction;"));
|
||||
assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;"));
|
||||
for forbidden in [
|
||||
"pub mod ",
|
||||
"pub use ksp_store_postgres_lib",
|
||||
"tokio_postgres",
|
||||
"deadpool_postgres",
|
||||
"rustls::",
|
||||
"deadpool::managed::Pool",
|
||||
"tokio_postgres::Client",
|
||||
"tokio_postgres::Row",
|
||||
"tokio_postgres::Statement",
|
||||
] {
|
||||
assert!(!crate_root.contains(forbidden), "forbidden physical backend facade surface detected: {forbidden}");
|
||||
}
|
||||
let production = format!("{}\n{}\n{}", include_str!("../src/health.rs"), include_str!("../src/settings.rs"), include_str!("../src/store.rs"));
|
||||
for forbidden in ["ksp_config_lib", "std::env", "dotenv", "PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", ".pgpass", "tokio_postgres", "deadpool_postgres"] {
|
||||
assert!(!production.contains(forbidden), "forbidden Store facade ownership bypass detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_008_facade_dispatches_exact_ten_raw_capabilities_without_physical_leak() {
|
||||
let store = include_str!("../src/store.rs");
|
||||
for required in [
|
||||
"impl ksp_store_api::RawAccountObservationRead for Store",
|
||||
"impl ksp_store_api::RawAccountObservationWrite for Store",
|
||||
"impl ksp_store_api::RawAccountStateRead for Store",
|
||||
"impl ksp_store_api::RawAccountStateWrite for Store",
|
||||
"impl ksp_store_api::RawTransactionObservationRead for Store",
|
||||
"impl ksp_store_api::RawTransactionObservationWrite for Store",
|
||||
"impl ksp_store_api::RawTransactionRead for Store",
|
||||
"impl ksp_store_api::RawTransactionRetentionRead for Store",
|
||||
"impl ksp_store_api::RawTransactionRetentionWrite for Store",
|
||||
"impl ksp_store_api::RawTransactionWrite for Store",
|
||||
"validate_operation_network",
|
||||
"StoreRuntime::Postgres(backend)",
|
||||
"map_postgres_error",
|
||||
] {
|
||||
assert!(store.contains(required), "missing pre.008 Store capability dispatch contract: {required}");
|
||||
}
|
||||
assert_eq!(store.matches("impl ksp_store_api::Raw").count(), 10);
|
||||
for forbidden in ["tokio_postgres::", "deadpool_postgres::", "CREATE TABLE", "INSERT INTO", "UPDATE ksp_", "DELETE FROM"] {
|
||||
assert!(!store.contains(forbidden), "pre.008 facade leaked physical backend material: {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;
|
||||
}
|
||||
323
crates/ksp-store-lib/tests/hardening_completeness.rs
Normal file
323
crates/ksp-store-lib/tests/hardening_completeness.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
// file: crates/ksp-store-lib/tests/hardening_completeness.rs
|
||||
// version: 6
|
||||
|
||||
#![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;
|
||||
}
|
||||
|
||||
fn raw_capability_trait_names<'a>(source: &'a str, implementor: &str) -> std::vec::Vec<&'a str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("impl ksp_store_api::Raw") || !trimmed.contains(implementor) {
|
||||
continue;
|
||||
}
|
||||
let trait_tail = match trimmed.strip_prefix("impl ksp_store_api::") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let trait_name = match trait_tail.split(" for ").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
names.push(trait_name);
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_facade_modules_and_crate_root_exports_are_exact() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
for required in ["mod constants;", "mod error;", "mod health;", "mod settings;", "mod store;"] {
|
||||
assert!(crate_root.contains(required), "missing Store facade module: {required}");
|
||||
}
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
let actual = public_reexport_names(crate_root);
|
||||
let mut expected = [
|
||||
"ERROR_CODE_BACKEND_CLOSED",
|
||||
"ERROR_CODE_BACKEND_NOT_COMPILED",
|
||||
"ERROR_CODE_BACKEND_OPEN_FAILED",
|
||||
"ERROR_CODE_POSTGRES_CONFIG_INVALID",
|
||||
"ERROR_CODE_POSTGRES_CONNECT_FAILED",
|
||||
"ERROR_CODE_POSTGRES_DATA_INVALID",
|
||||
"ERROR_CODE_POSTGRES_HEALTH_FAILED",
|
||||
"ERROR_CODE_POSTGRES_MIGRATION_FAILED",
|
||||
"ERROR_CODE_POSTGRES_MIGRATION_MISMATCH",
|
||||
"ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED",
|
||||
"ERROR_CODE_POSTGRES_POOL_TIMEOUT",
|
||||
"ERROR_CODE_POSTGRES_READ_FAILED",
|
||||
"ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED",
|
||||
"ERROR_CODE_POSTGRES_SCHEMA_NEWER",
|
||||
"ERROR_CODE_POSTGRES_TLS_FAILED",
|
||||
"ERROR_CODE_POSTGRES_WRITE_FAILED",
|
||||
"ERROR_CODE_RAW_CONFLICT",
|
||||
"ERROR_CODE_RAW_MODEL_INVALID",
|
||||
"ERROR_CODE_RAW_PAYLOAD_INVALID",
|
||||
"ERROR_CODE_RAW_PROVENANCE_INVALID",
|
||||
"ERROR_CODE_RAW_QUERY_INVALID",
|
||||
"ERROR_CODE_RAW_REFERENCE_NOT_FOUND",
|
||||
"ERROR_CODE_RAW_RETENTION_INVALID",
|
||||
"ERROR_CODE_SETTINGS_INVALID",
|
||||
"ERROR_CODE_SHUTDOWN_TIMEOUT",
|
||||
"ERROR_CODE_WRONG_NETWORK",
|
||||
"Error",
|
||||
"ErrorCode",
|
||||
"ErrorContext",
|
||||
"MAX_RAW_ACCOUNT_DATA_BYTES",
|
||||
"MAX_RAW_CODE_BYTES",
|
||||
"MAX_RAW_PAGE_CURSOR_BYTES",
|
||||
"MAX_RAW_PAYLOAD_BYTES",
|
||||
"MAX_RAW_SOURCE_PAYLOAD_BYTES",
|
||||
"MAX_RAW_UNIX_MILLIS",
|
||||
"PostgresBootstrapSettings",
|
||||
"PostgresPoolSettings",
|
||||
"PostgresStoreSettings",
|
||||
"PostgresTlsMode",
|
||||
"Pubkey",
|
||||
"RawAccountObservation",
|
||||
"RawAccountObservationRead",
|
||||
"RawAccountObservationWrite",
|
||||
"RawAccountState",
|
||||
"RawAccountStateQuery",
|
||||
"RawAccountStateRead",
|
||||
"RawAccountStateReference",
|
||||
"RawAccountStateWrite",
|
||||
"RawAcquisitionOrigin",
|
||||
"RawAcquisitionProvenance",
|
||||
"RawAcquisitionWriteOutcome",
|
||||
"RawContentHash",
|
||||
"RawEntityWriteOutcome",
|
||||
"RawFormatId",
|
||||
"RawNetworkId",
|
||||
"RawObservationKey",
|
||||
"RawObservationWriteOutcome",
|
||||
"RawPage",
|
||||
"RawPageCursor",
|
||||
"RawPageLimit",
|
||||
"RawPageRequest",
|
||||
"RawPayload",
|
||||
"RawProvenanceCode",
|
||||
"RawRetentionState",
|
||||
"RawRetentionWriteOutcome",
|
||||
"RawSlotRange",
|
||||
"RawSortDirection",
|
||||
"RawTimestamp",
|
||||
"RawTransaction",
|
||||
"RawTransactionAcquisitionMode",
|
||||
"RawTransactionObservation",
|
||||
"RawTransactionObservationRead",
|
||||
"RawTransactionObservationWrite",
|
||||
"RawTransactionQuery",
|
||||
"RawTransactionRead",
|
||||
"RawTransactionReference",
|
||||
"RawTransactionRetentionRead",
|
||||
"RawTransactionRetentionTransition",
|
||||
"RawTransactionRetentionWrite",
|
||||
"RawTransactionSignature",
|
||||
"RawTransactionTombstone",
|
||||
"RawTransactionWrite",
|
||||
"Result",
|
||||
"Store",
|
||||
"StoreApiFuture",
|
||||
"StoreBackendKind",
|
||||
"StoreBackendSettings",
|
||||
"StoreHealthSnapshot",
|
||||
"StoreHealthState",
|
||||
"StoreRuntimeSnapshot",
|
||||
"StoreSettings",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual.as_slice(), expected.as_slice());
|
||||
assert_eq!(actual.len(), 91);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_facade_manifest_and_feature_contract_are_exact() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
assert!(manifest.contains("default = [\"postgres\"]"));
|
||||
assert!(manifest.contains("postgres = [\"dep:ksp-store-postgres-lib\"]"));
|
||||
let actual = manifest_dependency_names(manifest);
|
||||
let expected = ["ksp-logging-lib", "ksp-store-api", "ksp-store-postgres-lib"];
|
||||
assert_eq!(actual.as_slice(), expected.as_slice());
|
||||
for forbidden in ["tokio-postgres", "deadpool-postgres", "rustls", "sqlx", "ksp-config-lib", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib"] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden Store facade dependency detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_secret_canary_never_crosses_settings_or_pre_io_error_debug() {
|
||||
let malformed = std::format!("not-a-postgresql-uri-{SECRET_CANARY}");
|
||||
let settings = hostile_settings(malformed.as_str());
|
||||
let rendered = std::format!("{settings:?}");
|
||||
assert!(!rendered.contains(SECRET_CANARY));
|
||||
assert!(rendered.contains("<redacted>"));
|
||||
let result = poll_ready(ksp_store_lib::Store::open(settings));
|
||||
let error = match result {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => panic!("hostile Store settings unexpectedly opened"),
|
||||
};
|
||||
#[cfg(feature = "postgres")]
|
||||
assert_eq!(error.code(), ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID);
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
assert_eq!(error.code(), ksp_store_lib::ERROR_CODE_BACKEND_NOT_COMPILED);
|
||||
assert!(!std::format!("{error}").contains(SECRET_CANARY));
|
||||
assert!(!std::format!("{error:?}").contains(SECRET_CANARY));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_facade_production_sources_keep_config_env_physical_sql_and_backend_handles_out() {
|
||||
let production = std::format!(
|
||||
"{}
|
||||
{}
|
||||
{}
|
||||
{}",
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/health.rs"),
|
||||
include_str!("../src/settings.rs"),
|
||||
include_str!("../src/store.rs")
|
||||
);
|
||||
for forbidden in [
|
||||
"ksp_config_lib",
|
||||
"std::env",
|
||||
"dotenv",
|
||||
"PGHOST",
|
||||
"PGPORT",
|
||||
"PGUSER",
|
||||
"PGPASSWORD",
|
||||
".pgpass",
|
||||
"tokio_postgres::Client",
|
||||
"tokio_postgres::Row",
|
||||
"tokio_postgres::Statement",
|
||||
"deadpool_postgres::Pool",
|
||||
"sqlx::",
|
||||
"CREATE TABLE",
|
||||
"INSERT INTO",
|
||||
"UPDATE ",
|
||||
"DELETE FROM",
|
||||
] {
|
||||
assert!(!production.contains(forbidden), "forbidden facade ownership/runtime material detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_010_facade_raw_capability_inventory_is_exactly_ten() {
|
||||
let store = include_str!("../src/store.rs");
|
||||
let capability_impls = [
|
||||
"impl ksp_store_api::RawAccountObservationRead for Store",
|
||||
"impl ksp_store_api::RawAccountObservationWrite for Store",
|
||||
"impl ksp_store_api::RawAccountStateRead for Store",
|
||||
"impl ksp_store_api::RawAccountStateWrite for Store",
|
||||
"impl ksp_store_api::RawTransactionObservationRead for Store",
|
||||
"impl ksp_store_api::RawTransactionObservationWrite for Store",
|
||||
"impl ksp_store_api::RawTransactionRead for Store",
|
||||
"impl ksp_store_api::RawTransactionRetentionRead for Store",
|
||||
"impl ksp_store_api::RawTransactionRetentionWrite for Store",
|
||||
"impl ksp_store_api::RawTransactionWrite for Store",
|
||||
];
|
||||
for implementation in capability_impls {
|
||||
assert_eq!(store.matches(implementation).count(), 1, "unexpected Store capability implementation inventory: {implementation}");
|
||||
}
|
||||
assert_eq!(store.matches("impl ksp_store_api::Raw").count(), 10);
|
||||
assert_eq!(store.matches("validate_operation_network(").count(), 14);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_010_facade_and_backend_raw_capability_sets_match_exactly_without_account_retention() {
|
||||
let store = include_str!("../src/store.rs");
|
||||
let backend = include_str!("../../ksp-store-postgres-lib/src/runtime.rs");
|
||||
let store_traits = raw_capability_trait_names(store, " for Store");
|
||||
let backend_traits = raw_capability_trait_names(backend, " for PostgresBackend");
|
||||
assert_eq!(store_traits.len(), 10);
|
||||
assert_eq!(backend_traits.len(), 10);
|
||||
assert_eq!(store_traits, backend_traits);
|
||||
for forbidden in ["RawAccountRetentionRead", "RawAccountRetentionWrite", "RawAccountDelete", "RawAccountCompaction"] {
|
||||
assert!(!store_traits.contains(&forbidden), "unexpected account capability added to Store: {forbidden}");
|
||||
assert!(!backend_traits.contains(&forbidden), "unexpected account capability added to PostgreSQL backend: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
98
crates/ksp-store-lib/tests/public_api.rs
Normal file
98
crates/ksp-store-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
// file: crates/ksp-store-lib/tests/public_api.rs
|
||||
// version: 8
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Public API canaries for Store settings, lifecycle and Store API reexports.
|
||||
|
||||
#[test]
|
||||
fn pre_003_settings_and_lifecycle_contract_are_available_from_crate_root() {
|
||||
let postgres = ksp_store_lib::PostgresStoreSettings::new(
|
||||
"postgresql://operator-supplied-sensitive-value",
|
||||
ksp_store_lib::PostgresPoolSettings::default(),
|
||||
ksp_store_lib::PostgresTlsMode::VerifyFull,
|
||||
ksp_store_lib::PostgresBootstrapSettings::default(),
|
||||
);
|
||||
let network = match ksp_store_lib::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid public API network rejected: {error:?}"),
|
||||
};
|
||||
let settings = ksp_store_lib::StoreSettings::with_default_shutdown(network, ksp_store_lib::StoreBackendSettings::Postgres(postgres));
|
||||
assert_eq!(settings.backend_kind(), ksp_store_lib::StoreBackendKind::Postgres);
|
||||
assert_eq!(settings.network().as_str(), "devnet");
|
||||
assert!(settings.validate().is_ok());
|
||||
let _open = ksp_store_lib::Store::open;
|
||||
let _close = ksp_store_lib::Store::close;
|
||||
let _runtime_snapshot = ksp_store_lib::Store::runtime_snapshot;
|
||||
let _health = ksp_store_lib::Store::health;
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_005_common_and_postgres_error_codes_are_stable_and_store_owned() {
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_SETTINGS_INVALID.domain(), "store");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_SETTINGS_INVALID.code(), "settings_invalid");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_NOT_COMPILED.code(), "backend_not_compiled");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_OPEN_FAILED.code(), "backend_open_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID.code(), "postgres_config_invalid");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONNECT_FAILED.code(), "postgres_connect_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_DATA_INVALID.code(), "postgres_data_invalid");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_HEALTH_FAILED.code(), "postgres_health_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_POOL_TIMEOUT.code(), "postgres_pool_timeout");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_READ_FAILED.code(), "postgres_read_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED.code(), "postgres_retention_compaction_unsupported");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_FAILED.code(), "postgres_migration_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH.code(), "postgres_migration_mismatch");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED.code(), "postgres_page_limit_unsupported");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_SCHEMA_NEWER.code(), "postgres_schema_newer");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_TLS_FAILED.code(), "postgres_tls_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_WRITE_FAILED.code(), "postgres_write_failed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_RAW_REFERENCE_NOT_FOUND.code(), "raw_reference_not_found");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_CLOSED.code(), "backend_closed");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_SHUTDOWN_TIMEOUT.code(), "shutdown_timeout");
|
||||
assert_eq!(ksp_store_lib::ERROR_CODE_WRONG_NETWORK.code(), "wrong_network");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_facade_reexports_backend_agnostic_store_api_types() {
|
||||
let _raw_transaction = std::mem::size_of::<std::option::Option<ksp_store_lib::RawTransaction>>();
|
||||
let _raw_account_state = std::mem::size_of::<std::option::Option<ksp_store_lib::RawAccountState>>();
|
||||
let _query = std::mem::size_of::<std::option::Option<ksp_store_lib::RawTransactionQuery>>();
|
||||
let _capability = std::mem::size_of::<std::option::Option<&dyn ksp_store_lib::RawTransactionRead>>();
|
||||
let _result: ksp_store_lib::Result<()> = std::result::Result::Ok(());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_health_and_runtime_snapshot_types_are_portable_crate_root_contracts() {
|
||||
let _state = std::mem::size_of::<std::option::Option<ksp_store_lib::StoreHealthState>>();
|
||||
let _health = std::mem::size_of::<std::option::Option<ksp_store_lib::StoreHealthSnapshot>>();
|
||||
let _runtime = std::mem::size_of::<std::option::Option<ksp_store_lib::StoreRuntimeSnapshot>>();
|
||||
return;
|
||||
}
|
||||
|
||||
fn assert_raw_capabilities<T>()
|
||||
where
|
||||
T: ksp_store_lib::RawAccountObservationRead
|
||||
+ ksp_store_lib::RawAccountObservationWrite
|
||||
+ ksp_store_lib::RawAccountStateRead
|
||||
+ ksp_store_lib::RawAccountStateWrite
|
||||
+ ksp_store_lib::RawTransactionObservationRead
|
||||
+ ksp_store_lib::RawTransactionObservationWrite
|
||||
+ ksp_store_lib::RawTransactionRead
|
||||
+ ksp_store_lib::RawTransactionRetentionRead
|
||||
+ ksp_store_lib::RawTransactionRetentionWrite
|
||||
+ ksp_store_lib::RawTransactionWrite,
|
||||
{
|
||||
let _marker = std::marker::PhantomData::<T>;
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_008_store_facade_implements_exact_raw_capability_set_10_of_10() {
|
||||
assert_raw_capabilities::<ksp_store_lib::Store>();
|
||||
return;
|
||||
}
|
||||
45
crates/ksp-store-lib/unit_tests/health.rs
Normal file
45
crates/ksp-store-lib/unit_tests/health.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// file: crates/ksp-store-lib/unit_tests/health.rs
|
||||
// version: 1
|
||||
|
||||
fn network() -> ksp_store_api::RawNetworkId {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid Store health test network rejected: {error:?}"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_snapshot_is_backend_neutral_and_contains_only_safe_counts() {
|
||||
let runtime = crate::StoreRuntimeSnapshot::new(crate::StoreBackendKind::Postgres, network(), 8, 3, 2, 1);
|
||||
assert_eq!(runtime.backend_kind(), crate::StoreBackendKind::Postgres);
|
||||
assert_eq!(runtime.network().as_str(), "devnet");
|
||||
assert_eq!(runtime.pool_capacity(), 8);
|
||||
assert_eq!(runtime.pool_size(), 3);
|
||||
assert_eq!(runtime.pool_available(), 2);
|
||||
assert_eq!(runtime.pool_waiting(), 1);
|
||||
let rendered = format!("{runtime:?}");
|
||||
for forbidden in ["postgresql://", "password", "username", "database", "SELECT ", "ksp_store_schema_migrations"] {
|
||||
assert!(!rendered.contains(forbidden), "unsafe runtime snapshot material detected: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_snapshot_carries_only_safe_state_migration_and_error_code() {
|
||||
let runtime = crate::StoreRuntimeSnapshot::new(crate::StoreBackendKind::Postgres, network(), 8, 1, 1, 0);
|
||||
let ready = crate::StoreHealthSnapshot::new(crate::StoreHealthState::Ready, runtime.clone(), std::option::Option::Some(0), 0, std::option::Option::None);
|
||||
assert_eq!(ready.state(), crate::StoreHealthState::Ready);
|
||||
assert_eq!(ready.migration_version(), std::option::Option::Some(0));
|
||||
assert_eq!(ready.pending_migration_count(), 0);
|
||||
assert_eq!(ready.last_error_code(), std::option::Option::None);
|
||||
let not_ready = crate::StoreHealthSnapshot::new(
|
||||
crate::StoreHealthState::NotReady,
|
||||
runtime,
|
||||
std::option::Option::None,
|
||||
0,
|
||||
std::option::Option::Some(crate::ERROR_CODE_POSTGRES_HEALTH_FAILED),
|
||||
);
|
||||
assert_eq!(not_ready.state(), crate::StoreHealthState::NotReady);
|
||||
assert_eq!(not_ready.last_error_code(), std::option::Option::Some(crate::ERROR_CODE_POSTGRES_HEALTH_FAILED));
|
||||
return;
|
||||
}
|
||||
155
crates/ksp-store-lib/unit_tests/settings.rs
Normal file
155
crates/ksp-store-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
// file: crates/ksp-store-lib/unit_tests/settings.rs
|
||||
// version: 4
|
||||
|
||||
fn valid_network() -> crate::RawNetworkId {
|
||||
return match crate::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid test network rejected: {error:?}"),
|
||||
};
|
||||
}
|
||||
|
||||
fn valid_postgres_settings() -> crate::PostgresStoreSettings {
|
||||
return crate::PostgresStoreSettings::new(
|
||||
"postgresql://secret-user:secret-password@db.internal/ksp",
|
||||
crate::PostgresPoolSettings::default(),
|
||||
crate::PostgresTlsMode::VerifyFull,
|
||||
crate::PostgresBootstrapSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_pre_001_runtime_bounds() {
|
||||
let pool = crate::PostgresPoolSettings::default();
|
||||
assert_eq!(pool.max_connections(), 8);
|
||||
assert_eq!(pool.connect_timeout(), std::time::Duration::from_millis(10_000));
|
||||
assert_eq!(pool.wait_timeout(), std::time::Duration::from_millis(5_000));
|
||||
assert_eq!(pool.create_timeout(), std::time::Duration::from_millis(10_000));
|
||||
assert_eq!(pool.recycle_timeout(), std::time::Duration::from_millis(5_000));
|
||||
let bootstrap = crate::PostgresBootstrapSettings::default();
|
||||
assert!(bootstrap.auto_migrate());
|
||||
assert!(bootstrap.schema_autocreate());
|
||||
assert!(bootstrap.schema_autoupdate());
|
||||
let split_policy =
|
||||
crate::PostgresBootstrapSettings::with_schema_policy(false, true, std::time::Duration::from_millis(30_000), std::time::Duration::from_millis(10_000));
|
||||
assert!(!split_policy.schema_autocreate());
|
||||
assert!(split_policy.schema_autoupdate());
|
||||
assert_eq!(bootstrap.migration_timeout(), std::time::Duration::from_millis(30_000));
|
||||
assert_eq!(bootstrap.migration_lock_timeout(), std::time::Duration::from_millis(10_000));
|
||||
let store = crate::StoreSettings::with_default_shutdown(valid_network(), crate::StoreBackendSettings::Postgres(valid_postgres_settings()));
|
||||
assert_eq!(store.shutdown_timeout(), std::time::Duration::from_millis(5_000));
|
||||
assert_eq!(store.backend_kind(), crate::StoreBackendKind::Postgres);
|
||||
assert_eq!(store.network().as_str(), "devnet");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_runtime_boundaries_validate_and_adjacent_values_are_rejected() {
|
||||
let minimum_pool = crate::PostgresPoolSettings::new(
|
||||
1,
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
);
|
||||
assert!(minimum_pool.validate().is_ok());
|
||||
let maximum_pool = crate::PostgresPoolSettings::new(
|
||||
64,
|
||||
std::time::Duration::from_millis(60_000),
|
||||
std::time::Duration::from_millis(60_000),
|
||||
std::time::Duration::from_millis(60_000),
|
||||
std::time::Duration::from_millis(60_000),
|
||||
);
|
||||
assert!(maximum_pool.validate().is_ok());
|
||||
assert!(
|
||||
crate::PostgresPoolSettings::new(
|
||||
0,
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::PostgresPoolSettings::new(
|
||||
65,
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::PostgresPoolSettings::new(
|
||||
1,
|
||||
std::time::Duration::from_millis(99),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::PostgresPoolSettings::new(
|
||||
1,
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(60_001),
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(100),
|
||||
)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
let minimum_bootstrap = crate::PostgresBootstrapSettings::new(false, std::time::Duration::from_millis(1_000), std::time::Duration::from_millis(100));
|
||||
assert!(minimum_bootstrap.validate().is_ok());
|
||||
let maximum_bootstrap = crate::PostgresBootstrapSettings::new(true, std::time::Duration::from_millis(300_000), std::time::Duration::from_millis(120_000));
|
||||
assert!(maximum_bootstrap.validate().is_ok());
|
||||
assert!(
|
||||
crate::PostgresBootstrapSettings::new(true, std::time::Duration::from_millis(999), std::time::Duration::from_millis(100),)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::PostgresBootstrapSettings::new(true, std::time::Duration::from_millis(1_000), std::time::Duration::from_millis(120_001),)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_shutdown_bound_is_independent_from_backend_and_rejects_outside_values() {
|
||||
let valid =
|
||||
crate::StoreSettings::new(valid_network(), crate::StoreBackendSettings::Postgres(valid_postgres_settings()), std::time::Duration::from_millis(100));
|
||||
assert!(valid.validate().is_ok());
|
||||
let invalid =
|
||||
crate::StoreSettings::new(valid_network(), crate::StoreBackendSettings::Postgres(valid_postgres_settings()), std::time::Duration::from_millis(30_001));
|
||||
let error = invalid.validate().err();
|
||||
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_SETTINGS_INVALID));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_uri_is_required_but_never_rendered_by_debug_or_validation_error() {
|
||||
let secret = "postgresql://secret-user:secret-password@db.internal/ksp";
|
||||
let settings = valid_postgres_settings();
|
||||
let debug = format!("{settings:?}");
|
||||
assert!(!debug.contains(secret));
|
||||
assert!(!debug.contains("secret-user"));
|
||||
assert!(!debug.contains("secret-password"));
|
||||
assert!(debug.contains("<redacted>"));
|
||||
let empty = crate::PostgresStoreSettings::new(
|
||||
std::string::String::new(),
|
||||
crate::PostgresPoolSettings::default(),
|
||||
crate::PostgresTlsMode::Disabled,
|
||||
crate::PostgresBootstrapSettings::default(),
|
||||
);
|
||||
let error = empty.validate().err();
|
||||
assert_eq!(error.as_ref().map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_SETTINGS_INVALID));
|
||||
assert!(!format!("{:?}", error).contains(secret));
|
||||
return;
|
||||
}
|
||||
107
crates/ksp-store-lib/unit_tests/store.rs
Normal file
107
crates/ksp-store-lib/unit_tests/store.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
// file: crates/ksp-store-lib/unit_tests/store.rs
|
||||
// version: 6
|
||||
|
||||
fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||
let mut future = std::boxed::Box::pin(future);
|
||||
let waker = std::task::Waker::noop();
|
||||
let mut context = std::task::Context::from_waker(waker);
|
||||
return match std::future::Future::poll(future.as_mut(), &mut context) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => panic!("Store pre-I/O rejection unexpectedly became pending"),
|
||||
};
|
||||
}
|
||||
|
||||
fn valid_network() -> crate::RawNetworkId {
|
||||
return match crate::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid test network rejected: {error:?}"),
|
||||
};
|
||||
}
|
||||
|
||||
fn store_settings(connection_uri: &str) -> crate::StoreSettings {
|
||||
let postgres = crate::PostgresStoreSettings::new(
|
||||
connection_uri,
|
||||
crate::PostgresPoolSettings::default(),
|
||||
crate::PostgresTlsMode::Disabled,
|
||||
crate::PostgresBootstrapSettings::default(),
|
||||
);
|
||||
return crate::StoreSettings::with_default_shutdown(valid_network(), crate::StoreBackendSettings::Postgres(postgres));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_settings_are_rejected_before_backend_dispatch() {
|
||||
let result = poll_ready(crate::Store::open(store_settings("")));
|
||||
let error = result.err();
|
||||
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_SETTINGS_INVALID));
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
#[test]
|
||||
fn compiled_postgres_rejects_malformed_physical_configuration_before_io() {
|
||||
let secret_canary = "not-a-postgresql-uri-secret-canary";
|
||||
let result = poll_ready(crate::Store::open(store_settings(secret_canary)));
|
||||
let error = result.err();
|
||||
assert_eq!(error.as_ref().map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_POSTGRES_CONFIG_INVALID));
|
||||
assert!(!format!("{error:?}").contains(secret_canary));
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
#[test]
|
||||
fn known_postgres_without_feature_is_rejected_before_io() {
|
||||
let result = poll_ready(crate::Store::open(store_settings("postgresql://operator-supplied-sensitive-value@localhost/ksp")));
|
||||
let error = result.err();
|
||||
assert_eq!(error.map(|value| return value.code()), std::option::Option::Some(crate::ERROR_CODE_BACKEND_NOT_COMPILED));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_008_operation_network_guard_rejects_mismatch_without_echoing_requested_network() {
|
||||
let store_network = valid_network();
|
||||
let hostile = match crate::RawNetworkId::new("other-network") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid alternate network rejected: {error:?}"),
|
||||
};
|
||||
let result = super::validate_operation_network(&store_network, &hostile, crate::StoreBackendKind::Postgres);
|
||||
let error = match result {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(()) => panic!("wrong operation network unexpectedly accepted"),
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_WRONG_NETWORK);
|
||||
assert!(!std::format!("{error:?}").contains("other-network"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
#[test]
|
||||
fn pre_010_postgres_error_code_mapping_covers_every_current_backend_kind() {
|
||||
let cases = [
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid, crate::ERROR_CODE_POSTGRES_CONFIG_INVALID),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed, crate::ERROR_CODE_POSTGRES_CONNECT_FAILED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout, crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed, crate::ERROR_CODE_POSTGRES_HEALTH_FAILED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict, ksp_store_api::ERROR_CODE_RAW_CONFLICT),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid, crate::ERROR_CODE_POSTGRES_DATA_INVALID),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed, crate::ERROR_CODE_POSTGRES_MIGRATION_FAILED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported, crate::ERROR_CODE_POSTGRES_PAGE_LIMIT_UNSUPPORTED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch, crate::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid, ksp_store_api::ERROR_CODE_RAW_QUERY_INVALID),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed, crate::ERROR_CODE_POSTGRES_READ_FAILED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound, crate::ERROR_CODE_RAW_REFERENCE_NOT_FOUND),
|
||||
(
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported,
|
||||
crate::ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED,
|
||||
),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer, crate::ERROR_CODE_POSTGRES_SCHEMA_NEWER),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout, crate::ERROR_CODE_SHUTDOWN_TIMEOUT),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed, crate::ERROR_CODE_POSTGRES_TLS_FAILED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed, crate::ERROR_CODE_POSTGRES_WRITE_FAILED),
|
||||
(ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork, crate::ERROR_CODE_WRONG_NETWORK),
|
||||
];
|
||||
assert_eq!(cases.len(), 18);
|
||||
for (kind, expected) in cases {
|
||||
assert_eq!(super::postgres_error_code(kind), expected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
22
crates/ksp-store-postgres-lib/Cargo.toml
Normal file
22
crates/ksp-store-postgres-lib/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# file: crates/ksp-store-postgres-lib/Cargo.toml
|
||||
# version: 3
|
||||
|
||||
[package]
|
||||
name = "ksp-store-postgres-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
deadpool-postgres = { workspace = true, features = ["rt_tokio_1"] }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-store-api = { path = "../ksp-store-api" }
|
||||
rustls = { workspace = true, features = ["aws_lc_rs", "std", "tls12"] }
|
||||
rustls-native-certs.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "time"] }
|
||||
tokio-postgres = { workspace = true, features = ["runtime"] }
|
||||
tokio-postgres-rustls = { workspace = true, features = ["aws-lc-rs"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
184
crates/ksp-store-postgres-lib/README.md
Normal file
184
crates/ksp-store-postgres-lib/README.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<!-- file: crates/ksp-store-postgres-lib/README.md -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# ksp-store-postgres-lib
|
||||
|
||||
`ksp-store-postgres-lib` est le backend PostgreSQL physique officiel du Store KSP.
|
||||
|
||||
La crate implémente connexion, pool, TLS, migrations, health et persistence RAW derrière `ksp-store-lib`. Elle dépend directement de `ksp-store-api` mais ne dépend jamais de la façade `ksp-store-lib`.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La crate possède seule pour PostgreSQL :
|
||||
|
||||
- le parsing et la normalisation de la configuration physique `tokio-postgres` ;
|
||||
- le pool borné `deadpool-postgres` ;
|
||||
- la policy TLS physique avec Rustls ;
|
||||
- les roots système et le provider cryptographique AWS-LC ;
|
||||
- le bootstrap et le moteur de migrations privé KSP ;
|
||||
- la table metadata `ksp_store_schema_migrations` ;
|
||||
- l'advisory transaction lock borné des migrations ;
|
||||
- les schémas physiques et statements privés `RawTransaction` et `RawAccountState` ;
|
||||
- les snapshots runtime/health sûrs destinés au bridge de façade ;
|
||||
- la fermeture explicite du pool et son fallback `Drop` best-effort ;
|
||||
- la classification d'erreurs backend sans conserver le texte d'erreur PostgreSQL.
|
||||
|
||||
## Frontière d'utilisation
|
||||
|
||||
Les applications, jobs et workers KSP ne dépendent normalement pas de cette crate :
|
||||
|
||||
```text
|
||||
consumer -> ksp-store-lib -> [feature postgres] ksp-store-postgres-lib
|
||||
```
|
||||
|
||||
La surface publique de cette crate existe pour le bridge inter-crates et les tests/intégrations backend. Elle ne constitue pas une seconde façade Store.
|
||||
|
||||
`ksp-store-postgres-lib` ne réexporte pas `tokio-postgres`, Deadpool ou Rustls.
|
||||
|
||||
## Connexion et pool
|
||||
|
||||
`PostgresBackend::open` :
|
||||
|
||||
1. valide et normalise l'URI fournie explicitement ;
|
||||
2. impose la policy TLS typée ;
|
||||
3. construit un pool borné ;
|
||||
4. prouve une connexion physique ;
|
||||
5. vérifie/applique le bootstrap selon les settings ;
|
||||
6. ne retourne qu'après succès de cette fondation.
|
||||
|
||||
Le backend ne lit aucun environnement, `.env`, `PG*`, `.pgpass` ou fichier TLS implicite libpq.
|
||||
|
||||
## TLS
|
||||
|
||||
Les modes sont exactement :
|
||||
|
||||
```text
|
||||
Disabled
|
||||
VerifyFull
|
||||
```
|
||||
|
||||
`VerifyFull` exige TLS, roots système, certificat valide et vérification de l'identité serveur. Une configuration ne permettant pas de vérifier cette identité, comme `hostaddr` seul, est rejetée.
|
||||
|
||||
## Migrations et schéma
|
||||
|
||||
Le moteur de migrations embarqué vérifie version logique, nom et checksum SHA-256, sérialise les runners par advisory transaction lock et refuse une history divergente ou plus récente que le runtime.
|
||||
|
||||
Le bootstrap metadata est conservé comme migration V000. La migration logique V001 matérialise le schéma `RawTransaction` et V002 le schéma `RawAccountState`, chacune en ressources séparées `tables/`, `constraints/` et `indexes/` afin que le backend puisse vérifier leur compatibilité effective sans transformer les fichiers SQL en parser généraliste.
|
||||
|
||||
La base est liée à un seul `RawNetworkId` via `ksp_store_identity`. Une migration enregistrée mais physiquement divergente est un mismatch ; les réparations additives sûres dépendent de `schema_autoupdate`.
|
||||
|
||||
## Health et erreurs
|
||||
|
||||
`PostgresBackendRuntimeSnapshot` et `PostgresBackendHealthSnapshot` ne contiennent que des compteurs et états sûrs destinés à la façade.
|
||||
|
||||
`PostgresBackendError` ne conserve que :
|
||||
|
||||
```text
|
||||
PostgresBackendErrorKind
|
||||
phase statique
|
||||
```
|
||||
|
||||
Le texte d'erreur PostgreSQL, l'URI, SQL et les valeurs bind ne traversent pas cette frontière.
|
||||
|
||||
## Support PostgreSQL
|
||||
|
||||
Le major minimal supporté est PostgreSQL 15. Le backend ne fixe aucun plafond arbitraire de major ; la compatibilité opérationnelle reste fondée sur le contrat de schéma KSP et l'introspection du catalogue.
|
||||
|
||||
## Lectures RAW transaction
|
||||
|
||||
Le backend expose :
|
||||
|
||||
```text
|
||||
get_raw_transaction
|
||||
get_raw_transaction_observation
|
||||
get_raw_transaction_retention_state
|
||||
get_raw_transaction_tombstone
|
||||
```
|
||||
|
||||
Le SQL et les rows restent privés. Le mapping PostgreSQL est fallible et couvre notamment `NUMERIC(20,0) -> u64`, `BIGINT -> u32/u64`, timestamps bornés, bytes de taille fixe et codes de provenance.
|
||||
|
||||
`Full` lit le payload chaud, `Archived` le reconstruit depuis la relation archive et `Purged` retourne `None`; le tombstone reste accessible séparément.
|
||||
|
||||
## Écritures RAW transaction
|
||||
|
||||
Le backend expose :
|
||||
|
||||
```text
|
||||
persist_raw_transaction_acquisition
|
||||
record_raw_transaction_observation
|
||||
```
|
||||
|
||||
L'acquisition canonique et son observation initiale sont commises dans une seule transaction PostgreSQL. Les clés uniques physiques fournissent l'admission idempotente ; après un conflit unique, le backend verrouille la ligne gagnante et compare le contenu réel avant de conclure `AlreadyPresent` ou `Conflict`.
|
||||
|
||||
Un tombstone `Purged` compatible produit `SkippedPurged/NotRecorded` en mode normal. `ForceRehydrate` restaure explicitement le payload `Full` et l'observation dans la même transaction.
|
||||
|
||||
## Pagination RAW transaction
|
||||
|
||||
`list_raw_transactions` parcourt les références canoniques récupérables avec un ordre total `(slot, signature)`. Les tombstones `Purged` sont exclus.
|
||||
|
||||
La continuation est une keyset stricte, jamais un `OFFSET`. Le cursor backend V1 est opaque et lié au réseau, à la direction et aux bornes de slots de la query. Store n'impose aucun plafond métier arbitraire à la taille de page ; seule la limitation physique du `LIMIT + 1` PostgreSQL est exposée.
|
||||
|
||||
## Rétention RAW transaction
|
||||
|
||||
`transition_raw_transaction_retention` applique les transitions physiques :
|
||||
|
||||
```text
|
||||
Full -> Archived -> Purged
|
||||
```
|
||||
|
||||
Le backend verrouille la ligne canonique avec `FOR UPDATE`, compare l'état courant à l'état attendu et applique la mutation atomiquement. L'archivage conserve le payload exact dans la relation archive ; la purge conserve seulement le tombstone minimal.
|
||||
|
||||
Toute transition impliquant `Compacted` est rejetée avec `RetentionCompactionUnsupported` tant qu'aucune représentation compactée réelle n'est implémentée.
|
||||
|
||||
## Lectures RAW account
|
||||
|
||||
Le backend expose :
|
||||
|
||||
```text
|
||||
get_raw_account_state
|
||||
get_raw_account_observation
|
||||
```
|
||||
|
||||
`get_raw_account_state` reconstruit l'état complet à partir de `(pubkey, slot, state_hash)` sans narrowing du domaine `u64`. Les bytes `pubkey`, `owner` et `state_hash` sont revalidés à leur largeur exacte et `data` reste un `BYTEA` complet, vide autorisé, borné par le contrat Store API.
|
||||
|
||||
`get_raw_account_observation` reconstruit la provenance commune et les métadonnées account optionnelles, notamment `is_startup`, `transaction_signature` et `write_version`. La signature est une metadata fixed-width et ne crée aucune FK vers la famille transaction.
|
||||
|
||||
## Écritures RAW account
|
||||
|
||||
Le backend expose :
|
||||
|
||||
```text
|
||||
persist_raw_account_acquisition
|
||||
record_raw_account_observation
|
||||
```
|
||||
|
||||
L'acquisition état+observation est transactionnelle. Les inserts utilisent `ON CONFLICT ... DO NOTHING`, puis verrouillent et comparent le contenu gagnant avant de conclure `AlreadyPresent` ou `Conflict`; aucun `DO UPDATE` n'est utilisé. Une collision divergente d'observation fait échouer toute l'acquisition et rollback un éventuel nouvel état.
|
||||
|
||||
L'ajout d'une observation vérifie que l'état référencé existe déjà et ne crée jamais implicitement cet état.
|
||||
|
||||
## Pagination RAW account
|
||||
|
||||
`list_raw_account_states` parcourt les références selon l'ordre total `(slot, pubkey, state_hash)`, en ASC ou DESC, avec filtre pubkey optionnel. La continuation est keyset, sans `OFFSET`.
|
||||
|
||||
Le cursor `KSPA` est opaque et lié au réseau, au filtre pubkey, à la direction, aux bornes de slots et à la dernière clé complète. Il est distinct du cursor transaction `KSPT`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
La crate ne contient :
|
||||
|
||||
- aucune rétention, archive, purge, suppression ou compaction account ;
|
||||
- aucune orchestration worker/job ;
|
||||
- aucun transport d'acquisition ou decoder Program ;
|
||||
- aucune policy autonome de batch, priorité ou rétention.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — guide pratique du bridge physique et de ses capabilities ;
|
||||
- [`../ksp-store-lib/README.md`](../ksp-store-lib/README.md) — façade runtime destinée aux consumers ;
|
||||
- [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) — architecture Store ;
|
||||
- [`../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md`](../../docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md) — décisions pool/TLS/migrations ;
|
||||
- [`../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md`](../../docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md) — validation de fondation ;
|
||||
- [`../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md`](../../docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md) — design `RawTransaction` ;
|
||||
- [`../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md`](../../docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md) — validation `RawTransaction` ;
|
||||
- [`../../docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md`](../../docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md) — design `RawAccountState` et complétude RAW ;
|
||||
- [`../../docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md`](../../docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md) — validation `RawAccountState` et conformance RAW.
|
||||
292
crates/ksp-store-postgres-lib/USAGE.md
Normal file
292
crates/ksp-store-postgres-lib/USAGE.md
Normal file
@@ -0,0 +1,292 @@
|
||||
<!-- file: crates/ksp-store-postgres-lib/USAGE.md -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# Utilisation de ksp-store-postgres-lib
|
||||
|
||||
## 1. Quand dépendre directement du backend
|
||||
|
||||
Le consumer applicatif normal utilise `ksp-store-lib`.
|
||||
|
||||
Une dépendance directe à `ksp-store-postgres-lib` est réservée aux composants qui implémentent, intègrent ou testent le bridge physique PostgreSQL. Cette crate ne doit pas devenir une façade Store parallèle.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ksp-store-api = { path = "../ksp-store-api" }
|
||||
ksp-store-postgres-lib = { path = "../ksp-store-postgres-lib" }
|
||||
```
|
||||
|
||||
Le backend reçoit les modèles et traits backend-neutral de `ksp-store-api`; il ne réexporte pas `tokio-postgres`, Deadpool ou Rustls.
|
||||
|
||||
## 2. Construire les settings physiques
|
||||
|
||||
Pour distinguer création initiale et mise à jour du schéma, utiliser `PostgresBackendSettings::with_schema_policy` :
|
||||
|
||||
```rust
|
||||
fn backend_settings(
|
||||
network: ksp_store_api::RawNetworkId,
|
||||
connection_uri: std::string::String,
|
||||
) -> ksp_store_postgres_lib::PostgresBackendSettings {
|
||||
return ksp_store_postgres_lib::PostgresBackendSettings::with_schema_policy(
|
||||
network,
|
||||
connection_uri,
|
||||
8,
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
std::time::Duration::from_secs(10),
|
||||
std::time::Duration::from_secs(5),
|
||||
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
|
||||
true,
|
||||
true,
|
||||
std::time::Duration::from_secs(30),
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`schema_autocreate` autorise l'initialisation d'un Store vierge. `schema_autoupdate` autorise les migrations pending et les réparations additives sûres d'une migration déjà enregistrée. Le constructeur `new(..., auto_migrate, ...)` existe pour les callers utilisant encore un switch unique et applique cette valeur aux deux politiques.
|
||||
|
||||
L'URI est sensible : elle n'est jamais rendue par `Debug`.
|
||||
|
||||
## 3. Ouvrir, sonder et fermer le backend
|
||||
|
||||
```rust
|
||||
async fn use_backend(
|
||||
settings: ksp_store_postgres_lib::PostgresBackendSettings,
|
||||
) -> std::result::Result<(), ksp_store_postgres_lib::PostgresBackendError> {
|
||||
let backend = ksp_store_postgres_lib::PostgresBackend::open(settings).await;
|
||||
let backend = match backend {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let runtime = backend.runtime_snapshot();
|
||||
let _capacity = runtime.pool_capacity();
|
||||
let _size = runtime.pool_size();
|
||||
let _available = runtime.pool_available();
|
||||
let _waiting = runtime.pool_waiting();
|
||||
|
||||
let health = backend.health().await;
|
||||
let _ready = health.is_ready();
|
||||
let _migration_version = health.migration_version();
|
||||
let _pending = health.pending_migration_count();
|
||||
let _safe_error_kind = health.error_kind();
|
||||
|
||||
return backend.close(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
```
|
||||
|
||||
`open` valide la configuration, construit le pool, prouve une connexion et vérifie/applique le bootstrap avant de retourner. `close` ferme le pool et attend son drain dans la deadline fournie.
|
||||
|
||||
Une instance physique est liée à un seul `RawNetworkId`.
|
||||
|
||||
## 4. Choisir le mode TLS
|
||||
|
||||
Pour une connexion PostgreSQL protégée :
|
||||
|
||||
```rust
|
||||
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull
|
||||
```
|
||||
|
||||
`VerifyFull` impose TLS, les roots système et la vérification de l'identité serveur. Une configuration ne fournissant pas d'identité vérifiable est rejetée.
|
||||
|
||||
Pour une topologie explicitement non chiffrée :
|
||||
|
||||
```rust
|
||||
ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled
|
||||
```
|
||||
|
||||
La policy typée choisie par KSP prime sur les paramètres SSL de l'URI.
|
||||
|
||||
## 5. Lire une transaction et ses métadonnées
|
||||
|
||||
Les méthodes backend retournent uniquement des modèles `ksp-store-api`.
|
||||
|
||||
```rust
|
||||
async fn read_transaction_state(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
reference: &ksp_store_api::RawTransactionReference,
|
||||
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransaction>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
let retention = backend.get_raw_transaction_retention_state(reference).await;
|
||||
if let std::result::Result::Err(error) = retention {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
let tombstone = backend.get_raw_transaction_tombstone(reference).await;
|
||||
if let std::result::Result::Err(error) = tombstone {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
return backend.get_raw_transaction(reference).await;
|
||||
}
|
||||
```
|
||||
|
||||
`Full` lit le payload chaud, `Archived` reconstruit le payload depuis l'archive et `Purged` retourne `None`. Un tombstone purgé reste lisible séparément.
|
||||
|
||||
Un réseau différent de celui du backend est rejeté avant acquisition d'un client du pool.
|
||||
|
||||
## 6. Lire une observation
|
||||
|
||||
```rust
|
||||
async fn read_observation(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
key: &ksp_store_api::RawObservationKey,
|
||||
) -> std::result::Result<std::option::Option<ksp_store_api::RawTransactionObservation>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.get_raw_transaction_observation(key).await;
|
||||
}
|
||||
```
|
||||
|
||||
Les rows PostgreSQL, SQLSTATE, statements et valeurs de bind ne traversent jamais cette API.
|
||||
|
||||
## 7. Persister une acquisition canonique
|
||||
|
||||
```rust
|
||||
async fn persist_acquisition(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
transaction: ksp_store_api::RawTransaction,
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
) -> std::result::Result<ksp_store_api::RawAcquisitionWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend
|
||||
.persist_raw_transaction_acquisition(
|
||||
transaction,
|
||||
observation,
|
||||
ksp_store_api::RawTransactionAcquisitionMode::Normal,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
L'opération est atomique : le canonique et son observation initiale sont tous deux durables ou aucun ne l'est. Une identité déjà présente avec un contenu identique est idempotente ; un contenu divergent retourne `PostgresBackendErrorKind::Conflict` sans overwrite silencieux.
|
||||
|
||||
Pour un tombstone purgé compatible, le mode `Normal` ne restaure pas le payload. `ForceRehydrate` doit être demandé explicitement pour rétablir un payload `Full`.
|
||||
|
||||
## 8. Ajouter une observation à un canonique existant
|
||||
|
||||
```rust
|
||||
async fn record_observation(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
observation: ksp_store_api::RawTransactionObservation,
|
||||
) -> std::result::Result<ksp_store_api::RawObservationWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.record_raw_transaction_observation(observation).await;
|
||||
}
|
||||
```
|
||||
|
||||
Cette opération ne crée jamais la transaction canonique. Une référence absente retourne `ReferenceNotFound`; une transaction purgée produit l'outcome `NotRecorded` prévu par l'API.
|
||||
|
||||
## 9. Paginer les transactions
|
||||
|
||||
```rust
|
||||
async fn list_transactions(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
query: &ksp_store_api::RawTransactionQuery,
|
||||
) -> std::result::Result<ksp_store_api::RawPage<ksp_store_api::RawTransactionReference>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.list_raw_transactions(query).await;
|
||||
}
|
||||
```
|
||||
|
||||
La navigation est keyset sur `(slot, signature)` et exclut les tombstones `Purged`. Le cursor retourné est opaque et lié au réseau, à la direction et aux bornes de slots de la query qui l'a produit.
|
||||
|
||||
Le backend n'utilise pas `OFFSET` et n'impose pas de plafond métier arbitraire. La seule borne exposée ici provient de la représentation physique de `LIMIT + 1` dans PostgreSQL.
|
||||
|
||||
## 10. Appliquer une transition de rétention
|
||||
|
||||
```rust
|
||||
async fn apply_retention(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||
) -> std::result::Result<ksp_store_api::RawRetentionWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.transition_raw_transaction_retention(transition).await;
|
||||
}
|
||||
```
|
||||
|
||||
Le backend applique la transition choisie par le caller ; il ne décide pas de la policy d'éligibilité. Les transitions physiques prises en charge sont `Full -> Archived` puis `Archived -> Purged`.
|
||||
|
||||
Une transition impliquant `Compacted` est refusée avec `PostgresBackendErrorKind::RetentionCompactionUnsupported` tant qu'aucune représentation compactée réelle n'est disponible.
|
||||
|
||||
## 11. Lire et paginer les états account
|
||||
|
||||
```rust
|
||||
async fn read_account_state(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
reference: &ksp_store_api::RawAccountStateReference,
|
||||
) -> std::result::Result<std::option::Option<ksp_store_api::RawAccountState>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.get_raw_account_state(reference).await;
|
||||
}
|
||||
```
|
||||
|
||||
Pour la navigation, construire un `RawAccountStateQuery` puis appeler :
|
||||
|
||||
```rust
|
||||
async fn list_account_states(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
query: &ksp_store_api::RawAccountStateQuery,
|
||||
) -> std::result::Result<ksp_store_api::RawPage<ksp_store_api::RawAccountStateReference>, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.list_raw_account_states(query).await;
|
||||
}
|
||||
```
|
||||
|
||||
La pagination est keyset sur `(slot, pubkey, state_hash)` avec filtre pubkey optionnel. Le cursor `KSPA` est opaque, lié au contexte de query et distinct du cursor transaction.
|
||||
|
||||
## 12. Persister une acquisition account
|
||||
|
||||
```rust
|
||||
async fn persist_account_acquisition(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
state: ksp_store_api::RawAccountState,
|
||||
observation: ksp_store_api::RawAccountObservation,
|
||||
) -> std::result::Result<ksp_store_api::RawAcquisitionWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.persist_raw_account_acquisition(state, observation).await;
|
||||
}
|
||||
```
|
||||
|
||||
Le backend exige le même réseau et la même référence complète entre l'état et l'observation avant l'I/O métier. L'opération est atomique et idempotente par comparaison exacte du contenu persistant ; un contenu divergent produit `PostgresBackendErrorKind::Conflict` sans overwrite silencieux.
|
||||
|
||||
## 13. Lire et ajouter une observation account
|
||||
|
||||
```rust
|
||||
async fn record_account_observation(
|
||||
backend: &ksp_store_postgres_lib::PostgresBackend,
|
||||
observation: ksp_store_api::RawAccountObservation,
|
||||
) -> std::result::Result<ksp_store_api::RawObservationWriteOutcome, ksp_store_postgres_lib::PostgresBackendError> {
|
||||
return backend.record_raw_account_observation(observation).await;
|
||||
}
|
||||
```
|
||||
|
||||
La lecture correspondante utilise `get_raw_account_observation`. L'ajout exige un état déjà durable et retourne `ReferenceNotFound` lorsqu'il manque. Les métadonnées Yellowstone optionnelles sont conservées sans créer de couplage physique vers `RawTransaction`.
|
||||
|
||||
## 14. Classifier les erreurs sans fuite
|
||||
|
||||
```rust
|
||||
fn classify(error: &ksp_store_postgres_lib::PostgresBackendError) {
|
||||
match error.kind() {
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::DataInvalid => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::HealthFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::PageLimitUnsupported => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork => {}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let _safe_phase = error.phase();
|
||||
}
|
||||
```
|
||||
|
||||
`PostgresBackendError` conserve uniquement une classification KSP et une phase statique. Ne pas reconstruire de diagnostic utilisateur à partir d'une erreur brute PostgreSQL.
|
||||
|
||||
## 15. Limites du backend direct
|
||||
|
||||
Le backend ne lit aucune variable d'environnement et ne possède aucune sélection de target Config. Les applications, jobs et workers doivent normalement passer par `ksp-store-lib`.
|
||||
|
||||
Les dix capabilities RAW communes sont implémentées par ce backend. Les décisions de batch, priorité, backlog, scheduling et policy de rétention restent hors de sa responsabilité ; aucune rétention/archivage/purge account n'est fournie.
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE ksp_store_schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DO $ksp$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'pk_ksp_store_identity'
|
||||
AND conrelid = to_regclass('ksp_store_identity')
|
||||
) THEN
|
||||
ALTER TABLE ksp_store_identity ADD CONSTRAINT pk_ksp_store_identity PRIMARY KEY (singleton);
|
||||
END IF;
|
||||
END
|
||||
$ksp$;
|
||||
@@ -0,0 +1,12 @@
|
||||
DO $ksp$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_ksp_store_identity_singleton'
|
||||
AND conrelid = to_regclass('ksp_store_identity')
|
||||
) THEN
|
||||
ALTER TABLE ksp_store_identity ADD CONSTRAINT ck_ksp_store_identity_singleton CHECK (singleton = 1);
|
||||
END IF;
|
||||
END
|
||||
$ksp$;
|
||||
@@ -0,0 +1,15 @@
|
||||
DO $ksp$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_ksp_store_identity_network'
|
||||
AND conrelid = to_regclass('ksp_store_identity')
|
||||
) THEN
|
||||
ALTER TABLE ksp_store_identity ADD CONSTRAINT ck_ksp_store_identity_network CHECK (
|
||||
octet_length(network) >= 1 AND octet_length(network) <= 128
|
||||
AND network ~ '^[A-Za-z0-9_.:-]+$'
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$ksp$;
|
||||
@@ -0,0 +1,12 @@
|
||||
DO $ksp$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'pk_ksp_raw_transactions'
|
||||
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||
) THEN
|
||||
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT pk_ksp_raw_transactions PRIMARY KEY (signature);
|
||||
END IF;
|
||||
END
|
||||
$ksp$;
|
||||
@@ -0,0 +1,12 @@
|
||||
DO $ksp$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'ck_ksp_raw_transactions_signature'
|
||||
AND conrelid = to_regclass('ksp_raw_transactions')
|
||||
) THEN
|
||||
ALTER TABLE ksp_raw_transactions ADD CONSTRAINT ck_ksp_raw_transactions_signature CHECK (octet_length(signature) = 64);
|
||||
END IF;
|
||||
END
|
||||
$ksp$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user