diff --git a/Cargo.toml b/Cargo.toml index ff7888d..efa49d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 73 +# version: 74 [workspace] resolver = "3" @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "0.5.3-pre.4" +version = "0.5.3-pre.5" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3" diff --git a/ROADMAP.md b/ROADMAP.md index 4f9d687..b4baf39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # ROADMAP — khadhroony-bot3 @@ -135,6 +135,7 @@ Le schéma ne doit pas être organisé en familles de tables par protocole ou Pr ### 0.5.4 — scénarios, exécuteurs et validations +- réconcilier le cas de decode CPI Metaplex Token Metadata observé en `0.5.3-pre.004` (`transfer`, chemin `2/0`, diagnostic `transfer account 9 lacks required signer/writable privileges`) afin de déterminer si le contrat d’accounts du décodeur doit accepter les privilèges runtime observés ; ce point relève du décodeur/scénario, pas de `ks-store` ; - diagnostiquer et corriger la régression observée dans `demo_execution_spl_token_2022` lors du chargement de fixture (`unable to read configured Token-2022 fixture`) après séparation des racines wallet/fixtures ; - inventorier non destructivement les keypairs historiques sous `wallets/temporary/**`, extraire les pubkeys des formats supportés et préparer une migration sélective des seuls signers devant devenir persistants ; - garder toute classification on-chain éventuelle chez le consommateur/scénario, à partir de la pubkey et du profil réseau, sans dépendance `ks-wallet -> ks-onchain-transport` ni rôle métier inventé depuis les bytes du secret ; diff --git a/docs/plans/V0_5_3_KS_STORE_NORMALIZATION_PLAN.md b/docs/plans/V0_5_3_KS_STORE_NORMALIZATION_PLAN.md index 74abc4d..ad7173a 100644 --- a/docs/plans/V0_5_3_KS_STORE_NORMALIZATION_PLAN.md +++ b/docs/plans/V0_5_3_KS_STORE_NORMALIZATION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.5.3` — audit et normalisation de `ks-store` @@ -47,7 +47,7 @@ Les décisions suivantes sont retenues pour les tranches suivantes : 6. **`kb_sol_mat_events` reste le journal générique versionné des sorties de matérialisation**, mais son nom durable devient `k_sol_mat_outputs`, cohérent avec son rôle réel ; les projections métier spécialisées seront additives ; 7. **aucune table DEX/trading n'est créée en `0.5.3`** : aucun chantier DEX n'a encore fourni les invariants nécessaires à un contrat SQL gelable ; lorsque ces matérialisations arriveront, les tables devront être génériques par fait/concept et non par protocole ; 8. **aucune projection metadata N4 n'est créée en `0.5.3`**, mais deux cibles conceptuelles sont déjà retenues : asset/token metadata commune Metaplex + Token-2022 et program metadata distincte pour SPM ; elles seront ajoutées lorsque leurs premiers contrats queryables seront suffisamment audités, puis resteront plus évolutives que les fondations N1-N3 ; -9. **les bases Devnet/Mainnet/test actuelles sont considérées destructibles pour `0.5.3`** : la cible est une reconstruction propre, pas une compatibilité in-place avec le schéma `0.5.2` ; l'ancien schéma doit être détecté/refusé plutôt que silencieusement mélangé au nouveau ; +9. **`ks-store` ne possède et ne valide que ses ressources `k_sol_*` courantes** : une reconstruction des bases Devnet/Mainnet/test reste acceptable pour `0.5.3`, mais une base non vide, des tables applicatives étrangères ou d'anciens objets `kb_sol_*` peuvent coexister sans blocage tant qu'aucun contrat courant de `ks-store` ne les utilise ; 10. **les ressources SQL PostgreSQL sont rangées sous `ks-store/migrations/postgres/`** et chaque fichier contient une seule instruction SQL ; les fonctions privées PostgreSQL utilisent `include_str!` pour exécuter la ressource correspondante ; 11. **tables, contraintes/keys, index et opérations de maintenance (`DROP`, `TRUNCATE`) sont séparés** en ressources/fonctions explicites et assemblés par un orchestrateur d'initialisation PostgreSQL transactionnel ; 12. **le DDL dupliqué en chaînes Rust disparaît** : le fichier SQL inclus est l'unique source du texte SQL exécuté ; @@ -625,36 +625,39 @@ La migration ne fait pas un simple remplacement textuel. Elle doit couvrir : - documentation active ; - chaînes visibles du desktop. -### 15.2 Base vide +### 15.2 Base PostgreSQL hôte -Une base vide est la **cible normale de `0.5.3`**. +`ks-store` ne requiert pas une base vide et ignore les tables étrangères au contrat courant, y compris d'éventuels objets historiques `kb_sol_*`. `auto_initialize_schema` est un mécanisme d'évolution **additive seulement** du schéma géré. -L'auto-initialisation PostgreSQL exécute l'orchestrateur des ressources SQL unitaires dans un ordre déterministe : tables, contraintes, index, puis vérification du snapshot logique. Le résultat final ne doit contenir aucun objet actif `kb_sol_*`. +À l'ouverture, les ressources gérées déjà présentes sont contrôlées avant toute initialisation. Une définition existante incompatible (type/nullabilité/défaut de colonne, définition PK/FK ou définition d'index) est bloquante et n'est jamais modifiée implicitement. En revanche, une table, colonne, clé/contrainte ou index attendu mais absent peut être ajouté lorsque l'auto-initialisation est active. L'orchestrateur applique ces ajouts transactionnellement dans l'ordre tables -> colonnes -> contraintes -> index, puis revalide le contrat complet. Il n'exécute aucun retrait, renommage ni mutation d'une colonne existante. Une base hôte peut donc être un sur-ensemble du schéma `ks-store` sans devenir incompatible. -### 15.3 Bases `0.5.2` Devnet/Mainnet/test +### 15.3 Compatibilité du schéma géré -La décision de session est de **supprimer et recréer** les bases actuelles. `0.5.3` n'investit donc pas dans une migration in-place des données historiques. +`0.5.3` n'investit pas dans une migration in-place des données historiques `0.5.2`, mais leur simple présence ne constitue plus une erreur. Le comportement attendu est : -Le comportement attendu est : +1. table gérée trouvée + colonnes, clés et index compatibles -> `OK`, sans exécuter d'initialisation ; +2. ressource gérée existante avec une définition incompatible -> `ERR`, même avec `auto_initialize_schema=true` ; +3. table/colonne/clé/index géré absent + `auto_initialize_schema=false` -> `ERR` ; +4. table/colonne/clé/index géré absent + `auto_initialize_schema=true` -> tenter l'ajout uniquement de la ressource absente, sans supprimer ni modifier les colonnes existantes, puis `ERR` si l'ajout ou la validation complète échoue ; +5. ignorer les tables et objets non possédés par `ks-store`, quel que soit leur préfixe ; +6. accepter les extensions additives compatibles, mais refuser une colonne additive étrangère obligatoire sans défaut qui casserait les écritures courantes. -1. si la base est vide, initialiser le nouveau schéma ; -2. si le nouveau schéma `k_sol_*` est déjà conforme, vérifier puis continuer ; -3. si des objets historiques `kb_sol_*` sont détectés, refuser l'auto-initialisation avec un diagnostic non destructif demandant une reconstruction explicite ; -4. ne jamais mélanger silencieusement ancien et nouveau namespace. +Le contrat de `pre.005` compare les **16 tables, 248 colonnes, 25 clés PK/FK et 79 index physiques** du baseline courant. Le snapshot exhaustif des checks et autres détails frozen reste consolidé en `pre.006`. -Les opérations `DROP`/`TRUNCATE` restent disponibles comme maintenance backend-interne explicite ; elles ne sont pas déclenchées automatiquement par `Store::open()` sur une base non vide. +Les opérations `DROP`/`TRUNCATE` restent des opérations de maintenance backend-internes explicites et ne sont jamais déclenchées automatiquement sur les ressources étrangères. ### 15.4 Conséquence sur les tests -Le test de migration `0.5.2 -> 0.5.3` n'est plus requis. Il est remplacé par : +Le test de migration `0.5.2 -> 0.5.3` n'est pas requis. Il est remplacé par : -- init d'une base vide ; +- validation du contrat structurel de toutes les tables gérées ; - réouverture idempotente d'une base déjà initialisée ; -- refus propre d'un schéma historique `kb_sol_*` ; -- reconstruction explicite via les opérations de maintenance retenues ; -- snapshot exact du schéma final. +- coexistence non bloquante avec des tables étrangères et des objets historiques `kb_sol_*` ; +- acceptation des extensions additives compatibles et réparation additive des ressources gérées absentes lorsque l'auto-initialisation est active ; +- refus des dérives existantes qui nécessiteraient une modification ou suppression pour redevenir compatibles ; +- snapshot exact du schéma frozen lors de la tranche de gel. -Cette stratégie est acceptable parce que les bases actuelles ne constituent pas encore des contrats de production à préserver. +Cette stratégie permet aux projections N4 futures d'être additives sans affaiblir le contrat N1/N2/N3 courant. ## 16. Source de vérité des migrations PostgreSQL @@ -1075,11 +1078,12 @@ La même règle s'applique à tout autre protocole : une table nommée d'après ### 25.1 Contrat de schéma -- initialisation sur base vide ; -- réouverture idempotente d'une base déjà initialisée ; -- refus propre d'une base contenant encore des objets `kb_sol_*` ; -- absence finale de tables, contraintes, index et séquences `kb_sol_*` après reconstruction ; - présence des **16 tables du baseline candidat au gel** retenu en `pre.003` ; +- validation dans `ks-store` des colonnes requises, types, nullabilités, précisions numériques et défauts nécessaires aux modèles/queries courants ; +- validation avant toute auto-initialisation des 25 clés PK/FK et des 79 index physiques attendus ; une dérive d'une table existante est bloquante et n'est pas auto-réparée ; +- acceptation d'une base contenant des tables étrangères ou historiques non gérées, y compris `kb_sol_*` ; +- acceptation de colonnes additives compatibles et refus d'une colonne additive `NOT NULL` sans défaut qui casserait les écritures courantes ; +- réouverture idempotente d'une base déjà initialisée ; - snapshot exact des colonnes/types/nullabilités/PK/FK/uniques/checks frozen ; - présence et contrat séparé des tables top-level/CPI ; - test de lecture logique combinée top-level+CPI sans perte, doublon ni ordre instable ; @@ -1149,8 +1153,8 @@ Avec `KS_SECRET_POSTGRES_TEST_URL` local : - transaction rollback ; - replay ; - diagnostics ; -- introspection du contrat frozen ; -- reconstruction propre d'une base de test et refus contrôlé d'un schéma historique `kb_sol_*`. +- introspection du contrat géré puis du contrat frozen ; +- vérification que les ressources étrangères/non gérées ne bloquent pas `ks-store` et que toute dérive structurelle d'une ressource gérée est détectée. ## 26. Snapshot de contrat frozen @@ -1253,20 +1257,22 @@ Le découpage suivant est une **trajectoire fonctionnelle**. Il ne force pas la ### `0.5.3-pre.005` — finalisation `demo_store`, consommateurs et validation PostgreSQL réelle +La pagination de consultation retenue sépare explicitement deux niveaux : blocs Store fixes de 500 lignes, parcourus par curseurs `Premier/Précédent/Suivant`, puis pagination/tri/filtre DataTables uniquement dans le bloc chargé lorsque la vue utilise DataTables. Chaque chargement ou navigation recalcule le nombre total de lignes/blocs correspondant exactement aux filtres serveur ; aucun champ `offset` ni `limit` éditable n’est exposé pour ces listings. Cette règle couvre Replay Candidates, annotations et journaux matérialisés SPL Token/ATA, sans modifier les limites métier des campagnes, backfills ou RPC. + - supprimer les derniers `Postgres*` de `ks-pipeline-demo-scenarios` ; - finaliser les surfaces `demo_store_*` de `kb-app-demo-desktop` autour de résultats tabulaires génériques indépendants du backend ; la nomenclature technique a déjà été migrée en `pre.002` ; - refaire `demo_config` afin qu'elle n'expose/interprète plus les champs PostgreSQL/SQLite et consomme uniquement le résumé store sanitisé ; - refaire les informations store du splash autour d'une vérification par modèle logique et de compteurs (tables/index attendus, présents, créés), sans afficher les noms physiques ; - confirmer que `ks-pipeline` reste inchangé dans son rôle d'orchestration ; -- tests base vide + réouverture + refus ancien schéma ; -- validations PostgreSQL réelles ; +- validation de compatibilité structurelle des ressources gérées + réouverture, avec coexistence non bloquante des ressources étrangères/historiques ; +- validations PostgreSQL réelles depuis `ks-store`, les consommateurs ne réimplémentant aucune introspection ; - lancer Tauri parce que les diagnostics/surfaces desktop auront changé. ### `0.5.3-pre.006` — gel de contrat et réconciliation technique - produire le snapshot machine-readable frozen avec le nombre réel de tables N1/N2/N3 après canari future-decoder, sans imposer artificiellement la baseline de 13 ; - canari exact du schéma logique ; -- audit workspace anti-`kb_sol_` actif ; +- audit workspace vérifiant qu’aucune ressource/requête active de `ks-store` n’utilise `kb_sol_*`, sans interdire leur coexistence physique ; - audit anti-PostgreSQL hors `ks-store` ; - audit secrets/logging ; - confirmer qu'aucune table trading/DEX ni projection metadata N4 non auditée n'a été introduite ; @@ -1319,7 +1325,7 @@ cargo tauri dev -c kb-app-demo-desktop/tauri.conf.json La version ne peut être clôturée que si : -- il n'existe plus d'objet actif `kb_sol_*` ; +- aucune ressource gérée ni requête active de `ks-store` n’utilise `kb_sol_*` ; leur coexistence comme objets non gérés reste tolérée ; - les 16 tables du baseline candidat retenu en `pre.003` correspondent exactement au snapshot frozen documenté ; - `block_time` est conservé lorsqu'il existe et reste `NULL` sinon ; - les top-level et CPI restent physiquement séparées mais peuvent être sélectionnées séparément ou comme flux logique commun pour le decode ; @@ -1551,21 +1557,21 @@ La nouvelle arborescence contient : migrations/postgres/ schema/tables/ 16 schema/constraints/ 129 - schema/indexes/ 59 + schema/indexes/ 63 maintenance/truncate/16 maintenance/drop/ 16 ``` -Soit **236 ressources SQL**. Chaque ressource contient une seule instruction top-level et est embarquée une seule fois par `include_str!`. +Soit **240 ressources SQL**. Chaque ressource contient une seule instruction top-level et est embarquée une seule fois par `include_str!`. L'orchestrateur PostgreSQL global : - valide les ressources ; -- refuse les objets historiques `kb_sol_*` ; -- ouvre une transaction ; -- prend un advisory lock ; -- applique tables, contraintes et index dans l'ordre ; -- commit uniquement lorsque le baseline complet est cohérent. +- inspecte d'abord les tables gérées déjà présentes et refuse toute dérive de colonnes, PK/FK ou index ; +- ignore les objets étrangers ou historiques non gérés, y compris `kb_sol_*` ; +- si aucune table gérée ne manque, n'exécute aucune initialisation ; +- si des tables gérées manquent et que l'auto-initialisation est autorisée, ouvre une transaction, prend un advisory lock et applique uniquement les tables manquantes, leurs contraintes puis leurs index ; +- commit la création puis revalide le baseline complet. Les initialiseurs partiels raw/Core/decode sont supprimés, y compris des tests. Une initialisation ne peut donc plus valider accidentellement un sous-schéma comme si le baseline complet était prêt. @@ -1582,7 +1588,7 @@ Le résumé backend-agnostique expose : sans publier les noms physiques. -Le backend PostgreSQL attend actuellement **75 index** : 59 index explicites et les 16 index de clés primaires produits par PostgreSQL. +Le backend PostgreSQL attend actuellement **79 index** : 63 index explicites et les 16 index de clés primaires produits par PostgreSQL. ### 33.8 Conclusion du canari future-decoder @@ -1609,7 +1615,6 @@ Les autres metadata transactionnelles auditées (`fee`, rewards, compute units, - les projections N4 metadata/trading. Ces points restent dans les tranches suivantes et peuvent décaler le premier candidat de clôture au-delà de `pre.007`. - ## 34. État réalisé de `0.5.3-pre.004` `pre.004` ferme la tranche repositories/replay sans ajouter de table et sans déclarer encore le gel formel N1/N2/N3. diff --git a/kb-app-demo-desktop/CHANGELOG.md b/kb-app-demo-desktop/CHANGELOG.md index 93fa44e..c620a46 100644 --- a/kb-app-demo-desktop/CHANGELOG.md +++ b/kb-app-demo-desktop/CHANGELOG.md @@ -1,8 +1,24 @@ - + # CHANGELOG — kb-app-demo-desktop +## `0.5.3-pre.005` + +- centralise la résolution et la préparation du Store des profils Devnet dans `demo_devnet_common`, utilisé par Solana Core, SPL et Metadata au lieu de rattacher ce contrat partagé à `demo_execution_solana_core` ; +- ajoute des traces `frontendDebug` explicites pour les clics et invocations de chargement des journaux SPL Token/ATA ainsi que pour la préparation des profils SPL/Metadata, afin de distinguer la réutilisation normale du Store partagé d’une absence de requête ; +- corrige la préparation automatique des profils Execution Devnet afin qu’elle utilise elle aussi le `ks_store::Store` partagé par profil : l’ouverture d’une seconde fenêtre Devnet et le premier chargement de journal ne rouvrent plus un pool PostgreSQL ni ne relancent `schema_initialize` après que le profil a déjà été préparé ; +- réutilise désormais un `ks_store::Store` partagé par profil pour toutes les surfaces Execution Devnet : les lectures de journaux et exécutions successives ne recréent plus un pool PostgreSQL ni ne relancent `schema_initialize` à chaque commande, tout en conservant le Store du profil actif séparé des profils Devnet ; +- corrige la restauration des boutons de navigation Store après le chargement initial parallèle de Replay Candidates : chaque contrôleur déjà chargé retrouve son état `Premier/Précédent/Suivant` une fois toutes les opérations concurrentes terminées, sans exiger un second clic sur `Charger` ; +- supprime les champs éditables de limite des listings de consultation Store : cinq listes Replay Candidates, annotations de transactions, journal SPL Token et journal ATA ; la taille backend est fixée à 500 lignes par bloc ; +- ajoute une navigation Store supérieure `Premier bloc / Bloc précédent / Bloc suivant` indépendante de la pagination locale DataTables ; +- recalcule `totalRows` et `totalBlocks` à chaque chargement ou navigation ; tout bouton `Charger` repart du bloc 1 avec les filtres courants, tandis que `Premier/Précédent/Suivant` rafraîchissent également le nombre de blocs ; +- invalide les curseurs mémorisés dès qu’un filtre serveur change et exige alors un nouveau chargement du premier bloc ; +- étend DataTables jusqu’à 500 lignes affichables localement dans le bloc courant ; +- applique le même contrôleur de blocs aux annotations de Decode Replay et aux journaux SPL Token/ATA ; leurs comptages backend utilisent exactement les filtres serveur visibles, y compris les contraintes de payload, sans `OFFSET` ; +- termine le nettoyage des identifiants frontend `refreshSql*` / `copySql*` au profit de `refreshStore*` / `copyStore*` ; +- conserve `demo_config` et le splash sur les résumés Store backend-agnostiques déjà introduits, sans noms physiques d’objets PostgreSQL. + ## `0.5.3-pre.004` - aligne la limite maximale de la fenêtre technique de candidats replay Store sur la nouvelle borne interactive backend-agnostique de 500 lignes ; diff --git a/kb-app-demo-desktop/README.md b/kb-app-demo-desktop/README.md index f2e5c18..fa93b82 100644 --- a/kb-app-demo-desktop/README.md +++ b/kb-app-demo-desktop/README.md @@ -1,5 +1,5 @@ - + # kb-app-demo-desktop @@ -20,7 +20,7 @@ Les commandes Tauri sont des adaptateurs minces. La logique réutilisable appartient à `ks-pipeline`, `ks-pipeline-demo-scenarios`, `ks-lib`, `ks-store` et `ks-onchain-transport`. -Depuis `0.5.3-pre.002`, l'application conserve une façade `ks_store::Store` unique dans `AppState`. Les commandes ne construisent plus de `PostgresStore` et les payloads de configuration/diagnostic ne transportent plus de champs PostgreSQL/SQLite concrets. Les anciennes surfaces `demo_sql_*` sont renommées en `demo_store_*` dès `pre.002`; la refonte fonctionnelle finale de leurs tableaux et compteurs reste planifiée en `pre.005`. +Depuis `0.5.3-pre.002`, l'application conserve une façade `ks_store::Store` unique dans `AppState`. Les commandes ne construisent plus de backend concret et les payloads de configuration/diagnostic ne transportent plus de champs PostgreSQL/SQLite concrets. Depuis `0.5.3-pre.005`, les listings de consultation Store utilisent des blocs fixes de 500 parcourus par curseurs : Replay Candidates, annotations de transactions et journaux SPL Token/ATA partagent le même principe. Chaque chargement recalcule le nombre total de blocs pour les filtres actifs. Lorsqu’une vue utilise DataTables, sa pagination/son tri/son filtre restent strictement locaux au bloc déjà chargé. Les campagnes Devnet/Testnet réutilisables appartiennent à `ks-pipeline-demo-scenarios`. Le desktop conserve leurs adaptateurs UI/Tauri, la sélection opérateur, la progression, les payloads TS-RS et la présentation ; `0.5.4` doit retirer les orchestrations réutilisables qui subsistent encore localement. diff --git a/kb-app-demo-desktop/TODO.md b/kb-app-demo-desktop/TODO.md index 2221d12..6548ad0 100644 --- a/kb-app-demo-desktop/TODO.md +++ b/kb-app-demo-desktop/TODO.md @@ -1,11 +1,11 @@ - + # TODO — kb-app-demo-desktop ## Évolutions générales -- [ ] `0.5.3` — finaliser en `pre.005` la présentation des surfaces `demo_store_*`, puis aligner `demo_config` et le splash sur les modèles/compteurs du nouveau baseline sans exposer de noms physiques ; le renommage technique est terminé en `pre.002`. +- [x] `0.5.3-pre.005` — finaliser les listings de consultation Store avec blocs fixes de 500, curseurs stables, total de blocs recalculé à chaque chargement/navigation et pagination DataTables locale lorsqu’elle existe ; Replay Candidates, annotations et journaux SPL Token/ATA suivent le même contrat, tandis que `demo_config` et le splash consomment les résumés génériques/sanitisés sans noms physiques. - [ ] `0.5.4` — réduire les panneaux d'exécution à l'adaptation UI/Tauri en déplaçant toute orchestration de campagne encore réutilisable vers `ks-pipeline-demo-scenarios`. - [ ] UX — empêcher les doubles déclenchements pendant une préparation, simulation ou soumission active. - [ ] Observabilité — afficher explicitement les comptes relus et les projections matérialisées. diff --git a/kb-app-demo-desktop/USAGE.md b/kb-app-demo-desktop/USAGE.md index d948580..dc5d016 100644 --- a/kb-app-demo-desktop/USAGE.md +++ b/kb-app-demo-desktop/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de kb-app-demo-desktop @@ -125,6 +125,14 @@ Les autres fenêtres sont ouvertes par les commandes Tauri dédiées : - exécution Solana Core ; - exécution SPL. +## Pagination des listings Store + +Les listings de consultation Store n’exposent plus de champ `limit` éditable lorsqu’ils utilisent la borne interactive de 500. Le backend charge un bloc fixe de 500 lignes maximum et recalcule `totalRows`/`totalBlocks` à chaque action `Charger`, `Premier bloc`, `Bloc précédent` ou `Bloc suivant`. + +Le curseur reste opaque et n’est jamais saisi par l’opérateur. Modifier un filtre serveur invalide les curseurs mémorisés et impose de recharger le premier bloc. Lorsqu’une vue utilise DataTables, sa pagination, son tri et son filtre restent locaux aux lignes du bloc courant et ne modifient jamais la pagination Store. + +Cette règle s’applique aux Replay Candidates, aux annotations de transaction et aux journaux SPL Token/ATA. Les limites métier de backfill, de campagne decode/Core ou de requêtes RPC restent indépendantes et peuvent demeurer configurables. + ## Interface Tauri publique Les commandes Tauri constituent l’interface applicative frontend/backend. Elles ne sont pas des APIs Rust publiques pour les autres crates. diff --git a/kb-app-demo-desktop/frontend/demo_decode_replay.html b/kb-app-demo-desktop/frontend/demo_decode_replay.html index 5a2fb44..2fb8fc7 100644 --- a/kb-app-demo-desktop/frontend/demo_decode_replay.html +++ b/kb-app-demo-desktop/frontend/demo_decode_replay.html @@ -1,5 +1,5 @@ - + @@ -131,11 +131,13 @@ -
- - -
+
+ + + +
+ Bloc — / —

Annotations non chargées.

diff --git a/kb-app-demo-desktop/frontend/demo_execution_spl.html b/kb-app-demo-desktop/frontend/demo_execution_spl.html index 48b0e9e..69ae86a 100644 --- a/kb-app-demo-desktop/frontend/demo_execution_spl.html +++ b/kb-app-demo-desktop/frontend/demo_execution_spl.html @@ -1,5 +1,5 @@ - + @@ -306,7 +306,12 @@

Journal métier SPL Token

Événements commités uniquement, sans solde final reconstruit ni OHLC.
- +
+ + + + +
@@ -330,15 +335,15 @@
-
- - -
+
+ Bloc 0 / 0 · 0 résultat · 500/bloc + Le total de blocs est recalculé à chaque chargement ou navigation. +
@@ -375,7 +380,12 @@

Journal lifecycle ATA

Création, réutilisation idempotente et récupération nested, sans duplication des CPI Token.
- +
+ + + + +
@@ -390,14 +400,14 @@
-
+
-
- - -
+
+
+ Bloc 0 / 0 · 0 résultat · 500/bloc + Le total de blocs est recalculé à chaque chargement ou navigation.
diff --git a/kb-app-demo-desktop/frontend/demo_store_core.html b/kb-app-demo-desktop/frontend/demo_store_core.html index 636555f..64916fe 100644 --- a/kb-app-demo-desktop/frontend/demo_store_core.html +++ b/kb-app-demo-desktop/frontend/demo_store_core.html @@ -1,5 +1,5 @@ - + @@ -32,7 +32,7 @@

Core store Solana

- +
Statut
@@ -94,4 +94,4 @@ - + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/demo_store_diag.html b/kb-app-demo-desktop/frontend/demo_store_diag.html index d0e33a3..ea57579 100644 --- a/kb-app-demo-desktop/frontend/demo_store_diag.html +++ b/kb-app-demo-desktop/frontend/demo_store_diag.html @@ -1,5 +1,5 @@ - + @@ -33,8 +33,8 @@

Diagnostic du store

- - + +
@@ -107,4 +107,4 @@ - + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/demo_store_raw.html b/kb-app-demo-desktop/frontend/demo_store_raw.html index 68eae7a..f93cab5 100644 --- a/kb-app-demo-desktop/frontend/demo_store_raw.html +++ b/kb-app-demo-desktop/frontend/demo_store_raw.html @@ -1,5 +1,5 @@ - + @@ -32,7 +32,7 @@

Acquisition transactionnelle Solana

- +
Statut
@@ -94,4 +94,4 @@ - + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/demo_store_replay_candidates.html b/kb-app-demo-desktop/frontend/demo_store_replay_candidates.html index bbc9718..44d9d22 100644 --- a/kb-app-demo-desktop/frontend/demo_store_replay_candidates.html +++ b/kb-app-demo-desktop/frontend/demo_store_replay_candidates.html @@ -1,5 +1,5 @@ - + @@ -36,12 +36,12 @@

Exploration read-only des candidats

-

Les requêtes sont paramétrées, bornées et indépendantes du backend concret. Utilisez les cases à cocher pour sélectionner les lignes. La case du header sélectionne ou désélectionne toutes les lignes correspondant au filtre local. Sans sélection explicite, la copie et l’export CSV utilisent toutes les lignes filtrées. Les CSV UTF-8 avec BOM et séparateur point-virgule sont écrits par le backend Tauri dans ./data/exports_csv/. LibreOffice Calc peut toujours afficher son dialogue d’import, car le format CSV ne décrit pas formellement son encodage ni son séparateur.

+

Les requêtes sont paramétrées, bornées et indépendantes du backend concret. La navigation Premier bloc / Bloc précédent / Bloc suivant recharge le Store par blocs fixes de 500 et recalcule leur nombre total à chaque chargement ; la pagination, le tri et le filtre DataTables restent locaux au bloc déjà chargé. Utilisez les cases à cocher pour sélectionner les lignes. La case du header sélectionne ou désélectionne toutes les lignes correspondant au filtre local. Sans sélection explicite, la copie et l’export CSV utilisent toutes les lignes filtrées du bloc courant. Les CSV UTF-8 avec BOM et séparateur point-virgule sont écrits par le backend Tauri dans ./data/exports_csv/. LibreOffice Calc peut toujours afficher son dialogue d’import, car le format CSV ne décrit pas formellement son encodage ni son séparateur.

Connexion : - Limite serveur maximale : - + Taille fixe d’un bloc Store : +
@@ -129,10 +129,6 @@
-
- - -
@@ -155,6 +151,14 @@

Transactions candidates

Aucune requête exécutée.
+
+
+ + + +
+ Bloc — / — +
@@ -199,15 +203,11 @@
-
+
-
- - -
@@ -223,6 +223,14 @@

Programmes indexés

Aucune requête exécutée.
+
+
+ + + +
+ Bloc — / — +
@@ -258,14 +266,10 @@
-
+
-
- - -
@@ -281,6 +285,14 @@

Mints

Aucune requête exécutée.
+
+
+ + + +
+ Bloc — / — +
Source : faits Core de variation de balance (mint).
@@ -316,14 +328,10 @@
-
+
-
- - -
@@ -339,6 +347,14 @@

Owners

Aucune requête exécutée.
+
+
+ + + +
+ Bloc — / — +
Source : faits Core de variation de balance (owner).
@@ -374,14 +390,10 @@
-
+
-
- - -
@@ -397,6 +409,14 @@

Comptes

Aucune requête exécutée.
+
+
+ + + +
+ Bloc — / — +
Source : clés de comptes Core résolues.
@@ -444,4 +464,4 @@ - + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/ts/demo_decode_replay.ts b/kb-app-demo-desktop/frontend/ts/demo_decode_replay.ts index fd58b95..549ee55 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_decode_replay.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_decode_replay.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_decode_replay.ts -// version: 8 +// version: 9 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -9,11 +9,21 @@ import DataTable, { type Api } from "datatables.net-bs5"; import "datatables.net-bs5/css/dataTables.bootstrap5.css"; import { listen } from "@tauri-apps/api/event"; import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log.ts"; +import { + applyStoreBlockMetadata, + createStoreBlockState, + cursorForBlock, + markStoreBlockFiltersDirty, + resetStoreBlockState, + renderStoreBlockControls, + type StoreBlockControls, +} from "./demo_store_block_pagination.ts"; import type { DemoDecodeDiagnosticsPayload } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoDecodeDiagnosticsPayload.ts"; import type { DemoDecodeReplayOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoDecodeReplayOptionsPayload.ts"; import type { DemoDecodeReplayProgressPayload } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoDecodeReplayProgressPayload.ts"; import type { DemoDecodeReplayRequest } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoDecodeReplayRequest.ts"; import type { DemoDecodeReplaySummaryPayload } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoDecodeReplaySummaryPayload.ts"; +import type { DemoTransactionAnnotationPagePayload } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoTransactionAnnotationPagePayload.ts"; import type { DemoTransactionAnnotationRequest } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoTransactionAnnotationRequest.ts"; import type { DemoTransactionAnnotationRow } from "./bindings/kb_app_demo_desktop/demo_decode_replay/DemoTransactionAnnotationRow.ts"; @@ -23,6 +33,15 @@ import type { DemoTransactionAnnotationRow } from "./bindings/kb_app_demo_deskto const logLines: string[] = []; let running = false; let materializationAvailable = false; +const annotationBlockControls: StoreBlockControls = { + firstSelector: "#annotationFirstBlockButton", + previousSelector: "#annotationPreviousBlockButton", + nextSelector: "#annotationNextBlockButton", + summarySelector: "#annotationBlockSummary", +}; +const annotationBlockState = createStoreBlockState(); +type AnnotationBaseRequest = Omit; +let annotationBaseRequest: AnnotationBaseRequest | null = null; function element(selector: string): T { const value = document.querySelector(selector); @@ -250,7 +269,7 @@ function renderAnnotations(rows: DemoTransactionAnnotationRow[]): void { annotationJournalTable = new DataTable("#annotationJournalTable", { autoWidth: false, pageLength: 25, - lengthMenu: [10, 25, 50, 100], + lengthMenu: [10, 25, 50, 100, 250, 500], order: [[0, "desc"]], scrollX: true, language: { @@ -265,23 +284,53 @@ function renderAnnotations(rows: DemoTransactionAnnotationRow[]): void { element("#annotationJournalStatus").textContent = `${rows.length} annotation(s) committed chargée(s).`; } -async function loadAnnotations(): Promise { - const request: DemoTransactionAnnotationRequest = { +async function startAnnotationLoad(): Promise { + annotationBaseRequest = { signatureContains: inputValue("#annotationSignatureInput") || null, - limit: integerValue("#annotationLimitInput"), + }; + resetStoreBlockState(annotationBlockState); + await loadAnnotationBlock(1, null); +} + +async function loadAnnotationBlock(blockIndex: number, cursor: string | null): Promise { + if (annotationBaseRequest === null) { + throw new Error("Rechargez les annotations pour initialiser les filtres Store."); + } + const request: DemoTransactionAnnotationRequest = { + ...annotationBaseRequest, + cursor, + blockIndex, }; try { - frontendDebug("kb_app_demo_desktop.frontend.demo_decode_replay", `Invoke demo_decode_replay_annotations limit=${request.limit}`); - const rows = await invoke("demo_decode_replay_annotations", { request }); - renderAnnotations(rows); - frontendDebug("kb_app_demo_desktop.frontend.demo_decode_replay", `Invoke demo_decode_replay_annotations completed rows=${rows.length}`); + frontendDebug("kb_app_demo_desktop.frontend.demo_decode_replay", `Invoke demo_decode_replay_annotations block=${blockIndex}`); + const payload = await invoke("demo_decode_replay_annotations", { request }); + renderAnnotations(payload.rows); + applyStoreBlockMetadata(annotationBlockState, payload.page, annotationBlockControls); + frontendDebug("kb_app_demo_desktop.frontend.demo_decode_replay", `Invoke demo_decode_replay_annotations completed block=${payload.page.blockIndex}/${payload.page.totalBlocks} rows=${payload.rows.length}`); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); element("#annotationJournalStatus").textContent = `Erreur : ${message}`; + renderStoreBlockControls(annotationBlockState, annotationBlockControls); frontendError("kb_app_demo_desktop.frontend.demo_decode_replay", `Annotation journal loading failed: ${message}`); } } +async function loadPreviousAnnotationBlock(): Promise { + const targetBlock = annotationBlockState.currentBlock - 1; + const cursor = cursorForBlock(annotationBlockState, targetBlock); + if (targetBlock < 1 || cursor === undefined) { + return; + } + await loadAnnotationBlock(targetBlock, cursor); +} + +async function loadNextAnnotationBlock(): Promise { + if (annotationBlockState.nextCursor === null) { + return; + } + await loadAnnotationBlock(annotationBlockState.currentBlock + 1, annotationBlockState.nextCursor); +} + async function openWindow(command: "open_demo_store_core_window" | "open_demo_store_replay_candidates_window"): Promise { try { frontendDebug("kb_app_demo_desktop.frontend.demo_decode_replay", `Invoke ${command}`); @@ -357,7 +406,14 @@ document.addEventListener("DOMContentLoaded", () => { element("#startDecodeReplayButton").addEventListener("click", () => void executeDecodeReplay()); element("#cancelDecodeReplayButton").addEventListener("click", () => void cancelDecodeReplay()); element("#loadDecodeDiagnosticsButton").addEventListener("click", () => void loadDiagnostics()); - element("#loadAnnotationsButton").addEventListener("click", () => void loadAnnotations()); + element("#loadAnnotationsButton").addEventListener("click", () => void startAnnotationLoad()); + element(annotationBlockControls.firstSelector).addEventListener("click", () => void loadAnnotationBlock(1, null)); + element(annotationBlockControls.previousSelector).addEventListener("click", () => void loadPreviousAnnotationBlock()); + element(annotationBlockControls.nextSelector).addEventListener("click", () => void loadNextAnnotationBlock()); + element("#annotationSignatureInput").addEventListener("input", () => { + annotationBaseRequest = null; + markStoreBlockFiltersDirty(annotationBlockState, annotationBlockControls); + }); element("#openReplayCandidatesFromDecodeButton").addEventListener("click", () => void openWindow("open_demo_store_replay_candidates_window")); element("#openCoreDiagnosticsFromDecodeButton").addEventListener("click", () => void openWindow("open_demo_store_core_window")); void listen("demo-decode-replay-progress", event => appendLog(event.payload)); diff --git a/kb-app-demo-desktop/frontend/ts/demo_execution_metadata.ts b/kb-app-demo-desktop/frontend/ts/demo_execution_metadata.ts index f29b30f..5ffd220 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_execution_metadata.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_execution_metadata.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_execution_metadata.ts -// version: 14 +// version: 15 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -479,7 +479,9 @@ async function prepareProfile(): Promise { setExecutionRunning(executionRunning); updateProfileStatus("Préparation", "badge text-bg-warning"); try { + frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", `Invoke demo_execution_devnet_prepare_profile profile=${profileName}`); const readiness = await invoke("demo_execution_devnet_prepare_profile", { profileName }); + frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", `Invoke demo_execution_devnet_prepare_profile completed profile=${profileName} resources=${readiness.availableResourcesAfter}/${readiness.expectedResources}`); synchronizeProfileSelections(profileName); window.localStorage.setItem(profileStorageKey, profileName); for (const selector of profileOutputSelectors) { @@ -580,9 +582,18 @@ document.addEventListener("DOMContentLoaded", () => { void prepareProfile(); }); } - element("#prepareMetadataProfileButton").addEventListener("click", () => void prepareProfile()); - element("#prepareToken2022MetadataProfileButton").addEventListener("click", () => void prepareProfile()); - element("#prepareSolanaProgramMetadataProfileButton").addEventListener("click", () => void prepareProfile()); + element("#prepareMetadataProfileButton").addEventListener("click", () => { + frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Metaplex profile load button clicked"); + void prepareProfile(); + }); + element("#prepareToken2022MetadataProfileButton").addEventListener("click", () => { + frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Token-2022 Metadata profile load button clicked"); + void prepareProfile(); + }); + element("#prepareSolanaProgramMetadataProfileButton").addEventListener("click", () => { + frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Solana Program Metadata profile load button clicked"); + void prepareProfile(); + }); element("#refreshMetadataScenariosButton").addEventListener("click", () => void loadScenarios()); element("#metadataExecutionLogOutput").addEventListener("app-log-clear", () => { metadataLogLines.length = 0; }); void listen("demo-execution-metadata-progress", event => appendMetadataLog(event.payload)); diff --git a/kb-app-demo-desktop/frontend/ts/demo_execution_spl.ts b/kb-app-demo-desktop/frontend/ts/demo_execution_spl.ts index cf66cba..45c5250 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_execution_spl.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_execution_spl.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_execution_spl.ts -// version: 11 +// version: 13 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -19,13 +19,23 @@ import type { DemoExecutionMemoSummaryPayload } from "./bindings/kb_app_demo_des import type { DemoExecutionSplTokenRequest } from "./bindings/kb_app_demo_desktop/demo_spl_token/DemoExecutionSplTokenRequest.ts"; import type { DemoExecutionSplTokenSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_spl_token/DemoExecutionSplTokenSummaryPayload.ts"; import type { DemoSplTokenJournalRequest } from "./bindings/kb_app_demo_desktop/demo_spl_token/DemoSplTokenJournalRequest.ts"; +import type { DemoSplTokenJournalPagePayload } from "./bindings/kb_app_demo_desktop/demo_spl_token/DemoSplTokenJournalPagePayload.ts"; import type { DemoSplTokenJournalRow } from "./bindings/kb_app_demo_desktop/demo_spl_token/DemoSplTokenJournalRow.ts"; import type { DemoExecutionSplAtaRequest } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoExecutionSplAtaRequest.ts"; import type { DemoExecutionSplAtaSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoExecutionSplAtaSummaryPayload.ts"; import type { DemoSplAtaDerivationPayload } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoSplAtaDerivationPayload.ts"; import type { DemoSplAtaDerivationRequest } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoSplAtaDerivationRequest.ts"; import type { DemoSplAtaJournalRequest } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoSplAtaJournalRequest.ts"; -import type { DemoSplAtaJournalRow } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoSplAtaJournalRow.ts"; +import type { DemoSplAtaJournalPagePayload } from "./bindings/kb_app_demo_desktop/demo_spl_ata/DemoSplAtaJournalPagePayload.ts"; +import { + applyStoreBlockMetadata, + createStoreBlockState, + cursorForBlock, + markStoreBlockFiltersDirty, + renderStoreBlockControls, + resetStoreBlockState, + type StoreBlockControls, +} from "./demo_store_block_pagination"; import type { DemoExecutionSplToken2022Request } from "./bindings/kb_app_demo_desktop/demo_spl_token_2022/DemoExecutionSplToken2022Request.ts"; import type { DemoExecutionSplToken2022SummaryPayload } from "./bindings/kb_app_demo_desktop/demo_spl_token_2022/DemoExecutionSplToken2022SummaryPayload.ts"; import type { DemoSplToken2022FixturePayload } from "./bindings/kb_app_demo_desktop/demo_spl_token_2022/DemoSplToken2022FixturePayload.ts"; @@ -40,6 +50,24 @@ let running = false; let profileStoreReady = false; let profileOptions: DemoExecutionSolanaCoreProfileOption[] = []; let token_2022Fixture: DemoSplToken2022FixturePayload | null = null; +const tokenJournalBlockState = createStoreBlockState(); +const ataJournalBlockState = createStoreBlockState(); +const tokenJournalBlockControls: StoreBlockControls = { + firstSelector: "#tokenJournalFirstBlockButton", + previousSelector: "#tokenJournalPreviousBlockButton", + nextSelector: "#tokenJournalNextBlockButton", + summarySelector: "#tokenJournalBlockSummary", +}; +const ataJournalBlockControls: StoreBlockControls = { + firstSelector: "#ataJournalFirstBlockButton", + previousSelector: "#ataJournalPreviousBlockButton", + nextSelector: "#ataJournalNextBlockButton", + summarySelector: "#ataJournalBlockSummary", +}; +type TokenJournalBaseRequest = Omit; +type AtaJournalBaseRequest = Omit; +let tokenJournalBaseRequest: TokenJournalBaseRequest | null = null; +let ataJournalBaseRequest: AtaJournalBaseRequest | null = null; function element(selector: string): T { const value = document.querySelector(selector); @@ -400,7 +428,7 @@ async function executeAta(submit: boolean): Promise { element("#ataJournalSignatureInput").value = summary.transactionSignature; element("#ataJournalMintInput").value = request.mint; element("#ataJournalAddressInput").value = summary.associatedTokenAccount; - await loadAtaJournal(); + await startAtaJournalLoad(); } } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); @@ -412,27 +440,52 @@ async function executeAta(submit: boolean): Promise { } } -function ataJournalRequest(): DemoSplAtaJournalRequest { +function ataJournalBaseFilters(): AtaJournalBaseRequest { return { profileName: element("#executionProfileSelect").value, signatureContains: optionalText("#ataJournalSignatureInput"), mint: optionalText("#ataJournalMintInput"), associatedTokenAccount: optionalText("#ataJournalAddressInput"), operation: optionalText("#ataJournalOperationInput"), - limit: integerValue("#ataJournalLimitInput"), }; } -async function loadAtaJournal(): Promise { +async function startAtaJournalLoad(): Promise { + ataJournalBaseRequest = ataJournalBaseFilters(); + resetStoreBlockState(ataJournalBlockState); + await loadAtaJournalBlock(1); +} + +async function loadAtaJournalBlock(blockIndex: number): Promise { + if (ataJournalBaseRequest === null) { + markStoreBlockFiltersDirty(ataJournalBlockState, ataJournalBlockControls); + return; + } + const cursor = cursorForBlock(ataJournalBlockState, blockIndex); + if (cursor === undefined) { + return; + } const button = element("#refreshAtaJournalButton"); button.disabled = true; + element(ataJournalBlockControls.firstSelector).disabled = true; + element(ataJournalBlockControls.previousSelector).disabled = true; + element(ataJournalBlockControls.nextSelector).disabled = true; + const request: DemoSplAtaJournalRequest = { + ...ataJournalBaseRequest, + cursor, + blockIndex, + }; try { - const rows = await invoke("demo_spl_ata_journal", { request: ataJournalRequest() }); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", `Invoke demo_spl_ata_journal block=${blockIndex}`); + const payload = await invoke("demo_spl_ata_journal", { request }); + applyStoreBlockMetadata(ataJournalBlockState, payload.page, ataJournalBlockControls); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", `Invoke demo_spl_ata_journal completed block=${payload.page.blockIndex}/${payload.page.totalBlocks} rows=${payload.rows.length}`); element("#ataJournalOutput").value = JSON.stringify({ title: "Journal lifecycle ATA", status: "success", - count: rows.length, - rows: rows.map(row => ({ + block: payload.page, + count: payload.rows.length, + rows: payload.rows.map(row => ({ ...row, payloadJson: parsedJson(row.payloadJson), })), @@ -445,11 +498,17 @@ async function loadAtaJournal(): Promise { message, error: message, }, null, 2); + renderStoreBlockControls(ataJournalBlockState, ataJournalBlockControls); } finally { button.disabled = false; } } +function markAtaJournalFiltersDirty(): void { + ataJournalBaseRequest = null; + markStoreBlockFiltersDirty(ataJournalBlockState, ataJournalBlockControls); +} + async function execute(submit: boolean): Promise { if (running) { return; @@ -567,7 +626,7 @@ async function executeToken(submit: boolean): Promise { }); if (summary.transactionSignature) { element("#tokenJournalSignatureInput").value = summary.transactionSignature; - await loadTokenJournal(); + await startTokenJournalLoad(); } } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); @@ -579,7 +638,7 @@ async function executeToken(submit: boolean): Promise { } } -function tokenJournalRequest(): DemoSplTokenJournalRequest { +function tokenJournalBaseFilters(): TokenJournalBaseRequest { return { profileName: element("#executionProfileSelect").value, signatureContains: optionalText("#tokenJournalSignatureInput"), @@ -587,7 +646,6 @@ function tokenJournalRequest(): DemoSplTokenJournalRequest { account: optionalText("#tokenJournalAccountInput"), family: element("#tokenJournalFamilySelect").value || null, operation: optionalText("#tokenJournalOperationInput"), - limit: integerValue("#tokenJournalLimitInput"), }; } @@ -831,17 +889,42 @@ async function executeToken2022(submit: boolean): Promise { } } -async function loadTokenJournal(): Promise { +async function startTokenJournalLoad(): Promise { + tokenJournalBaseRequest = tokenJournalBaseFilters(); + resetStoreBlockState(tokenJournalBlockState); + await loadTokenJournalBlock(1); +} + +async function loadTokenJournalBlock(blockIndex: number): Promise { + if (tokenJournalBaseRequest === null) { + markStoreBlockFiltersDirty(tokenJournalBlockState, tokenJournalBlockControls); + return; + } + const cursor = cursorForBlock(tokenJournalBlockState, blockIndex); + if (cursor === undefined) { + return; + } const button = element("#refreshTokenJournalButton"); button.disabled = true; + element(tokenJournalBlockControls.firstSelector).disabled = true; + element(tokenJournalBlockControls.previousSelector).disabled = true; + element(tokenJournalBlockControls.nextSelector).disabled = true; + const request: DemoSplTokenJournalRequest = { + ...tokenJournalBaseRequest, + cursor, + blockIndex, + }; try { - const rows = await invoke("demo_spl_token_journal", { request: tokenJournalRequest() }); - renderTokenJournal(rows); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", `Invoke demo_spl_token_journal block=${blockIndex}`); + const payload = await invoke("demo_spl_token_journal", { request }); + applyStoreBlockMetadata(tokenJournalBlockState, payload.page, tokenJournalBlockControls); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", `Invoke demo_spl_token_journal completed block=${payload.page.blockIndex}/${payload.page.totalBlocks} rows=${payload.rows.length}`); + renderTokenJournal(payload.rows); appendLog({ timestamp: new Date().toISOString(), level: "info", stage: "spl_token_journal", - message: `${rows.length} événement(s) matérialisé(s) chargé(s).`, + message: `${payload.rows.length} événement(s) matérialisé(s) chargé(s) dans le bloc ${payload.page.blockIndex}/${payload.page.totalBlocks}.`, }); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); @@ -851,12 +934,18 @@ async function loadTokenJournal(): Promise { message, error: message, }, null, 2); + renderStoreBlockControls(tokenJournalBlockState, tokenJournalBlockControls); frontendError("kb-app-demo-desktop.frontend.demo_execution_spl", `Token journal loading failed: ${message}`); } finally { button.disabled = false; } } +function markTokenJournalFiltersDirty(): void { + tokenJournalBaseRequest = null; + markStoreBlockFiltersDirty(tokenJournalBlockState, tokenJournalBlockControls); +} + async function generateRecipient(): Promise { try { const payload = await invoke("demo_execution_solana_core_generate_recipient"); @@ -898,7 +987,9 @@ async function prepareSelectedProfile(): Promise { } appendLog({ timestamp: new Date().toISOString(), level: "info", stage: "store_prepare", message: `Vérification du store du profil ${profile.name}.` }); try { + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", `Invoke demo_execution_devnet_prepare_profile profile=${profile.name}`); const readiness = await invoke("demo_execution_devnet_prepare_profile", { profileName: profile.name }); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", `Invoke demo_execution_devnet_prepare_profile completed profile=${profile.name} resources=${readiness.availableResourcesAfter}/${readiness.expectedResources}`); profileStoreReady = true; appendLog({ timestamp: new Date().toISOString(), level: "info", stage: "store_ready", message: `Store ${readiness.backend} prêt: ${readiness.availableResourcesAfter}/${readiness.expectedResources} ressources, créées=${readiness.createdResources}, autoInit=${readiness.autoInitializeSchema}.` }); } catch (caughtError) { @@ -948,6 +1039,8 @@ document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item)); element("#executionProfileSelect").addEventListener("change", () => { updateProfileHelp(); + markTokenJournalFiltersDirty(); + markAtaJournalFiltersDirty(); void prepareSelectedProfile(); }); element("#generateRecipientButton").addEventListener("click", () => { @@ -982,8 +1075,27 @@ document.addEventListener("DOMContentLoaded", () => { void executeToken2022(true); }); element("#refreshTokenJournalButton").addEventListener("click", () => { - void loadTokenJournal(); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", "Token journal load button clicked"); + void startTokenJournalLoad(); }); + element("#tokenJournalFirstBlockButton").addEventListener("click", () => { + void loadTokenJournalBlock(1); + }); + element("#tokenJournalPreviousBlockButton").addEventListener("click", () => { + void loadTokenJournalBlock(tokenJournalBlockState.currentBlock - 1); + }); + element("#tokenJournalNextBlockButton").addEventListener("click", () => { + void loadTokenJournalBlock(tokenJournalBlockState.currentBlock + 1); + }); + for (const selector of [ + "#tokenJournalSignatureInput", + "#tokenJournalMintInput", + "#tokenJournalAccountInput", + "#tokenJournalOperationInput", + ]) { + element(selector).addEventListener("input", markTokenJournalFiltersDirty); + } + element("#tokenJournalFamilySelect").addEventListener("change", markTokenJournalFiltersDirty); element("#deriveAtaButton").addEventListener("click", () => { void deriveAta(); }); @@ -994,8 +1106,26 @@ document.addEventListener("DOMContentLoaded", () => { void executeAta(true); }); element("#refreshAtaJournalButton").addEventListener("click", () => { - void loadAtaJournal(); + frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", "ATA journal load button clicked"); + void startAtaJournalLoad(); }); + element("#ataJournalFirstBlockButton").addEventListener("click", () => { + void loadAtaJournalBlock(1); + }); + element("#ataJournalPreviousBlockButton").addEventListener("click", () => { + void loadAtaJournalBlock(ataJournalBlockState.currentBlock - 1); + }); + element("#ataJournalNextBlockButton").addEventListener("click", () => { + void loadAtaJournalBlock(ataJournalBlockState.currentBlock + 1); + }); + for (const selector of [ + "#ataJournalSignatureInput", + "#ataJournalMintInput", + "#ataJournalAddressInput", + "#ataJournalOperationInput", + ]) { + element(selector).addEventListener("input", markAtaJournalFiltersDirty); + } element("#cancelExecutionButton").addEventListener("click", () => { void cancelExecution(); }); diff --git a/kb-app-demo-desktop/frontend/ts/demo_store_block_pagination.ts b/kb-app-demo-desktop/frontend/ts/demo_store_block_pagination.ts new file mode 100644 index 0000000..22db002 --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_store_block_pagination.ts @@ -0,0 +1,114 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_store_block_pagination.ts +// version: 1 + +export type StoreBlockMetadata = { + blockSize: number; + blockIndex: number; + totalRows: string; + totalBlocks: number; + nextCursor: string | null; +}; + +export type StoreBlockState = { + blockSize: number; + currentBlock: number; + totalBlocks: number; + totalRows: string; + nextCursor: string | null; + cursors: Map; +}; + +export type StoreBlockControls = { + firstSelector: string; + previousSelector: string; + nextSelector: string; + summarySelector: string; +}; + +export function createStoreBlockState(): StoreBlockState { + return { + blockSize: 500, + currentBlock: 0, + totalBlocks: 0, + totalRows: "0", + nextCursor: null, + cursors: new Map([[1, null]]), + }; +} + +export function resetStoreBlockState(state: StoreBlockState): void { + state.blockSize = 500; + state.currentBlock = 0; + state.totalBlocks = 0; + state.totalRows = "0"; + state.nextCursor = null; + state.cursors = new Map([[1, null]]); +} + +export function applyStoreBlockMetadata( + state: StoreBlockState, + metadata: StoreBlockMetadata, + controls: StoreBlockControls, +): void { + state.blockSize = metadata.blockSize; + state.currentBlock = metadata.blockIndex; + state.totalBlocks = metadata.totalBlocks; + state.totalRows = metadata.totalRows; + state.nextCursor = metadata.nextCursor; + if (metadata.blockIndex > 0 && metadata.nextCursor !== null) { + state.cursors.set(metadata.blockIndex + 1, metadata.nextCursor); + } + renderStoreBlockControls(state, controls); +} + +export function renderStoreBlockControls( + state: StoreBlockState, + controls: StoreBlockControls, +): void { + const firstButton = document.querySelector(controls.firstSelector); + const previousButton = document.querySelector(controls.previousSelector); + const nextButton = document.querySelector(controls.nextSelector); + const summary = document.querySelector(controls.summarySelector); + const hasRows = state.totalBlocks > 0; + if (firstButton !== null) { + firstButton.disabled = !hasRows || state.currentBlock <= 1; + } + if (previousButton !== null) { + previousButton.disabled = !hasRows || state.currentBlock <= 1; + } + if (nextButton !== null) { + nextButton.disabled = state.nextCursor === null; + } + if (summary !== null) { + summary.textContent = hasRows + ? `Bloc ${state.currentBlock} / ${state.totalBlocks} · ${state.totalRows} résultat(s) · ${state.blockSize}/bloc` + : `Bloc 0 / 0 · 0 résultat · ${state.blockSize}/bloc`; + } +} + +export function markStoreBlockFiltersDirty( + state: StoreBlockState, + controls: StoreBlockControls, +): void { + resetStoreBlockState(state); + const firstButton = document.querySelector(controls.firstSelector); + const previousButton = document.querySelector(controls.previousSelector); + const nextButton = document.querySelector(controls.nextSelector); + const summary = document.querySelector(controls.summarySelector); + if (firstButton !== null) { + firstButton.disabled = true; + } + if (previousButton !== null) { + previousButton.disabled = true; + } + if (nextButton !== null) { + nextButton.disabled = true; + } + if (summary !== null) { + summary.textContent = "Filtres modifiés — rechargez le premier bloc."; + } +} + +export function cursorForBlock(state: StoreBlockState, blockIndex: number): string | null | undefined { + return state.cursors.get(blockIndex); +} diff --git a/kb-app-demo-desktop/frontend/ts/demo_store_core.ts b/kb-app-demo-desktop/frontend/ts/demo_store_core.ts index 07ae053..48a1ec0 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_store_core.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_store_core.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_store_core.ts -// version: 6 +// version: 7 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -35,7 +35,7 @@ document.addEventListener("DOMContentLoaded", () => { frontendDebug("kb_app_demo_desktop.frontend.demo_store_core", "Core store demo window loaded"); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); - document.querySelector("#refreshSqlCoreButton")?.addEventListener("click", () => { + document.querySelector("#refreshStoreCoreButton")?.addEventListener("click", () => { void refreshCore(); }); void refreshCore(); diff --git a/kb-app-demo-desktop/frontend/ts/demo_store_diag.ts b/kb-app-demo-desktop/frontend/ts/demo_store_diag.ts index c5c50ac..85cd9b0 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_store_diag.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_store_diag.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_store_diag.ts -// version: 5 +// version: 6 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -50,10 +50,10 @@ document.addEventListener("DOMContentLoaded", () => { frontendDebug("kb_app_demo_desktop.frontend.demo_store_diag", "Store diagnostics demo window loaded"); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); - document.querySelector("#refreshSqlDiagButton")?.addEventListener("click", () => { + document.querySelector("#refreshStoreDiagButton")?.addEventListener("click", () => { void refreshDiagnostics(); }); - document.querySelector("#copySqlDiagButton")?.addEventListener("click", () => { + document.querySelector("#copyStoreDiagButton")?.addEventListener("click", () => { void copyDiagnostics(); }); void refreshDiagnostics(); diff --git a/kb-app-demo-desktop/frontend/ts/demo_store_raw.ts b/kb-app-demo-desktop/frontend/ts/demo_store_raw.ts index d82f2e6..173f590 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_store_raw.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_store_raw.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_store_raw.ts -// version: 6 +// version: 7 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -35,7 +35,7 @@ document.addEventListener("DOMContentLoaded", () => { frontendDebug("kb_app_demo_desktop.frontend.demo_store_raw", "Raw store demo window loaded"); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); - document.querySelector("#refreshSqlRawButton")?.addEventListener("click", () => { + document.querySelector("#refreshStoreRawButton")?.addEventListener("click", () => { void refreshRaw(); }); void refreshRaw(); diff --git a/kb-app-demo-desktop/frontend/ts/demo_store_replay_candidates.ts b/kb-app-demo-desktop/frontend/ts/demo_store_replay_candidates.ts index 32022b2..4068d29 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_store_replay_candidates.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_store_replay_candidates.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_store_replay_candidates.ts -// version: 12 +// version: 14 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -10,12 +10,25 @@ import "datatables.net-select-bs5"; import "datatables.net-select-bs5/css/select.bootstrap5.css"; import { invoke } from "@tauri-apps/api/core"; import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log"; +import { + applyStoreBlockMetadata, + createStoreBlockState, + cursorForBlock, + markStoreBlockFiltersDirty, + resetStoreBlockState, + renderStoreBlockControls, + type StoreBlockControls, + type StoreBlockState, +} from "./demo_store_block_pagination"; +import type { DemoStoreReplayEntityPagePayload } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayEntityPagePayload"; import type { DemoStoreReplayEntityRequest } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayEntityRequest"; import type { DemoStoreReplayEntityRow } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayEntityRow"; import type { DemoStoreReplayKnownProgramOption } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayKnownProgramOption"; import type { DemoStoreReplayOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayOptionsPayload"; +import type { DemoStoreReplayProgramPagePayload } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayProgramPagePayload"; import type { DemoStoreReplayProgramRequest } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayProgramRequest"; import type { DemoStoreReplayProgramRow } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayProgramRow"; +import type { DemoStoreReplayTransactionPagePayload } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayTransactionPagePayload"; import type { DemoStoreReplayTransactionRequest } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayTransactionRequest"; import type { DemoStoreReplayTransactionRow } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayTransactionRow"; @@ -30,7 +43,7 @@ type EntityViewConfig = { pluralLabel: string; tableSelector: string; containsSelector: string; - limitSelector: string; + blockControls: StoreBlockControls; summarySelector: string; loadButtonSelector: string; resetButtonSelector: string; @@ -48,7 +61,12 @@ const entityViewConfigs: EntityViewConfig[] = [ pluralLabel: "mints", tableSelector: "#mintCandidateTable", containsSelector: "#mintContainsInput", - limitSelector: "#mintLimitInput", + blockControls: { + firstSelector: "#mintFirstBlockButton", + previousSelector: "#mintPreviousBlockButton", + nextSelector: "#mintNextBlockButton", + summarySelector: "#mintBlockSummary", + }, summarySelector: "#mintResultSummary", loadButtonSelector: "#loadMintsButton", resetButtonSelector: "#resetMintsButton", @@ -63,7 +81,12 @@ const entityViewConfigs: EntityViewConfig[] = [ pluralLabel: "owners", tableSelector: "#ownerCandidateTable", containsSelector: "#ownerContainsInput", - limitSelector: "#ownerLimitInput", + blockControls: { + firstSelector: "#ownerFirstBlockButton", + previousSelector: "#ownerPreviousBlockButton", + nextSelector: "#ownerNextBlockButton", + summarySelector: "#ownerBlockSummary", + }, summarySelector: "#ownerResultSummary", loadButtonSelector: "#loadOwnersButton", resetButtonSelector: "#resetOwnersButton", @@ -78,7 +101,12 @@ const entityViewConfigs: EntityViewConfig[] = [ pluralLabel: "comptes", tableSelector: "#accountCandidateTable", containsSelector: "#accountContainsInput", - limitSelector: "#accountLimitInput", + blockControls: { + firstSelector: "#accountFirstBlockButton", + previousSelector: "#accountPreviousBlockButton", + nextSelector: "#accountNextBlockButton", + summarySelector: "#accountBlockSummary", + }, summarySelector: "#accountResultSummary", loadButtonSelector: "#loadAccountsButton", resetButtonSelector: "#resetAccountsButton", @@ -94,7 +122,31 @@ let mintTable: Api | null = null; let ownerTable: Api | null = null; let accountTable: Api | null = null; let busyOperationCount = 0; -let maximumReplayCandidateLimit = 500; +const transactionBlockControls: StoreBlockControls = { + firstSelector: "#transactionFirstBlockButton", + previousSelector: "#transactionPreviousBlockButton", + nextSelector: "#transactionNextBlockButton", + summarySelector: "#transactionBlockSummary", +}; +const programBlockControls: StoreBlockControls = { + firstSelector: "#programFirstBlockButton", + previousSelector: "#programPreviousBlockButton", + nextSelector: "#programNextBlockButton", + summarySelector: "#programBlockSummary", +}; +const transactionBlockState = createStoreBlockState(); +const programBlockState = createStoreBlockState(); +const entityBlockStates = new Map([ + ["mint", createStoreBlockState()], + ["owner", createStoreBlockState()], + ["account_key", createStoreBlockState()], +]); +type TransactionBaseRequest = Omit; +type ProgramBaseRequest = Omit; +type EntityBaseRequest = Omit; +let transactionBaseRequest: TransactionBaseRequest | null = null; +let programBaseRequest: ProgramBaseRequest | null = null; +const entityBaseRequests = new Map(); function element(selector: string): T { const value = document.querySelector(selector); @@ -117,24 +169,6 @@ function requiredInputValue(selector: string): string { return value; } -function positiveIntegerValue(selector: string): number { - const value = Number.parseInt(requiredInputValue(selector), 10); - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`Valeur entière positive invalide pour ${selector}`); - } - return value; -} - -function boundedPositiveIntegerValue(selector: string): number { - const value = positiveIntegerValue(selector); - if (value > maximumReplayCandidateLimit) { - throw new Error( - `La limite doit être comprise entre 1 et ${maximumReplayCandidateLimit} pour ${selector}`, - ); - } - return value; -} - function optionalNonNegativeIntegerValue(selector: string): number | null { const raw = optionalInputValue(selector); if (raw === null) { @@ -285,7 +319,7 @@ function createTransactionTable(): Api { ], order: [[2, "desc"]], pageLength: 10, - lengthMenu: [10, 25, 50, 100, 250], + lengthMenu: [10, 25, 50, 100, 250, 500], select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" }, language: commonLanguage( "Aucune transaction chargée", @@ -319,7 +353,7 @@ function createProgramTable(): Api { ], order: [[3, "desc"]], pageLength: 10, - lengthMenu: [10, 25, 50, 100, 250], + lengthMenu: [10, 25, 50, 100, 250, 500], select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" }, language: commonLanguage( "Aucun programme chargé", @@ -352,7 +386,7 @@ function createEntityTable(config: EntityViewConfig): Api(table: Api, label: string): T { throw new Error(`Cochez exactement un ${label}, ou réduisez le filtre local à une seule ligne.`); } +function restoreLoadedStoreBlockControls(): void { + if (transactionBlockState.currentBlock > 0) { + renderStoreBlockControls(transactionBlockState, transactionBlockControls); + } + if (programBlockState.currentBlock > 0) { + renderStoreBlockControls(programBlockState, programBlockControls); + } + for (const config of entityViewConfigs) { + const state = entityBlockState(config.kind); + if (state.currentBlock > 0) { + renderStoreBlockControls(state, config.blockControls); + } + } +} + function setBusy(busy: boolean, message: string): void { busyOperationCount = busy ? busyOperationCount + 1 : Math.max(0, busyOperationCount - 1); const operationInProgress = busyOperationCount > 0; @@ -419,13 +468,20 @@ function setBusy(busy: boolean, message: string): void { badge.textContent = operationInProgress ? (busy ? message : "Chargement en cours") : "Prêt"; badge.className = operationInProgress ? "badge text-bg-warning" : "badge text-bg-success"; document.querySelectorAll("button").forEach(button => { - if (button.id !== "openCoreExtractionButton") { - button.disabled = operationInProgress; + if (button.id === "openCoreExtractionButton") { + return; } + if (!operationInProgress && button.dataset.storeBlockNavigation === "true") { + return; + } + button.disabled = operationInProgress; }); + if (!operationInProgress) { + restoreLoadedStoreBlockControls(); + } } -function buildTransactionRequest(): DemoStoreReplayTransactionRequest { +function buildTransactionBaseRequest(): TransactionBaseRequest { const entityKind = optionalInputValue("#transactionEntityKindSelect"); const entityValue = optionalInputValue("#transactionEntityValueInput"); if ((entityKind === null) !== (entityValue === null)) { @@ -441,11 +497,31 @@ function buildTransactionRequest(): DemoStoreReplayTransactionRequest { programScope: requiredInputValue("#transactionProgramScopeSelect"), entityKind, entityValue, - limit: boundedPositiveIntegerValue("#transactionLimitInput"), newestFirst: element("#transactionNewestFirstInput").checked, }; } +function buildProgramBaseRequest(): ProgramBaseRequest { + return { + programIdContains: optionalInputValue("#programContainsInput"), + }; +} + +function buildEntityBaseRequest(config: EntityViewConfig): EntityBaseRequest { + return { + entityKind: config.kind, + entityValueContains: optionalInputValue(config.containsSelector), + }; +} + +function entityBlockState(kind: EntityKindCode): StoreBlockState { + const state = entityBlockStates.get(kind); + if (state === undefined) { + throw new Error(`État de pagination Store manquant pour ${kind}.`); + } + return state; +} + async function resetTransactionFilters(): Promise { element("#transactionSignatureContainsInput").value = ""; element("#transactionMinSlotInput").value = ""; @@ -456,95 +532,190 @@ async function resetTransactionFilters(): Promise { element("#transactionProgramScopeSelect").value = "any"; element("#transactionEntityKindSelect").value = ""; element("#transactionEntityValueInput").value = ""; - element("#transactionLimitInput").value = "500"; element("#transactionNewestFirstInput").checked = true; resetTableState(transactionTable); - await loadTransactions(); + await startTransactionLoad(); } async function resetProgramFilters(): Promise { element("#knownProgramSelect").value = ""; element("#programContainsInput").value = ""; - element("#programLimitInput").value = "500"; resetTableState(programTable); - await loadPrograms(); + await startProgramLoad(); } async function resetEntityFilters(config: EntityViewConfig): Promise { element(config.containsSelector).value = ""; - element(config.limitSelector).value = "500"; resetTableState(entityTable(config.kind)); - await loadEntity(config); + await startEntityLoad(config); } -async function loadTransactions(): Promise { +async function startTransactionLoad(): Promise { + transactionBaseRequest = buildTransactionBaseRequest(); + resetStoreBlockState(transactionBlockState); + await loadTransactionBlock(1, null); +} + +async function loadTransactionBlock(blockIndex: number, cursor: string | null): Promise { if (transactionTable === null) { return; } + if (transactionBaseRequest === null) { + throw new Error("Rechargez les transactions pour initialiser les filtres Store."); + } try { setBusy(true, "Chargement transactions"); - const request = buildTransactionRequest(); - const rows = await invoke("load_demo_store_replay_transactions", { request }); - replaceRows(transactionTable, rows); - element("#transactionResultSummary").textContent = `${rows.length} transaction(s) chargée(s) depuis le store actif.`; - frontendDebug(tracingTarget, `loaded ${rows.length} replay transaction candidates`); + const request: DemoStoreReplayTransactionRequest = { + ...transactionBaseRequest, + cursor, + blockIndex, + }; + const payload = await invoke( + "load_demo_store_replay_transactions", + { request }, + ); + replaceRows(transactionTable, payload.rows); + applyStoreBlockMetadata(transactionBlockState, payload.page, transactionBlockControls); + element("#transactionResultSummary").textContent = `${payload.rows.length} transaction(s) dans le bloc Store courant.`; + frontendDebug(tracingTarget, `loaded replay transaction block ${payload.page.blockIndex}/${payload.page.totalBlocks} with ${payload.rows.length} rows`); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); element("#transactionResultSummary").textContent = `Erreur : ${message}`; + renderStoreBlockControls(transactionBlockState, transactionBlockControls); frontendError(tracingTarget, `transaction candidate loading failed: ${message}`); } finally { setBusy(false, "Prêt"); } } -async function loadPrograms(): Promise { +async function startProgramLoad(): Promise { + programBaseRequest = buildProgramBaseRequest(); + resetStoreBlockState(programBlockState); + await loadProgramBlock(1, null); +} + +async function loadProgramBlock(blockIndex: number, cursor: string | null): Promise { if (programTable === null) { return; } + if (programBaseRequest === null) { + throw new Error("Rechargez les programmes pour initialiser les filtres Store."); + } try { setBusy(true, "Chargement programmes"); const request: DemoStoreReplayProgramRequest = { - programIdContains: optionalInputValue("#programContainsInput"), - limit: boundedPositiveIntegerValue("#programLimitInput"), + ...programBaseRequest, + cursor, + blockIndex, }; - const rows = await invoke("load_demo_store_replay_programs", { request }); - replaceRows(programTable, rows); - element("#programResultSummary").textContent = `${rows.length} programme(s) chargé(s), avec occurrences top-level, inner et logs liés.`; - frontendDebug(tracingTarget, `loaded ${rows.length} replay program summaries`); + const payload = await invoke( + "load_demo_store_replay_programs", + { request }, + ); + replaceRows(programTable, payload.rows); + applyStoreBlockMetadata(programBlockState, payload.page, programBlockControls); + element("#programResultSummary").textContent = `${payload.rows.length} programme(s) dans le bloc Store courant, avec occurrences top-level, inner et logs liés.`; + frontendDebug(tracingTarget, `loaded replay program block ${payload.page.blockIndex}/${payload.page.totalBlocks} with ${payload.rows.length} rows`); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); element("#programResultSummary").textContent = `Erreur : ${message}`; + renderStoreBlockControls(programBlockState, programBlockControls); frontendError(tracingTarget, `program summary loading failed: ${message}`); } finally { setBusy(false, "Prêt"); } } -async function loadEntity(config: EntityViewConfig): Promise { +async function startEntityLoad(config: EntityViewConfig): Promise { + entityBaseRequests.set(config.kind, buildEntityBaseRequest(config)); + resetStoreBlockState(entityBlockState(config.kind)); + await loadEntityBlock(config, 1, null); +} + +async function loadEntityBlock(config: EntityViewConfig, blockIndex: number, cursor: string | null): Promise { const table = entityTable(config.kind); if (table === null) { return; } + const baseRequest = entityBaseRequests.get(config.kind); + if (baseRequest === undefined) { + throw new Error(`Rechargez les ${config.pluralLabel} pour initialiser les filtres Store.`); + } try { setBusy(true, `Chargement ${config.pluralLabel}`); const request: DemoStoreReplayEntityRequest = { - entityKind: config.kind, - entityValueContains: optionalInputValue(config.containsSelector), - limit: boundedPositiveIntegerValue(config.limitSelector), + ...baseRequest, + cursor, + blockIndex, }; - const rows = await invoke("load_demo_store_replay_entities", { request }); - replaceRows(table, rows); - element(config.summarySelector).textContent = `${rows.length} ${config.pluralLabel} chargé(s) depuis le store actif.`; - frontendDebug(tracingTarget, `loaded ${rows.length} replay ${config.kind} summaries`); + const payload = await invoke( + "load_demo_store_replay_entities", + { request }, + ); + replaceRows(table, payload.rows); + applyStoreBlockMetadata(entityBlockState(config.kind), payload.page, config.blockControls); + element(config.summarySelector).textContent = `${payload.rows.length} ${config.pluralLabel} dans le bloc Store courant.`; + frontendDebug(tracingTarget, `loaded replay ${config.kind} block ${payload.page.blockIndex}/${payload.page.totalBlocks} with ${payload.rows.length} rows`); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); element(config.summarySelector).textContent = `Erreur : ${message}`; + renderStoreBlockControls(entityBlockState(config.kind), config.blockControls); frontendError(tracingTarget, `${config.kind} summary loading failed: ${message}`); } finally { setBusy(false, "Prêt"); } } +async function loadPreviousTransactionBlock(): Promise { + const targetBlock = transactionBlockState.currentBlock - 1; + const cursor = cursorForBlock(transactionBlockState, targetBlock); + if (targetBlock < 1 || cursor === undefined) { + return; + } + await loadTransactionBlock(targetBlock, cursor); +} + +async function loadNextTransactionBlock(): Promise { + if (transactionBlockState.nextCursor === null) { + return; + } + await loadTransactionBlock(transactionBlockState.currentBlock + 1, transactionBlockState.nextCursor); +} + +async function loadPreviousProgramBlock(): Promise { + const targetBlock = programBlockState.currentBlock - 1; + const cursor = cursorForBlock(programBlockState, targetBlock); + if (targetBlock < 1 || cursor === undefined) { + return; + } + await loadProgramBlock(targetBlock, cursor); +} + +async function loadNextProgramBlock(): Promise { + if (programBlockState.nextCursor === null) { + return; + } + await loadProgramBlock(programBlockState.currentBlock + 1, programBlockState.nextCursor); +} + +async function loadPreviousEntityBlock(config: EntityViewConfig): Promise { + const state = entityBlockState(config.kind); + const targetBlock = state.currentBlock - 1; + const cursor = cursorForBlock(state, targetBlock); + if (targetBlock < 1 || cursor === undefined) { + return; + } + await loadEntityBlock(config, targetBlock, cursor); +} + +async function loadNextEntityBlock(config: EntityViewConfig): Promise { + const state = entityBlockState(config.kind); + if (state.nextCursor === null) { + return; + } + await loadEntityBlock(config, state.currentBlock + 1, state.nextCursor); +} + async function copyLines(lines: string[], label: string): Promise { if (lines.length === 0) { throw new Error(`Aucune ${label} à copier.`); @@ -635,7 +806,7 @@ async function useSelectedProgram(): Promise { element("#transactionProgramIdInput").value = row.programId; element("#transactionProgramScopeSelect").value = "any"; showTransactionsTab(); - await loadTransactions(); + await startTransactionLoad(); } async function useSelectedEntity(config: EntityViewConfig): Promise { @@ -647,7 +818,7 @@ async function useSelectedEntity(config: EntityViewConfig): Promise { element("#transactionEntityKindSelect").value = config.kind; element("#transactionEntityValueInput").value = row.entityValue; showTransactionsTab(); - await loadTransactions(); + await startTransactionLoad(); } async function openCoreExtraction(): Promise { @@ -661,16 +832,12 @@ async function openCoreExtraction(): Promise { async function loadOptions(): Promise { const options = await invoke("demo_store_replay_options"); - if (!Number.isSafeInteger(options.maximumLimit) || options.maximumLimit <= 0) { - throw new Error("La limite maximale des candidats replay retournée par le backend est invalide."); + if (!Number.isSafeInteger(options.blockSize) || options.blockSize !== 500) { + throw new Error("La taille de bloc Store retournée par le backend est invalide."); } - maximumReplayCandidateLimit = options.maximumLimit; element("#replayCandidateProfileBadge").textContent = `Profil ${options.activeProfileName}`; element("#replayCandidateDsn").textContent = options.connectionDescriptor; - element("#replayCandidateMaximumLimit").textContent = String(options.maximumLimit); - document.querySelectorAll("input[type=number][max]").forEach(input => { - input.max = String(options.maximumLimit); - }); + element("#replayCandidateBlockSize").textContent = `${options.blockSize} lignes`; populateKnownPrograms(options.knownPrograms); } @@ -729,14 +896,20 @@ function reportUiError(action: string, caughtError: unknown): void { } function installHandlers(): void { - element("#loadTransactionsButton").addEventListener("click", () => void loadTransactions()); + element("#loadTransactionsButton").addEventListener("click", () => void startTransactionLoad()); element("#resetTransactionsButton").addEventListener("click", () => { void resetTransactionFilters().catch(error => reportUiError("reset transaction filters", error)); }); - element("#loadProgramsButton").addEventListener("click", () => void loadPrograms()); + element(transactionBlockControls.firstSelector).addEventListener("click", () => void loadTransactionBlock(1, null)); + element(transactionBlockControls.previousSelector).addEventListener("click", () => void loadPreviousTransactionBlock()); + element(transactionBlockControls.nextSelector).addEventListener("click", () => void loadNextTransactionBlock()); + element("#loadProgramsButton").addEventListener("click", () => void startProgramLoad()); element("#resetProgramsButton").addEventListener("click", () => { void resetProgramFilters().catch(error => reportUiError("reset program filters", error)); }); + element(programBlockControls.firstSelector).addEventListener("click", () => void loadProgramBlock(1, null)); + element(programBlockControls.previousSelector).addEventListener("click", () => void loadPreviousProgramBlock()); + element(programBlockControls.nextSelector).addEventListener("click", () => void loadNextProgramBlock()); element("#openCoreExtractionButton").addEventListener("click", () => void openCoreExtraction()); element("#copySignaturesButton").addEventListener("click", () => { void copyLines(transactionRowsForExport().map(row => row.signature), "signature").catch(error => reportUiError("copy signatures", error)); @@ -754,10 +927,13 @@ function installHandlers(): void { void useSelectedProgram().catch(error => reportUiError("use selected program", error)); }); for (const config of entityViewConfigs) { - element(config.loadButtonSelector).addEventListener("click", () => void loadEntity(config)); + element(config.loadButtonSelector).addEventListener("click", () => void startEntityLoad(config)); element(config.resetButtonSelector).addEventListener("click", () => { void resetEntityFilters(config).catch(error => reportUiError(`reset ${config.pluralLabel} filters`, error)); }); + element(config.blockControls.firstSelector).addEventListener("click", () => void loadEntityBlock(config, 1, null)); + element(config.blockControls.previousSelector).addEventListener("click", () => void loadPreviousEntityBlock(config)); + element(config.blockControls.nextSelector).addEventListener("click", () => void loadNextEntityBlock(config)); element(config.copyButtonSelector).addEventListener("click", () => { void copyLines(entityRowsForExport(config.kind).map(row => row.entityValue), config.label).catch(error => reportUiError(`copy ${config.pluralLabel}`, error)); }); @@ -767,16 +943,49 @@ function installHandlers(): void { element(config.useButtonSelector).addEventListener("click", () => { void useSelectedEntity(config).catch(error => reportUiError(`use selected ${config.label}`, error)); }); + element(config.containsSelector).addEventListener("input", () => { + entityBaseRequests.delete(config.kind); + markStoreBlockFiltersDirty(entityBlockState(config.kind), config.blockControls); + }); } + const transactionFilterSelectors = [ + "#transactionSignatureContainsInput", + "#transactionMinSlotInput", + "#transactionMaxSlotInput", + "#transactionRawStateSelect", + "#transactionLedgerStatusSelect", + "#transactionProgramIdInput", + "#transactionProgramScopeSelect", + "#transactionEntityKindSelect", + "#transactionEntityValueInput", + "#transactionNewestFirstInput", + ]; + for (const selector of transactionFilterSelectors) { + element(selector).addEventListener("change", () => { + transactionBaseRequest = null; + markStoreBlockFiltersDirty(transactionBlockState, transactionBlockControls); + }); + element(selector).addEventListener("input", () => { + transactionBaseRequest = null; + markStoreBlockFiltersDirty(transactionBlockState, transactionBlockControls); + }); + } + element("#programContainsInput").addEventListener("input", () => { + programBaseRequest = null; + markStoreBlockFiltersDirty(programBlockState, programBlockControls); + }); element("#knownProgramSelect").addEventListener("change", event => { const target = event.currentTarget as HTMLSelectElement; element("#programContainsInput").value = target.value; + programBaseRequest = null; + markStoreBlockFiltersDirty(programBlockState, programBlockControls); }); document.querySelectorAll('[data-bs-toggle="tab"]').forEach(button => { button.addEventListener("shown.bs.tab", adjustVisibleTables); }); } + document.addEventListener("DOMContentLoaded", () => { installFrontendConsoleBridge(tracingTarget); frontendDebug(tracingTarget, "Store replay candidate browser loaded"); @@ -790,11 +999,11 @@ document.addEventListener("DOMContentLoaded", () => { installInlineCopyHandler(); void loadOptions() .then(() => Promise.all([ - loadTransactions(), - loadPrograms(), - loadEntity(entityViewConfigs[0]), - loadEntity(entityViewConfigs[1]), - loadEntity(entityViewConfigs[2]), + startTransactionLoad(), + startProgramLoad(), + startEntityLoad(entityViewConfigs[0]), + startEntityLoad(entityViewConfigs[1]), + startEntityLoad(entityViewConfigs[2]), ])) .catch(caughtError => reportUiError("initial loading", caughtError)); }); diff --git a/kb-app-demo-desktop/src/app_state.rs b/kb-app-demo-desktop/src/app_state.rs index f53180a..2c63870 100644 --- a/kb-app-demo-desktop/src/app_state.rs +++ b/kb-app-demo-desktop/src/app_state.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/app_state.rs -// version: 20 +// version: 21 //! Shared Tauri application state and startup initialization. @@ -18,6 +18,12 @@ pub(crate) struct AppState { logging_guard: std::sync::Mutex, http_pool: ks_onchain_transport::HttpEndpointPool, store: tokio::sync::OnceCell, + profile_stores: tokio::sync::Mutex< + std::collections::BTreeMap< + std::string::String, + std::sync::Arc>, + >, + >, ws_pool: std::sync::Mutex>>, demo_ws_session: @@ -108,6 +114,7 @@ impl crate::AppState { logging_guard: std::sync::Mutex::new(logging_guard), http_pool, store: tokio::sync::OnceCell::new(), + profile_stores: tokio::sync::Mutex::new(std::collections::BTreeMap::new()), ws_pool: std::sync::Mutex::new(std::option::Option::None), demo_ws_session: tokio::sync::Mutex::new(std::option::Option::None), demo_backfill_running: std::sync::atomic::AtomicBool::new(false), @@ -195,6 +202,63 @@ impl crate::AppState { .await; } + /// Returns a shared persistent store for one immutable configured profile. + pub(crate) async fn store_for_profile( + &self, + profile: &ks_config::ProfileConfig, + ) -> ks_core::Result { + let configured_profile = self + .app_config + .profiles + .iter() + .find(|candidate| return candidate.name == profile.name); + let configured_database = match configured_profile { + std::option::Option::Some(value) => value.database.clone(), + std::option::Option::None => { + return std::result::Result::Err(ks_core::Error::invalid_state( + "store profile is not part of the resolved application configuration", + )); + }, + }; + if configured_database != profile.database { + return std::result::Result::Err(ks_core::Error::invalid_state( + "store profile database configuration mismatch", + )); + } + if profile.name == self.active_profile.name { + let store_result = self.store().await; + return match store_result { + std::result::Result::Ok(store) => std::result::Result::Ok(store.clone()), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + let cell = { + let mut stores = self.profile_stores.lock().await; + if let std::option::Option::Some(existing) = stores.get(profile.name.as_str()) { + std::sync::Arc::clone(existing) + } else { + let created = std::sync::Arc::new(tokio::sync::OnceCell::new()); + stores.insert(profile.name.clone(), std::sync::Arc::clone(&created)); + created + } + }; + let options = match ks_store::StoreOpenOptions::new( + configured_database.enabled, + configured_database.backend, + configured_database.backend_options, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let store_result = cell + .get_or_try_init(|| return async move { return ks_store::Store::open(options).await }) + .await; + return match store_result { + std::result::Result::Ok(store) => std::result::Result::Ok(store.clone()), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + /// Returns the session-only execution-wallet alias override for one profile. pub(crate) fn demo_execution_wallet_alias_override( &self, diff --git a/kb-app-demo-desktop/src/demo_decode_replay.rs b/kb-app-demo-desktop/src/demo_decode_replay.rs index abd4696..4d7f6b1 100644 --- a/kb-app-demo-desktop/src/demo_decode_replay.rs +++ b/kb-app-demo-desktop/src/demo_decode_replay.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_decode_replay.rs -// version: 45 +// version: 46 //! Tauri commands and UI payloads for contextual instruction decode replay. @@ -271,8 +271,10 @@ pub(crate) struct DemoDecodeDiagnosticsPayload { pub(crate) struct DemoTransactionAnnotationRequest { /// Optional partial transaction signature. pub(crate) signature_contains: std::option::Option, - /// Maximum returned rows. - pub(crate) limit: u32, + /// Opaque cursor identifying the requested Store block. + pub(crate) cursor: std::option::Option, + /// One-based Store block index maintained by the desktop navigation. + pub(crate) block_index: u32, } /// UI-safe committed transaction annotation row. @@ -313,6 +315,20 @@ pub(crate) struct DemoTransactionAnnotationRow { pub(crate) updated_at: std::string::String, } +/// One counted Store block of committed transaction annotations. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_decode_replay/DemoTransactionAnnotationPagePayload.ts" +)] +pub(crate) struct DemoTransactionAnnotationPagePayload { + /// Shared block metadata refreshed with this load. + pub(crate) page: crate::DemoStoreBlockMetadata, + /// Annotations in the current Store block. + pub(crate) rows: std::vec::Vec, +} + pub(crate) struct DemoDecodeReplayObserver<'a> { pub(crate) app_handle: tauri::AppHandle, pub(crate) campaign_id: std::string::String, @@ -604,39 +620,49 @@ pub(crate) async fn demo_decode_replay_diagnostics( pub(crate) async fn demo_decode_replay_annotations( state: tauri::State<'_, crate::AppState>, request: crate::DemoTransactionAnnotationRequest, -) -> std::result::Result, std::string::String> { - let filter_result = ks_store::MaterializedOutputFilter::new( +) -> std::result::Result { + let filter = ks_store::MaterializedOutputPageFilter::new( std::option::Option::Some("materializer.transaction.annotations".to_string()), std::option::Option::Some("transaction_annotation".to_string()), + std::option::Option::Some("spl.memo".to_string()), request.signature_contains, - request.limit, + std::vec::Vec::new(), ); - let filter = match filter_result { + let page_request_result = crate::demo_store_block_request(request.cursor); + let page_request = match page_request_result { std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + std::result::Result::Err(error) => return std::result::Result::Err(error), }; - tracing::debug!(target: crate::TRACING_TARGET, action = "load_transaction_annotations", signature_contains = ?filter.signature_contains, limit = filter.limit, "load bounded committed transaction annotation journal"); + tracing::debug!(target: crate::TRACING_TARGET, action = "load_transaction_annotations", block_index = request.block_index, "load committed transaction annotation Store block"); let store_result = crate::shared_store(state.inner()).await; let store = match store_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let rows_result = - ks_store::DecodePipelineStore::list_materialized_outputs(&store, &filter).await; - let rows = match rows_result { + let page_result = store.materialized_output_page(&filter, &page_request).await; + let page = match page_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { + let metadata_result = + crate::demo_store_block_metadata(page.total_rows, page.next_cursor, request.block_index); + let metadata = match metadata_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut rows = std::vec::Vec::with_capacity(page.rows.len()); + for row in page.rows { let mapped = annotation_payload(row); match mapped { - std::result::Result::Ok(value) => output.push(value), + std::result::Result::Ok(value) => rows.push(value), std::result::Result::Err(error) => return std::result::Result::Err(error), } } - tracing::debug!(target: crate::TRACING_TARGET, action = "load_transaction_annotations", row_count = output.len(), "bounded committed transaction annotation journal loaded"); - return std::result::Result::Ok(output); + tracing::debug!(target: crate::TRACING_TARGET, action = "load_transaction_annotations", row_count = rows.len(), total_rows = %metadata.total_rows, block_index = metadata.block_index, total_blocks = metadata.total_blocks, "committed transaction annotation Store block loaded"); + return std::result::Result::Ok(crate::DemoTransactionAnnotationPagePayload { + page: metadata, + rows, + }); } fn register_active_campaign( diff --git a/kb-app-demo-desktop/src/demo_devnet_common.rs b/kb-app-demo-desktop/src/demo_devnet_common.rs index 8a99585..6533a10 100644 --- a/kb-app-demo-desktop/src/demo_devnet_common.rs +++ b/kb-app-demo-desktop/src/demo_devnet_common.rs @@ -1,7 +1,7 @@ // file: kb-app-demo-desktop/src/demo_devnet_common.rs -// version: 7 +// version: 9 -//! Shared Devnet demo UI contracts. +//! Shared Devnet demo UI contracts and profile/store helpers. use ts_rs::TS; // rust-rules: derive-import @@ -31,8 +31,57 @@ pub(crate) struct DemoExecutionDevnetStoreReadinessPayload { pub(crate) expected_resources: u32, } +/// Resolves one explicitly selected compatible Devnet profile. +pub(crate) fn select_devnet_profile( + config: &ks_config::AppConfig, + profile_name: &str, +) -> std::result::Result { + return ks_pipeline_demo_scenarios::resolve_demo_devnet_profile( + config, + std::option::Option::Some(profile_name), + ) + .map_err(|error| return error.to_string()); +} + +/// Verifies or initializes the configured store selected by one Devnet profile. +pub(crate) async fn demo_execution_devnet_prepare_profile( + state: tauri::State<'_, crate::AppState>, + profile_name: std::string::String, +) -> std::result::Result { + let profile = match select_devnet_profile(state.app_config(), profile_name.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let configuration = store.configuration_summary(); + let initialization = store.initialization_summary(); + if initialization.status != ks_store::StoreInitializationStatus::Ready { + return std::result::Result::Err(format!( + "Devnet profile '{}' store model is incomplete and automatic initialization is {}", + profile.name, + if configuration.auto_initialize_schema { "enabled" } else { "disabled" } + )); + } + let readiness = ks_pipeline_demo_scenarios::DevnetProfileStoreReadiness { + profile_name: profile.name.clone(), + backend: configuration.backend_code.clone(), + auto_initialize_schema: configuration.auto_initialize_schema, + available_resources_before: initialization + .available_resource_count + .saturating_sub(initialization.created_resource_count) + as usize, + created_resources: initialization.created_resource_count as usize, + available_resources_after: initialization.available_resource_count as usize, + expected_resources: initialization.expected_resource_count as usize, + }; + return std::result::Result::Ok(devnet_store_readiness_payload(readiness)); +} + /// Converts the reusable scenario readiness report to the desktop TS-RS payload. -pub(crate) fn devnet_store_readiness_payload( +fn devnet_store_readiness_payload( readiness: ks_pipeline_demo_scenarios::DevnetProfileStoreReadiness, ) -> DemoExecutionDevnetStoreReadinessPayload { return DemoExecutionDevnetStoreReadinessPayload { diff --git a/kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs b/kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs index e24839f..fcb81d8 100644 --- a/kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs +++ b/kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs -// version: 13 +// version: 14 //! Desktop adapters for generic and qualified Metaplex Token Metadata execution workflows. @@ -190,7 +190,7 @@ pub(crate) async fn demo_execution_metadata_metaplex_token_metadata_execute( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; @@ -1041,7 +1041,7 @@ pub(crate) async fn demo_execution_metadata_metaplex_execute_campaign( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; diff --git a/kb-app-demo-desktop/src/demo_execution_metadata_token_2022.rs b/kb-app-demo-desktop/src/demo_execution_metadata_token_2022.rs index 0313d9e..0543bb1 100644 --- a/kb-app-demo-desktop/src/demo_execution_metadata_token_2022.rs +++ b/kb-app-demo-desktop/src/demo_execution_metadata_token_2022.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_execution_metadata_token_2022.rs -// version: 7 +// version: 8 //! Thin desktop adapter for the complete Token-2022 Token Metadata Devnet campaign. @@ -88,7 +88,7 @@ pub(crate) async fn demo_execution_metadata_token_2022_execute_campaign( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; diff --git a/kb-app-demo-desktop/src/demo_execution_solana_core.rs b/kb-app-demo-desktop/src/demo_execution_solana_core.rs index 9ff530d..02a87ab 100644 --- a/kb-app-demo-desktop/src/demo_execution_solana_core.rs +++ b/kb-app-demo-desktop/src/demo_execution_solana_core.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_execution_solana_core.rs -// version: 23 +// version: 26 //! Tauri adapter for bounded Solana Core execution on Devnet. @@ -359,17 +359,6 @@ impl ks_pipeline_demo_scenarios::SolanaExecutionObserver } } -pub(crate) fn select_devnet_profile( - config: &ks_config::AppConfig, - profile_name: &str, -) -> std::result::Result { - return ks_pipeline_demo_scenarios::resolve_demo_devnet_profile( - config, - std::option::Option::Some(profile_name), - ) - .map_err(|error| return error.to_string()); -} - pub(crate) fn execution_cluster_code( cluster: ks_lib::ExApiExecutionCluster, ) -> std::string::String { @@ -394,25 +383,6 @@ pub(crate) fn confirmation_status_code( }; } -/// Verifies or initializes the configured store selected by one Devnet profile. -pub(crate) async fn demo_execution_devnet_prepare_profile( - state: tauri::State<'_, crate::AppState>, - profile_name: std::string::String, -) -> std::result::Result { - let profile = match crate::select_devnet_profile(state.app_config(), profile_name.as_str()) { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error), - }; - let readiness = - match ks_pipeline_demo_scenarios::prepare_demo_devnet_profile_store(&profile).await { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => { - return std::result::Result::Err(error.to_string()); - }, - }; - return std::result::Result::Ok(crate::devnet_store_readiness_payload(readiness)); -} - /// Returns compatible Devnet profiles and conservative defaults. pub(crate) fn demo_execution_solana_core_options( state: tauri::State<'_, crate::AppState>, @@ -473,7 +443,7 @@ pub(crate) async fn demo_execution_solana_core_execute( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; @@ -557,7 +527,7 @@ pub(crate) async fn demo_execution_spl_memo_execute( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; diff --git a/kb-app-demo-desktop/src/demo_spl_ata.rs b/kb-app-demo-desktop/src/demo_spl_ata.rs index 0634a89..9b3d946 100644 --- a/kb-app-demo-desktop/src/demo_spl_ata.rs +++ b/kb-app-demo-desktop/src/demo_spl_ata.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_spl_ata.rs -// version: 19 +// version: 21 //! Thin Tauri adapter for ATA execution, derivation and lifecycle journal reads. @@ -129,8 +129,24 @@ pub(crate) struct DemoSplAtaJournalRequest { pub(crate) associated_token_account: std::option::Option, /// Optional exact materialized operation. pub(crate) operation: std::option::Option, - /// Maximum returned rows. - pub(crate) limit: u32, + /// Opaque cursor identifying the requested Store block. + pub(crate) cursor: std::option::Option, + /// One-based Store block index maintained by the desktop navigation. + pub(crate) block_index: u32, +} + +/// One counted Store block of ATA lifecycle journal rows. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_spl_ata/DemoSplAtaJournalPagePayload.ts" +)] +pub(crate) struct DemoSplAtaJournalPagePayload { + /// Shared block metadata refreshed with this load. + pub(crate) page: crate::DemoStoreBlockMetadata, + /// Typed rows retained from the current Store block. + pub(crate) rows: std::vec::Vec, } /// One UI-safe ATA lifecycle journal row. @@ -260,7 +276,7 @@ pub(crate) async fn demo_execution_spl_ata_execute( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; @@ -303,46 +319,54 @@ pub(crate) async fn demo_execution_spl_ata_execute( pub(crate) async fn demo_spl_ata_journal( state: tauri::State<'_, crate::AppState>, request: crate::DemoSplAtaJournalRequest, -) -> std::result::Result, std::string::String> { - if request.limit == 0 || request.limit > 500 { - return std::result::Result::Err("ATA journal limit must be between 1 and 500".to_string()); - } +) -> std::result::Result { let profile = match crate::select_devnet_profile(state.app_config(), request.profile_name.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let filter = match ks_store::MaterializedOutputFilter::new( + let mut payload_match_groups = std::vec::Vec::new(); + if let std::option::Option::Some(operation) = request.operation.as_ref() { + payload_match_groups.push(vec![serde_json::json!({"operation": operation})]); + } + if let std::option::Option::Some(mint) = request.mint.as_ref() { + payload_match_groups.push(vec![serde_json::json!({"mint": mint})]); + } + if let std::option::Option::Some(associated_token_account) = + request.associated_token_account.as_ref() + { + payload_match_groups + .push(vec![serde_json::json!({"associatedTokenAccount": associated_token_account})]); + } + let filter = ks_store::MaterializedOutputPageFilter::new( std::option::Option::Some("materializer.token.accounts".to_string()), std::option::Option::None, + std::option::Option::Some("spl.associated_token_account".to_string()), request.signature_contains.clone(), - 500, - ) { + payload_match_groups, + ); + let page_request_result = crate::demo_store_block_request(request.cursor.clone()); + let page_request = match page_request_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let page_result = store.materialized_output_page(&filter, &page_request).await; + let page = match page_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let rows = match ks_store::DecodePipelineStore::list_materialized_outputs(&store, &filter).await - { + let metadata_result = + crate::demo_store_block_metadata(page.total_rows, page.next_cursor, request.block_index); + let metadata = match metadata_result { std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let mut output = std::vec::Vec::new(); - for row in rows { - if row.source_decoder_name != "spl.associated_token_account" - || !journal_matches(&row.payload_json, &request) - { - continue; - } - output.push(journal_row(row)); - if output.len() >= request.limit as usize { - break; - } - } - return std::result::Result::Ok(output); + let rows = page.rows.into_iter().map(journal_row).collect(); + return std::result::Result::Ok(crate::DemoSplAtaJournalPagePayload { page: metadata, rows }); } async fn load_profile_wallet( @@ -544,6 +568,7 @@ fn summary_payload( }; } +#[cfg(test)] fn journal_matches(payload: &serde_json::Value, request: &crate::DemoSplAtaJournalRequest) -> bool { for (expected, key) in [ (&request.operation, "operation"), @@ -605,7 +630,8 @@ mod tests { mint: std::option::Option::Some("mint111".to_string()), associated_token_account: std::option::Option::Some("ata111".to_string()), operation: std::option::Option::Some("create_idempotent".to_string()), - limit: 100, + cursor: std::option::Option::None, + block_index: 1, }; assert!(super::journal_matches( &serde_json::json!({ diff --git a/kb-app-demo-desktop/src/demo_spl_token.rs b/kb-app-demo-desktop/src/demo_spl_token.rs index 578ecc9..5f6b223 100644 --- a/kb-app-demo-desktop/src/demo_spl_token.rs +++ b/kb-app-demo-desktop/src/demo_spl_token.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_spl_token.rs -// version: 17 +// version: 19 //! Thin Tauri adapter for classic SPL Token execution and materialized journals. @@ -111,8 +111,24 @@ pub(crate) struct DemoSplTokenJournalRequest { pub(crate) family: std::option::Option, /// Optional exact operation code without the `spl.token.` prefix. pub(crate) operation: std::option::Option, - /// Maximum number of rows returned after typed filtering. - pub(crate) limit: u32, + /// Opaque cursor identifying the requested Store block. + pub(crate) cursor: std::option::Option, + /// One-based Store block index maintained by the desktop navigation. + pub(crate) block_index: u32, +} + +/// One counted Store block of classic SPL Token journal rows. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_spl_token/DemoSplTokenJournalPagePayload.ts" +)] +pub(crate) struct DemoSplTokenJournalPagePayload { + /// Shared block metadata refreshed with this load. + pub(crate) page: crate::DemoStoreBlockMetadata, + /// Typed rows retained from the current Store block. + pub(crate) rows: std::vec::Vec, } /// UI-safe materialized classic SPL Token journal row. @@ -183,7 +199,7 @@ pub(crate) async fn demo_execution_spl_token_execute( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; @@ -246,7 +262,7 @@ pub(crate) async fn demo_execution_spl_token_execute( pub(crate) async fn demo_spl_token_journal( state: tauri::State<'_, crate::AppState>, request: crate::DemoSplTokenJournalRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { let validated = match validate_journal_request(request) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), @@ -256,43 +272,48 @@ pub(crate) async fn demo_spl_token_journal( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let needs_payload_filter = - validated.mint.is_some() || validated.account.is_some() || validated.operation.is_some(); - let query_limit = if needs_payload_filter { - ks_store::MAX_MATERIALIZED_OUTPUT_QUERY_ROWS - } else { - validated.limit - }; - let filter = match ks_store::MaterializedOutputFilter::new( + let mut payload_match_groups = std::vec::Vec::new(); + if let std::option::Option::Some(operation) = validated.operation.as_ref() { + payload_match_groups.push(vec![serde_json::json!({"operation": operation})]); + } + if let std::option::Option::Some(mint) = validated.mint.as_ref() { + payload_match_groups.push(vec![ + serde_json::json!({"mint": mint}), + serde_json::json!({"accounts": [{"role": "mint", "accountKey": mint}]}), + ]); + } + if let std::option::Option::Some(account) = validated.account.as_ref() { + payload_match_groups.push(vec![serde_json::json!({"accounts": [{"accountKey": account}]})]); + } + let filter = ks_store::MaterializedOutputPageFilter::new( std::option::Option::None, validated.family.clone(), + std::option::Option::Some("spl.token".to_string()), validated.signature_contains.clone(), - query_limit, - ) { + payload_match_groups, + ); + let page_request_result = crate::demo_store_block_request(validated.cursor.clone()); + let page_request = match page_request_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let page_result = store.materialized_output_page(&filter, &page_request).await; + let page = match page_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let rows = match ks_store::DecodePipelineStore::list_materialized_outputs(&store, &filter).await - { + let metadata_result = + crate::demo_store_block_metadata(page.total_rows, page.next_cursor, validated.block_index); + let metadata = match metadata_result { std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let mut output = std::vec::Vec::new(); - for row in rows { - if row.source_decoder_name != "spl.token" || !payload_matches(&row.payload_json, &validated) - { - continue; - } - output.push(journal_row(row)); - if output.len() >= validated.limit as usize { - break; - } - } - return std::result::Result::Ok(output); + let rows = page.rows.into_iter().map(journal_row).collect(); + return std::result::Result::Ok(crate::DemoSplTokenJournalPagePayload { page: metadata, rows }); } fn summary_payload( @@ -412,12 +433,6 @@ fn replay_diagnostics(summary: &ks_pipeline::DecodeReplaySummary) -> serde_json: fn validate_journal_request( request: crate::DemoSplTokenJournalRequest, ) -> std::result::Result { - if request.limit == 0 || request.limit > ks_store::MAX_MATERIALIZED_OUTPUT_QUERY_ROWS { - return std::result::Result::Err(format!( - "journal limit must be between 1 and {}", - ks_store::MAX_MATERIALIZED_OUTPUT_QUERY_ROWS - )); - } if request.profile_name.trim().is_empty() { return std::result::Result::Err("journal profile name must not be empty".to_string()); } @@ -456,7 +471,8 @@ fn validate_journal_request( account, family, operation, - limit: request.limit, + cursor: request.cursor, + block_index: request.block_index, }); } @@ -473,6 +489,7 @@ fn bounded_optional( return std::result::Result::Ok(trimmed); } +#[cfg(test)] fn payload_matches( payload: &serde_json::Value, request: &crate::DemoSplTokenJournalRequest, @@ -586,7 +603,8 @@ mod tests { account: std::option::Option::Some("source111".to_string()), family: std::option::Option::Some("token_account".to_string()), operation: std::option::Option::Some("transfer_checked".to_string()), - limit: 25, + cursor: std::option::Option::None, + block_index: 1, }; let payload = serde_json::json!({ "operation": "transfer_checked", @@ -622,7 +640,7 @@ mod tests { } #[test] - fn journal_rejects_unbounded_or_unknown_family_requests() { + fn journal_rejects_unknown_family_requests() { let request = crate::DemoSplTokenJournalRequest { profile_name: "local_devnet".to_string(), signature_contains: std::option::Option::None, @@ -630,7 +648,8 @@ mod tests { account: std::option::Option::None, family: std::option::Option::Some("fee".to_string()), operation: std::option::Option::None, - limit: 501, + cursor: std::option::Option::None, + block_index: 1, }; assert!(super::validate_journal_request(request).is_err()); } diff --git a/kb-app-demo-desktop/src/demo_spl_token_2022.rs b/kb-app-demo-desktop/src/demo_spl_token_2022.rs index c383322..ecaf8ca 100644 --- a/kb-app-demo-desktop/src/demo_spl_token_2022.rs +++ b/kb-app-demo-desktop/src/demo_spl_token_2022.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_spl_token_2022.rs -// version: 20 +// version: 21 //! Thin Tauri adapter for independent public Token-2022 Devnet validation scenarios. @@ -159,7 +159,7 @@ pub(crate) async fn demo_execution_spl_token_2022_execute( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let store = match crate::open_store(&profile).await { + let store = match crate::shared_store_for_profile(state.inner(), &profile).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; diff --git a/kb-app-demo-desktop/src/demo_store_common.rs b/kb-app-demo-desktop/src/demo_store_common.rs index e4a85d8..7d91dcf 100644 --- a/kb-app-demo-desktop/src/demo_store_common.rs +++ b/kb-app-demo-desktop/src/demo_store_common.rs @@ -1,10 +1,78 @@ // file: kb-app-demo-desktop/src/demo_store_common.rs -// version: 19 +// version: 21 //! Shared backend-agnostic store demo helpers and serializable payloads. use ts_rs::TS; // rust-rules: derive-import +/// Pagination metadata shared by desktop Store block listings. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_store/DemoStoreBlockMetadata.ts" +)] +pub(crate) struct DemoStoreBlockMetadata { + /// Fixed number of backend rows requested per Store block. + pub(crate) block_size: u16, + /// One-based block index, or zero when the filtered result set is empty. + pub(crate) block_index: u32, + /// Total matching rows rendered losslessly for the WebView. + pub(crate) total_rows: std::string::String, + /// Total number of fixed Store blocks. + pub(crate) total_blocks: u32, + /// Opaque cursor for the next Store block when one exists. + pub(crate) next_cursor: std::option::Option, +} + +/// Fixed backend block size used by desktop Store listings. +pub(crate) const DEMO_STORE_BLOCK_SIZE: u16 = ks_store::MAX_PAGE_SIZE; + +/// Builds one fixed-size Store block request. +pub(crate) fn demo_store_block_request( + cursor: std::option::Option, +) -> std::result::Result { + let request_result = ks_store::PageRequest::new(crate::DEMO_STORE_BLOCK_SIZE, cursor); + return match request_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }; +} + +/// Calculates refreshed desktop block metadata from one counted Store page. +pub(crate) fn demo_store_block_metadata( + total_rows: u64, + next_cursor: std::option::Option, + requested_block_index: u32, +) -> std::result::Result { + if requested_block_index == 0 { + return std::result::Result::Err("Store block index must be greater than zero".to_string()); + } + let block_size = u64::from(crate::DEMO_STORE_BLOCK_SIZE); + let total_blocks_u64 = if total_rows == 0 { 0 } else { ((total_rows - 1) / block_size) + 1 }; + let total_blocks_result = u32::try_from(total_blocks_u64); + let total_blocks = match total_blocks_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_error) => { + return std::result::Result::Err( + "Store block count exceeds desktop bounds".to_string(), + ); + }, + }; + if total_blocks > 0 && requested_block_index > total_blocks { + return std::result::Result::Err(format!( + "Store block {requested_block_index} no longer exists; reload the first block" + )); + } + return std::result::Result::Ok(crate::DemoStoreBlockMetadata { + block_size: crate::DEMO_STORE_BLOCK_SIZE, + block_index: if total_rows == 0 { 0 } else { requested_block_index }, + total_rows: total_rows.to_string(), + total_blocks, + next_cursor, + }); +} + /// UI-safe diagnostics for one logical store resource. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] @@ -34,33 +102,6 @@ pub(crate) struct DemoStoreResourceSnapshot { pub(crate) latest_created_at: std::option::Option, } -/// Builds backend-agnostic store-open options from one resolved profile. -fn store_open_options_from_profile( - profile: &ks_config::ProfileConfig, -) -> ks_core::Result { - return ks_store::StoreOpenOptions::new( - profile.database.enabled, - profile.database.backend.clone(), - profile.database.backend_options.clone(), - ); -} - -/// Opens a backend-agnostic store from one resolved profile. -/// -/// Prefer [`crate::AppState::store`] for desktop commands that already own application state. -pub(crate) async fn open_store( - profile: &ks_config::ProfileConfig, -) -> std::result::Result { - let options = match store_open_options_from_profile(profile) { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), - }; - return match ks_store::Store::open(options).await { - std::result::Result::Ok(store) => std::result::Result::Ok(store), - std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), - }; -} - /// Clones the persistent store held by application state. pub(crate) async fn shared_store( state: &crate::AppState, @@ -72,6 +113,18 @@ pub(crate) async fn shared_store( }; } +/// Clones the shared persistent store held for one configured profile. +pub(crate) async fn shared_store_for_profile( + state: &crate::AppState, + profile: &ks_config::ProfileConfig, +) -> std::result::Result { + let store_result = state.store_for_profile(profile).await; + return match store_result { + std::result::Result::Ok(store) => std::result::Result::Ok(store), + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }; +} + /// Converts one logical store resource diagnostic to the UI payload shape. pub(crate) fn store_resource_snapshot( value: &ks_store::StoreResourceDiagnostics, @@ -262,3 +315,22 @@ fn emit_store_startup_splash( std::option::Option::None, ); } + +#[cfg(test)] +mod tests { + #[test] + fn fixed_store_block_metadata_rounds_up_and_keeps_empty_zero() { + let first_result = crate::demo_store_block_metadata( + 1749, + std::option::Option::Some("next".to_string()), + 1, + ); + let first = match first_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected block metadata error: {error}"), + }; + assert_eq!(first.total_blocks, 4); + assert_eq!(first.total_rows, "1749"); + assert_eq!(first.block_size, 500); + } +} diff --git a/kb-app-demo-desktop/src/demo_store_replay_candidates.rs b/kb-app-demo-desktop/src/demo_store_replay_candidates.rs index f79750c..f7e9426 100644 --- a/kb-app-demo-desktop/src/demo_store_replay_candidates.rs +++ b/kb-app-demo-desktop/src/demo_store_replay_candidates.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_store_replay_candidates.rs -// version: 16 +// version: 17 //! Backend-agnostic read-only replay candidate browser commands. @@ -31,8 +31,8 @@ pub(crate) struct DemoStoreReplayOptionsPayload { pub(crate) active_profile_name: std::string::String, /// Safe backend connection descriptor. pub(crate) connection_descriptor: std::string::String, - /// Maximum rows accepted by one query. - pub(crate) maximum_limit: u32, + /// Fixed Store block size used by every replay-candidate listing. + pub(crate) block_size: u16, /// Program identifiers enumerable from `ks_program_ids`. pub(crate) known_programs: std::vec::Vec, } @@ -65,8 +65,10 @@ pub(crate) struct DemoStoreReplayTransactionRequest { pub(crate) entity_kind: std::option::Option, /// Optional exact entity value. pub(crate) entity_value: std::option::Option, - /// Maximum returned rows. - pub(crate) limit: u32, + /// Opaque cursor identifying the requested Store block. + pub(crate) cursor: std::option::Option, + /// One-based Store block index maintained by the desktop navigation. + pub(crate) block_index: u32, /// Orders newest slots first when true. pub(crate) newest_first: bool, } @@ -114,6 +116,20 @@ pub(crate) struct DemoStoreReplayTransactionRow { pub(crate) updated_at: std::string::String, } +/// One counted Store block of transaction replay candidates. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayTransactionPagePayload.ts" +)] +pub(crate) struct DemoStoreReplayTransactionPagePayload { + /// Shared block metadata refreshed with this load. + pub(crate) page: crate::DemoStoreBlockMetadata, + /// Rows in the current Store block. + pub(crate) rows: std::vec::Vec, +} + /// UI request for bounded program summaries. #[derive(Clone, Debug, serde::Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -124,8 +140,10 @@ pub(crate) struct DemoStoreReplayTransactionRow { pub(crate) struct DemoStoreReplayProgramRequest { /// Optional partial program id search. pub(crate) program_id_contains: std::option::Option, - /// Maximum returned rows. - pub(crate) limit: u32, + /// Opaque cursor identifying the requested Store block. + pub(crate) cursor: std::option::Option, + /// One-based Store block index maintained by the desktop navigation. + pub(crate) block_index: u32, } /// Aggregated program row shown by the replay candidate browser. @@ -160,6 +178,20 @@ pub(crate) struct DemoStoreReplayProgramRow { pub(crate) max_slot: i64, } +/// One counted Store block of program summaries. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayProgramPagePayload.ts" +)] +pub(crate) struct DemoStoreReplayProgramPagePayload { + /// Shared block metadata refreshed with this load. + pub(crate) page: crate::DemoStoreBlockMetadata, + /// Rows in the current Store block. + pub(crate) rows: std::vec::Vec, +} + /// UI request for bounded core entity summaries. #[derive(Clone, Debug, serde::Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -172,8 +204,10 @@ pub(crate) struct DemoStoreReplayEntityRequest { pub(crate) entity_kind: std::string::String, /// Optional partial entity value search. pub(crate) entity_value_contains: std::option::Option, - /// Maximum returned rows. - pub(crate) limit: u32, + /// Opaque cursor identifying the requested Store block. + pub(crate) cursor: std::option::Option, + /// One-based Store block index maintained by the desktop navigation. + pub(crate) block_index: u32, } /// Aggregated core entity row shown by the replay candidate browser. @@ -202,6 +236,20 @@ pub(crate) struct DemoStoreReplayEntityRow { pub(crate) max_slot: i64, } +/// One counted Store block of mint, owner or account-key summaries. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayEntityPagePayload.ts" +)] +pub(crate) struct DemoStoreReplayEntityPagePayload { + /// Shared block metadata refreshed with this load. + pub(crate) page: crate::DemoStoreBlockMetadata, + /// Rows in the current Store block. + pub(crate) rows: std::vec::Vec, +} + /// Writes a bounded replay-candidate CSV file into `./data/exports_csv/`. pub(crate) async fn export_demo_store_replay_csv( file_name: std::string::String, @@ -264,7 +312,7 @@ pub(crate) async fn demo_store_replay_options( return std::result::Result::Ok(crate::DemoStoreReplayOptionsPayload { active_profile_name: profile.name, connection_descriptor, - maximum_limit: ks_store::MAX_REPLAY_CANDIDATE_ROWS, + block_size: crate::DEMO_STORE_BLOCK_SIZE, known_programs, }); } @@ -273,7 +321,7 @@ pub(crate) async fn demo_store_replay_options( pub(crate) async fn load_demo_store_replay_transactions( state: tauri::State<'_, crate::AppState>, request: crate::DemoStoreReplayTransactionRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { let program_scope_result = program_scope_from_code(request.program_scope.as_str()); let program_scope = match program_scope_result { std::result::Result::Ok(value) => value, @@ -294,95 +342,132 @@ pub(crate) async fn load_demo_store_replay_transactions( program_scope, entity_kind, request.entity_value, - request.limit, request.newest_first, ); let filter = match filter_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; + let page_request_result = crate::demo_store_block_request(request.cursor); + let page_request = match page_request_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let store_result = crate::shared_store(state.inner()).await; let store = match store_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let rows_result = store.replay_transaction_candidates(&filter).await; - let rows = match rows_result { + let page_result = store.replay_transaction_candidates(&filter, &page_request).await; + let page = match page_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), "loaded replay transaction candidates"); - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { - output.push(transaction_row_from_store(row)); + let metadata_result = + crate::demo_store_block_metadata(page.total_rows, page.next_cursor, request.block_index); + let metadata = match metadata_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut rows = std::vec::Vec::with_capacity(page.rows.len()); + for row in page.rows { + rows.push(transaction_row_from_store(row)); } - return std::result::Result::Ok(output); + tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), total_rows = %metadata.total_rows, block_index = metadata.block_index, total_blocks = metadata.total_blocks, "loaded replay transaction candidate block"); + return std::result::Result::Ok(crate::DemoStoreReplayTransactionPagePayload { + page: metadata, + rows, + }); } -/// Loads bounded program summaries from the active store. +/// Loads one counted Store block of program summaries. pub(crate) async fn load_demo_store_replay_programs( state: tauri::State<'_, crate::AppState>, request: crate::DemoStoreReplayProgramRequest, -) -> std::result::Result, std::string::String> { - let filter_result = - ks_store::ReplayProgramFilter::new(request.program_id_contains, request.limit); +) -> std::result::Result { + let filter_result = ks_store::ReplayProgramFilter::new(request.program_id_contains); let filter = match filter_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; + let page_request_result = crate::demo_store_block_request(request.cursor); + let page_request = match page_request_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let store_result = crate::shared_store(state.inner()).await; let store = match store_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let rows_result = store.replay_program_summaries(&filter).await; - let rows = match rows_result { + let page_result = store.replay_program_summaries(&filter, &page_request).await; + let page = match page_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), "loaded replay program summaries"); - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { - output.push(program_row_from_store(row)); + let metadata_result = + crate::demo_store_block_metadata(page.total_rows, page.next_cursor, request.block_index); + let metadata = match metadata_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut rows = std::vec::Vec::with_capacity(page.rows.len()); + for row in page.rows { + rows.push(program_row_from_store(row)); } - return std::result::Result::Ok(output); + tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), total_rows = %metadata.total_rows, block_index = metadata.block_index, total_blocks = metadata.total_blocks, "loaded replay program summary block"); + return std::result::Result::Ok(crate::DemoStoreReplayProgramPagePayload { + page: metadata, + rows, + }); } -/// Loads bounded mint, owner or account-key summaries from Core facts. +/// Loads one counted Store block of mint, owner or account-key summaries. pub(crate) async fn load_demo_store_replay_entities( state: tauri::State<'_, crate::AppState>, request: crate::DemoStoreReplayEntityRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { let entity_kind_result = entity_kind_from_code(request.entity_kind.as_str()); let entity_kind = match entity_kind_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let filter_result = ks_store::ReplayEntityFilter::new( - entity_kind, - request.entity_value_contains, - request.limit, - ); + let filter_result = + ks_store::ReplayEntityFilter::new(entity_kind, request.entity_value_contains); let filter = match filter_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; + let page_request_result = crate::demo_store_block_request(request.cursor); + let page_request = match page_request_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let store_result = crate::shared_store(state.inner()).await; let store = match store_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let rows_result = store.replay_entity_summaries(&filter).await; - let rows = match rows_result { + let page_result = store.replay_entity_summaries(&filter, &page_request).await; + let page = match page_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), "loaded replay entity summaries"); - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { - output.push(entity_row_from_store(row)); + let metadata_result = + crate::demo_store_block_metadata(page.total_rows, page.next_cursor, request.block_index); + let metadata = match metadata_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut rows = std::vec::Vec::with_capacity(page.rows.len()); + for row in page.rows { + rows.push(entity_row_from_store(row)); } - return std::result::Result::Ok(output); + tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), total_rows = %metadata.total_rows, block_index = metadata.block_index, total_blocks = metadata.total_blocks, entity_kind = request.entity_kind.as_str(), "loaded replay entity summary block"); + return std::result::Result::Ok(crate::DemoStoreReplayEntityPagePayload { + page: metadata, + rows, + }); } fn transaction_row_from_store( diff --git a/kb-app-demo-desktop/src/lib.rs b/kb-app-demo-desktop/src/lib.rs index 3da748f..a62d8f2 100644 --- a/kb-app-demo-desktop/src/lib.rs +++ b/kb-app-demo-desktop/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/lib.rs -// version: 47 +// version: 50 //! Tauri desktop demo application for `khadhroony-bot3`. @@ -133,6 +133,8 @@ pub(crate) use self::demo_decode_replay::DemoDecodeReplayRequest; pub(crate) use self::demo_decode_replay::DemoDecodeReplayRunGuard; /// Internal demo decode replay item. pub(crate) use self::demo_decode_replay::DemoDecodeReplaySummaryPayload; +/// Counted Store block of transaction annotations. +pub(crate) use self::demo_decode_replay::DemoTransactionAnnotationPagePayload; /// Internal demo decode replay item. pub(crate) use self::demo_decode_replay::DemoTransactionAnnotationRequest; /// Internal demo decode replay item. @@ -151,16 +153,18 @@ pub(crate) use self::demo_decode_replay::demo_decode_replay_options; pub(crate) use self::demo_devnet_common::DemoExecutionDevnetStoreReadinessPayload; /// Restores the shared single-execution state flag when a Devnet run finishes. pub(crate) use self::demo_devnet_common::DemoExecutionRunGuard; +/// Prepares the Store selected by one compatible Devnet profile. +pub(crate) use self::demo_devnet_common::demo_execution_devnet_prepare_profile; /// Loads the backend-only demo wallet password. pub(crate) use self::demo_devnet_common::demo_wallet_password; /// Returns whether the backend-only demo wallet password is configured. pub(crate) use self::demo_devnet_common::demo_wallet_password_configured; -/// Converts the reusable scenario readiness report to the desktop TS-RS payload. -pub(crate) use self::demo_devnet_common::devnet_store_readiness_payload; /// Serializes a diagnostic value to indented JSON without panicking. pub(crate) use self::demo_devnet_common::pretty_json; /// Resolves the temporary or persistent signer selected by one Devnet profile. pub(crate) use self::demo_devnet_common::resolve_devnet_execution_wallet; +/// Selects and validates one Devnet profile by name. +pub(crate) use self::demo_devnet_common::select_devnet_profile; /// Bridges Metadata execution progress to the Metadata desktop window. pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataObserver; /// Progress event emitted by Metadata execution workflows. @@ -237,8 +241,6 @@ pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreRequest; pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreSummaryPayload; /// Maps a confirmation status to its stable UI code. pub(crate) use self::demo_execution_solana_core::confirmation_status_code; -/// Returns compatible Devnet profiles and conservative defaults. -pub(crate) use self::demo_execution_solana_core::demo_execution_devnet_prepare_profile; /// Requests cooperative cancellation before the next execution stage. pub(crate) use self::demo_execution_solana_core::demo_execution_solana_core_cancel; /// Executes one bounded System transfer simulation or Devnet submission. @@ -251,8 +253,6 @@ pub(crate) use self::demo_execution_solana_core::demo_execution_solana_core_opti pub(crate) use self::demo_execution_solana_core::demo_execution_spl_memo_execute; /// Maps an execution cluster to its stable UI code. pub(crate) use self::demo_execution_solana_core::execution_cluster_code; -/// Selects and validates one Devnet profile by name. -pub(crate) use self::demo_execution_solana_core::select_devnet_profile; /// Frontend payload for one independent Devnet SPL validation scenario. pub(crate) use self::demo_execution_spl::DevnetSplValidationScenarioPayload; /// Returns the complete ordered Devnet SPL validation inventory for milestone 0.4.6. @@ -283,6 +283,8 @@ pub(crate) use self::demo_spl_ata::DemoExecutionSplAtaSummaryPayload; pub(crate) use self::demo_spl_ata::DemoSplAtaDerivationPayload; /// Request used to derive the representative wallet ATA before execution. pub(crate) use self::demo_spl_ata::DemoSplAtaDerivationRequest; +/// One counted Store block of ATA lifecycle journal rows. +pub(crate) use self::demo_spl_ata::DemoSplAtaJournalPagePayload; /// Bounded ATA lifecycle journal request. pub(crate) use self::demo_spl_ata::DemoSplAtaJournalRequest; /// One UI-safe ATA lifecycle journal row. @@ -297,6 +299,8 @@ pub(crate) use self::demo_spl_ata::demo_spl_ata_journal; pub(crate) use self::demo_spl_token::DemoExecutionSplTokenRequest; /// UI-safe result of one checked SPL Token transfer orchestration. pub(crate) use self::demo_spl_token::DemoExecutionSplTokenSummaryPayload; +/// One counted Store block of classic SPL Token journal rows. +pub(crate) use self::demo_spl_token::DemoSplTokenJournalPagePayload; /// Bounded exact filters for the classic SPL Token materialized journal. pub(crate) use self::demo_spl_token::DemoSplTokenJournalRequest; /// UI-safe materialized classic SPL Token journal row. @@ -315,16 +319,24 @@ pub(crate) use self::demo_spl_token_2022::DemoSplToken2022FixturePayload; pub(crate) use self::demo_spl_token_2022::demo_execution_spl_token_2022_execute; /// Loads public Token-2022 fixture values without reading private key bytes. pub(crate) use self::demo_spl_token_2022::demo_spl_token_2022_fixture; +/// Fixed Store block size used by desktop listings. +pub(crate) use self::demo_store_common::DEMO_STORE_BLOCK_SIZE; +/// UI-safe Store block pagination metadata. +pub(crate) use self::demo_store_common::DemoStoreBlockMetadata; /// UI-safe logical store resource diagnostic snapshot. pub(crate) use self::demo_store_common::DemoStoreResourceSnapshot; /// Formats diagnostic statuses. pub(crate) use self::demo_store_common::debug_status; +/// Calculates refreshed Store block metadata for desktop listings. +pub(crate) use self::demo_store_common::demo_store_block_metadata; +/// Builds one fixed-size Store block request for desktop listings. +pub(crate) use self::demo_store_common::demo_store_block_request; /// Opens and verifies the configured store during startup. pub(crate) use self::demo_store_common::initialize_store_for_startup; -/// Opens the configured backend-agnostic store. -pub(crate) use self::demo_store_common::open_store; /// Clones the persistent application store. pub(crate) use self::demo_store_common::shared_store; +/// Clones the shared persistent store held for one configured profile. +pub(crate) use self::demo_store_common::shared_store_for_profile; /// Returns the safe connection descriptor exposed by the active store backend. pub(crate) use self::demo_store_common::store_connection_descriptor; /// Converts one logical store resource diagnostic. @@ -343,6 +355,8 @@ pub(crate) use self::demo_store_diag::load_demo_store_diag; pub(crate) use self::demo_store_raw::DemoStoreRawPayload; /// Loads read-only raw resource diagnostics from the active store. pub(crate) use self::demo_store_raw::load_demo_store_raw; +/// Store replay entity page payload. +pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayEntityPagePayload; /// Store replay entity request. pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayEntityRequest; /// Store replay entity row. @@ -351,10 +365,14 @@ pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayEntityRow; pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayKnownProgramOption; /// Store replay options payload. pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayOptionsPayload; +/// Store replay program page payload. +pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayProgramPagePayload; /// Store replay program request. pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayProgramRequest; /// Store replay program row. pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayProgramRow; +/// Store replay transaction page payload. +pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayTransactionPagePayload; /// Store replay transaction request. pub(crate) use self::demo_store_replay_candidates::DemoStoreReplayTransactionRequest; /// Store replay transaction row. diff --git a/kb-app-demo-desktop/src/tauri.rs b/kb-app-demo-desktop/src/tauri.rs index e30c02d..dd8f5c8 100644 --- a/kb-app-demo-desktop/src/tauri.rs +++ b/kb-app-demo-desktop/src/tauri.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/tauri.rs -// version: 46 +// version: 47 //! Tauri runtime assembly and private command wrappers. @@ -513,7 +513,7 @@ async fn demo_decode_replay_diagnostics( async fn demo_decode_replay_annotations( state: tauri::State<'_, crate::AppState>, request: crate::DemoTransactionAnnotationRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { return crate::demo_decode_replay_annotations(state, request).await; } @@ -605,7 +605,7 @@ async fn demo_store_replay_options( async fn load_demo_store_replay_transactions( state: tauri::State<'_, crate::AppState>, request: crate::DemoStoreReplayTransactionRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { return crate::load_demo_store_replay_transactions(state, request).await; } @@ -613,7 +613,7 @@ async fn load_demo_store_replay_transactions( async fn load_demo_store_replay_programs( state: tauri::State<'_, crate::AppState>, request: crate::DemoStoreReplayProgramRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { return crate::load_demo_store_replay_programs(state, request).await; } @@ -621,7 +621,7 @@ async fn load_demo_store_replay_programs( async fn load_demo_store_replay_entities( state: tauri::State<'_, crate::AppState>, request: crate::DemoStoreReplayEntityRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { return crate::load_demo_store_replay_entities(state, request).await; } @@ -857,7 +857,7 @@ async fn demo_execution_spl_token_execute( async fn demo_spl_token_journal( state: tauri::State<'_, crate::AppState>, request: crate::DemoSplTokenJournalRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { return crate::demo_spl_token_journal(state, request).await; } @@ -882,7 +882,7 @@ async fn demo_execution_spl_ata_execute( async fn demo_spl_ata_journal( state: tauri::State<'_, crate::AppState>, request: crate::DemoSplAtaJournalRequest, -) -> std::result::Result, std::string::String> { +) -> std::result::Result { return crate::demo_spl_ata_journal(state, request).await; } diff --git a/ks-pipeline-demo-scenarios/CHANGELOG.md b/ks-pipeline-demo-scenarios/CHANGELOG.md index 5642597..97b5234 100644 --- a/ks-pipeline-demo-scenarios/CHANGELOG.md +++ b/ks-pipeline-demo-scenarios/CHANGELOG.md @@ -1,8 +1,12 @@ - + # CHANGELOG — ks-pipeline-demo-scenarios +## `0.5.3-pre.005` + +- renomme l’environnement de validation Token-2022 `Postgres` en `Store` et la valeur de matrice `postgres` en `store`, afin que la validation de persistance/replay ne nomme plus le backend concret. + ## `0.5.3-pre.003` - adapte les scénarios réutilisables au renommage N3 `MaterializedOutput*` / `list_materialized_outputs` sans modifier les campagnes Devnet ni leurs critères de succès ; diff --git a/ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs b/ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs index 7efd1d7..6270e2a 100644 --- a/ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs +++ b/ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs @@ -1,5 +1,5 @@ // file: ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs -// version: 8 +// version: 9 //! Machine-readable validation evidence contract for the Token-2022 milestone. @@ -20,8 +20,8 @@ pub enum Token2022ValidationEnvironment { LocalnetOrDevnet, /// Mainnet observations without mutable execution. MainnetObservation, - /// PostgreSQL persistence and replay validation. - Postgres, + /// Active Store persistence and replay validation. + Store, /// Tauri application smoke validation. Tauri, } diff --git a/ks-store/CHANGELOG.md b/ks-store/CHANGELOG.md index a868090..e018e76 100644 --- a/ks-store/CHANGELOG.md +++ b/ks-store/CHANGELOG.md @@ -1,8 +1,26 @@ - + # CHANGELOG — ks-store +## `0.5.3-pre.005` + +- supprime l’ancien chemin complet `apply_store_schema` / `initialize_store_schema`, devenu mort après l’introduction de l’auto-initialisation additive, et fait utiliser aux tests PostgreSQL le même chemin additif que la production afin de conserver `cargo check` et `cargo clippy` sans warnings ; +- corrige l’exécution SQLx 0.9 des statements DDL additifs dynamiques en marquant explicitement le SQL généré par `ks-store` avec `sqlx::AssertSqlSafe`, sans modifier la logique d’auto-initialisation additive ; +- aligne l’ouverture PostgreSQL sur un contrat de compatibilité additive : une ressource gérée existante mais incompatible (colonne, clé primaire/étrangère ou index) provoque une erreur, tandis qu’une ressource simplement absente est classée comme dérive additive ; +- fait de `auto_initialize_schema` un mécanisme d’extension non destructive : `false` refuse toute ressource gérée manquante, `true` peut créer une table absente, ajouter une colonne absente et recréer les clés/index absents avant validation complète ; aucune colonne existante n’est supprimée, renommée ou modifiée automatiquement ; +- étend `schema_compatibility_verify` au contrat courant de **16 tables, 248 colonnes, 25 clés PK/FK et 79 index physiques**, avec comparaison de la table cible, des colonnes de clé, des actions FK, de l’unicité, de la méthode B-tree, des expressions/ordres et des prédicats d’index ; +- remplace le blocage global des objets historiques `kb_sol_*` par une validation de compatibilité limitée aux ressources réellement possédées par `ks-store` ; les tables étrangères ou historiques peuvent désormais coexister sans être interprétées ; +- dérive le contrat de **248 colonnes gérées sur 16 tables** directement des ressources `CREATE TABLE` embarquées et vérifie le type PostgreSQL, la nullabilité, la précision/échelle `NUMERIC` et la présence des défauts nécessaires aux écritures courantes ; +- accepte les colonnes additives compatibles mais refuse une colonne additive `NOT NULL` sans défaut sur une table gérée, car les `INSERT` courants pourraient l'omettre ; +- ajoute le diagnostic `schema_compatibility_verify` et un test PostgreSQL opt-in dans `ks-store`; les consommateurs desktop/pipeline restent sans logique d'introspection PostgreSQL ; +- remplace les listes Replay Candidates à limite embarquée par des pages `PageRequest` cursorisées et comptées via `CountedPageSlice` ; +- ajoute un comptage rafraîchi à chaque chargement transaction/program/entity afin que le desktop recalcule le nombre total de blocs sans `OFFSET` ; +- conserve `MAX_PAGE_SIZE = 500` comme borne repository générique ; la politique de bloc desktop fixe à 500 appartient à `kb-app-demo-desktop` et ne crée aucun contrat UI spécifique dans `ks-store` ; +- ajoute une pagination comptée générique de `k_sol_mat_outputs` avec curseur `(slot, id)` et groupes bornés de contraintes structurelles JSON, utilisés à l’identique par le `COUNT` et la page afin que les totaux reflètent exactement les filtres visibles sans introduire de connaissance SPL/protocole dans le Store ; +- ajoute des curseurs stables spécifiques aux ordres transaction `(slot, signature)`, programme `(transaction_count, program_id)` et entité `(transaction_count, entity_value)` ; +- refuse un curseur transaction réutilisé avec une direction d’ordre différente et maintient les requêtes Replay Candidates sans `OFFSET`. + ## `0.5.3-pre.004` ### Fix `pre.004-delta-fix-003` @@ -11,6 +29,7 @@ - limite désormais les vérifications de source à la partie production précédant `#[cfg(test)]` dans `account_state_queries.rs` et `invalidation_queries.rs` ; - ne modifie aucune requête PostgreSQL ni aucun comportement runtime. + ### Fix `pre.004-delta-fix-002` - ajoute les annotations explicites `std::string::String` sur `origin_text` et `status_text` dans le mapper PostgreSQL des observations de compte ; @@ -22,6 +41,7 @@ - remplace les 53 utilisations accidentelles de l'opérateur `?` introduites dans les nouveaux mappers PostgreSQL account-state/Core replay par une propagation explicite `match` conforme aux règles du workspace ; - ne modifie ni les contrats publics, ni le SQL, ni les règles de replay/pagination/invalidation de `pre.004`. + ### Repositories et replay - rend le replay N2 -> N3 symétrique pour les instructions top-level et CPI via une union logique `CoreInstructionScope::{TopLevel, Inner}` sans fusionner les deux tables physiques ; diff --git a/ks-store/README.md b/ks-store/README.md index 0c1c3e9..f8a4045 100644 --- a/ks-store/README.md +++ b/ks-store/README.md @@ -1,5 +1,5 @@ - + # ks-store @@ -38,7 +38,7 @@ Le baseline candidat au gel comprend **16 tables N1-N3** : - N2 : transaction Core, clés de comptes, instructions top-level, CPI, logs, variations de balances, return data et états canoniques de comptes ; - N3/transverse : processing ledger, événements décodés, déclarations/observations de couverture et `k_sol_mat_outputs`. -Les ressources PostgreSQL sont rangées sous `migrations/postgres/` et suivent la règle **une instruction SQL par fichier**. L'orchestrateur privé applique tables, contraintes et index dans une transaction unique et refuse explicitement un schéma historique contenant encore des objets `kb_sol_*`. +Les ressources PostgreSQL sont rangées sous `migrations/postgres/` et suivent la règle **une instruction SQL par fichier**. À l’ouverture, `ks-store` valide toute ressource gérée déjà présente : une colonne, clé primaire/étrangère ou index existant mais incompatible provoque une erreur. Avec `auto_initialize_schema=true`, les ressources gérées absentes peuvent être complétées de manière **strictement additive** : création d’une table absente, ajout d’une colonne absente, ajout d’une contrainte/clé absente et création d’un index absent. L’auto-initialisation ne supprime, ne renomme et ne modifie jamais une colonne existante. Les tables étrangères et d’anciens objets `kb_sol_*` peuvent coexister sans blocage tant qu’ils ne font pas partie du contrat actif. Le baseline conserve **16 tables** et comporte désormais **240 ressources SQL atomiques** et **79 index attendus** après la revue des chemins de replay `pre.004`, comptés sans exposer leurs noms dans l'API publique. diff --git a/ks-store/TODO.md b/ks-store/TODO.md index fb2a2cf..5db06de 100644 --- a/ks-store/TODO.md +++ b/ks-store/TODO.md @@ -1,5 +1,5 @@ - + # TODO — ks-store @@ -10,7 +10,7 @@ - [ ] `0.5.3` — confirmer les 79 index attendus par plans `EXPLAIN` sur dataset PostgreSQL représentatif avant gel ; les index non uniques restent une optimisation physique ajustable. - [ ] `0.5.3` — finaliser en `pre.005` la présentation fonctionnelle des surfaces `demo_store_*`, `demo_config` et le splash autour des modèles/compteurs du nouveau baseline sans noms d'objets physiques. - [ ] `0.5.3` — produire le snapshot machine-readable du baseline N1/N2/N3 réellement stabilisé, ajouter les canaris de gel/anti-`kb_sol_*`/anti-backend leak/anti-secret, confirmer le canari future-decoder sur transactions + comptes et documenter la politique N4 plus souple. -- [ ] `0.5.3` — valider PostgreSQL réel sur base vide/recréée : 16 tables, 79 index attendus, réouverture idempotente, refus de l'ancien namespace, rollback, roundtrips raw/Core/decode/materialization/account-state, CPI et invalidation descendante. +- [ ] `0.5.3` — valider PostgreSQL réel sur le schéma géré : 16 tables, 248 colonnes, 25 clés PK/FK et 79 index compatibles, auto-initialisation additive des ressources absentes sans suppression/modification de colonnes existantes, coexistence non bloquante de ressources étrangères/historiques, rollback, roundtrips raw/Core/decode/materialization/account-state, CPI et invalidation descendante. - [ ] `0.5.x` — concevoir les projections N4 metadata : asset/token metadata commune Metaplex + Token-2022 et program metadata séparée pour SPM, toutes deux reconstructibles depuis `k_sol_mat_outputs`. ## Plus tard diff --git a/ks-store/USAGE.md b/ks-store/USAGE.md index 915cac7..ba08795 100644 --- a/ks-store/USAGE.md +++ b/ks-store/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de ks-store @@ -27,13 +27,15 @@ let store = match ks_store::Store::open(options).await { }; ``` -`Store::open` interprète les options du backend actif, crée la connexion, exécute l'auto-initialisation lorsqu'elle est configurée et capture un résumé d'initialisation sanitisé. PostgreSQL reste le seul backend opérationnel exigé en `0.5.3`; un code backend inconnu est refusé proprement. +`Store::open` interprète les options du backend actif, crée la connexion, valide d'abord les définitions déjà présentes et n'exécute l'auto-initialisation que pour compléter des ressources gérées absentes lorsque l'option l'autorise. Il capture ensuite un résumé d'initialisation sanitisé. PostgreSQL reste le seul backend opérationnel exigé en `0.5.3`; un code backend inconnu est refusé proprement. Avec PostgreSQL, l'auto-initialisation utilise un **seul orchestrateur transactionnel global**. Il ne faut pas initialiser séparément raw/Core/decode : la cohérence du baseline est vérifiée comme un contrat unique. ## Baseline PostgreSQL actif -`0.5.3-pre.003` utilise directement le namespace `k_sol_*` et un baseline candidat au gel de 16 tables N1-N3. Une base contenant encore des objets `kb_sol_*` est refusée ; elle doit être supprimée/reconstruite explicitement. +`0.5.3-pre.003` utilise directement le namespace `k_sol_*` et un baseline candidat au gel de 16 tables N1-N3. `ks-store` ne possède que ses ressources `k_sol_*` courantes : les tables étrangères ou historiques, y compris `kb_sol_*`, sont ignorées tant qu'elles ne sont pas référencées par le contrat actif. + +`auto_initialize_schema` applique uniquement des évolutions additives. Si une table, colonne, PK/FK ou index géré manque, `false` provoque une erreur ; avec `true`, `ks-store` tente de créer uniquement les ressources absentes puis revalide le contrat complet. Une colonne existante avec un type, une nullabilité, une précision numérique ou un défaut incompatible provoque toujours une erreur : l'auto-initialisation n'exécute aucun `DROP COLUMN`, `ALTER COLUMN` ni renommage. Le même principe s'applique aux clés et index existants dont la définition ne correspond pas au contrat. Les ressources actives sont sous : @@ -117,11 +119,13 @@ Les instructions top-level et CPI restent deux tables physiques, mais la lecture ## Matérialisation N3 -Le journal générique est `k_sol_mat_outputs`. L'API publique utilise désormais : +Le journal générique est `k_sol_mat_outputs`. L'API publique conserve la lecture bornée de processing et ajoute une lecture de consultation comptée : -- `MaterializedOutputFilter` ; -- `MaterializedOutputQueryRow` ; -- `DecodePipelineStore::list_materialized_outputs`. +- `MaterializedOutputFilter` + `DecodePipelineStore::list_materialized_outputs` pour les workflows bornés existants ; +- `MaterializedOutputPageFilter` + `Store::materialized_output_page` pour les listings cursorisés ; +- `MaterializedOutputQueryRow` comme ligne générique commune. + +`MaterializedOutputPageFilter` peut transporter des groupes bornés de contraintes structurelles sur le payload JSON : tous les groupes doivent correspondre, mais une seule alternative par groupe suffit. Le backend PostgreSQL applique exactement ces mêmes contraintes au `COUNT` et à la page ; le Store reste donc générique et ne connaît ni mint, ni ATA, ni opération SPL. `k_sol_mat_outputs` reste obligatoire même lorsque des projections N4 spécialisées existeront. @@ -169,12 +173,14 @@ Les bundles `AccountStatePersistenceBundle`, `CoreExtractionBundle`, `DecodePers Les tests opt-in utilisent `KS_SECRET_POSTGRES_TEST_URL`. Aucun credential réel ne doit être enregistré dans les fixtures ou logs. Les erreurs d'ouverture de connexion sont volontairement sanitisées. -Pour `pre.004`, les validations PostgreSQL à exécuter après compilation sont particulièrement importantes : +Pour le baseline `0.5.3`, les validations PostgreSQL à exécuter après compilation sont particulièrement importantes : -- init sur base vide ; +- présence des 16 tables ; +- compatibilité des 248 colonnes actuellement gérées : type, nullabilité, précision numérique et défaut requis ; +- compatibilité des 25 clés primaires/étrangères attendues et des 79 index physiques attendus, pas seulement leur nombre ; +- acceptation des tables étrangères/historiques et des colonnes additives compatibles ; +- refus d'une dérive structurelle d'une ressource gérée qui casserait les lectures ou écritures courantes ; - réouverture idempotente ; -- présence des 16 tables et 79 index attendus ; -- refus d'un schéma historique `kb_sol_*` ; - roundtrip raw/Core/decode/materialization ; - roundtrip account observation -> account state, y compris nouvelle version de normalizer et force replay ; - replay top-level + CPI paginé sur plusieurs pages ; @@ -188,3 +194,9 @@ Pour `pre.004`, les validations PostgreSQL à exécuter après compilation sont - snapshot machine-readable et gel formel N1/N2/N3 dans la tranche de réconciliation technique ; - refonte desktop fonctionnelle finale autour du nouveau baseline en `pre.005` ; - projections N4 spécialisées, hors `0.5.3`. + +## Pagination des listings Store + +Les lectures interactives restent bornées à 500 lignes par bloc. Les filtres fonctionnels ne portent pas d’offset : le consumer transmet un `PageRequest { limit, cursor }` et reçoit une page stable. Les Replay Candidates et les listings cursorisés de matérialisations utilisent `CountedPageSlice` afin de retourner en plus `total_rows`; le desktop peut ainsi afficher `Bloc N / total` tout en conservant un curseur opaque. + +Le comptage est rafraîchi à chaque chargement de bloc. Un changement de filtre doit invalider l’historique de curseurs et repartir du premier bloc. La pagination DataTables du desktop est locale aux 500 lignes déjà chargées et ne modifie jamais la pagination repository. diff --git a/ks-store/src/contracts.rs b/ks-store/src/contracts.rs index 6bc264b..cc434f1 100644 --- a/ks-store/src/contracts.rs +++ b/ks-store/src/contracts.rs @@ -1,5 +1,5 @@ // file: ks-store/src/contracts.rs -// version: 8 +// version: 9 //! Backend-neutral storage contracts used by pipeline crates. @@ -91,6 +91,8 @@ pub use self::dto::MaterializationPersistenceBundle; pub use self::dto::MaterializedOutputFilter; /// One processor-owned materialized output row. pub use self::dto::MaterializedOutputInsert; +/// Cursor-paginated materialized output filter. +pub use self::dto::MaterializedOutputPageFilter; /// One materialized output returned by a bounded query. pub use self::dto::MaterializedOutputQueryRow; /// Stable processing ledger identity. @@ -163,6 +165,8 @@ pub use self::error::storage_contract_error; pub use self::health::StoreHealthSnapshot; /// Store backend health status. pub use self::health::StoreHealthStatus; +/// One bounded page with a refreshed total row count. +pub use self::pagination::CountedPageSlice; /// Default repository page size. pub use self::pagination::DEFAULT_PAGE_SIZE; /// Maximum encoded cursor size. @@ -175,8 +179,6 @@ pub use self::pagination::PageRequest; pub use self::pagination::PageSlice; /// Sort direction for repository list operations. pub use self::pagination::SortDirection; -/// Maximum number of rows returned by one replay candidate query. -pub use self::replay::MAX_REPLAY_CANDIDATE_ROWS; /// Bounded read-only filter for core entity summaries. pub use self::replay::ReplayEntityFilter; /// Core entity kind used while filtering replay candidates. diff --git a/ks-store/src/contracts/dto.rs b/ks-store/src/contracts/dto.rs index b39344b..c24f880 100644 --- a/ks-store/src/contracts/dto.rs +++ b/ks-store/src/contracts/dto.rs @@ -1,5 +1,5 @@ // file: ks-store/src/contracts/dto.rs -// version: 8 +// version: 9 //! Backend-neutral DTO exports for storage repository contracts. @@ -86,6 +86,8 @@ pub use self::decode::MaterializationPersistenceBundle; pub use self::decode::MaterializedOutputFilter; /// One processor-owned materialized output row. pub use self::decode::MaterializedOutputInsert; +/// Cursor-paginated materialized output filter. +pub use self::decode::MaterializedOutputPageFilter; /// One materialized output returned by a bounded query. pub use self::decode::MaterializedOutputQueryRow; /// Insert or upsert result contract returned by repositories. diff --git a/ks-store/src/contracts/dto/decode.rs b/ks-store/src/contracts/dto/decode.rs index c6441c6..d26f4f1 100644 --- a/ks-store/src/contracts/dto/decode.rs +++ b/ks-store/src/contracts/dto/decode.rs @@ -1,5 +1,5 @@ // file: ks-store/src/contracts/dto/decode.rs -// version: 7 +// version: 8 //! Backend-neutral decode, coverage and materialization persistence DTOs. @@ -48,6 +48,46 @@ impl crate::MaterializedOutputFilter { } } +/// Backend-neutral filter for cursor-paginated materialized output listings. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MaterializedOutputPageFilter { + /// Optional exact materializer processor name. + pub processor_name: std::option::Option, + /// Optional exact materialized family code. + pub materialized_family: std::option::Option, + /// Optional exact source decoder name. + pub source_decoder_name: std::option::Option, + /// Optional partial transaction signature. + pub signature_contains: std::option::Option, + /// Structural payload match groups. Every group must match and one alternative inside each group is sufficient. + pub payload_match_groups: std::vec::Vec>, +} + +impl crate::MaterializedOutputPageFilter { + /// Builds a normalized materialized output page filter. + pub fn new( + processor_name: std::option::Option, + materialized_family: std::option::Option, + source_decoder_name: std::option::Option, + signature_contains: std::option::Option, + payload_match_groups: std::vec::Vec>, + ) -> Self { + return Self { + processor_name: crate::contracts::dto::decode::trim_optional_text(processor_name), + materialized_family: crate::contracts::dto::decode::trim_optional_text( + materialized_family, + ), + source_decoder_name: crate::contracts::dto::decode::trim_optional_text( + source_decoder_name, + ), + signature_contains: crate::contracts::dto::decode::trim_optional_text( + signature_contains, + ), + payload_match_groups, + }; + } +} + /// One materialized output returned by the common bounded query contract. #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct MaterializedOutputQueryRow { @@ -707,6 +747,31 @@ mod tests { ); } + #[test] + fn materialized_output_page_filter_preserves_structural_payload_groups() { + let groups = std::vec![ + std::vec![serde_json::json!({"operation": "transfer_checked"})], + std::vec![ + serde_json::json!({"mint": "mint111"}), + serde_json::json!({"accounts": [{"role": "mint", "accountKey": "mint111"}]}), + ], + ]; + let filter = crate::MaterializedOutputPageFilter::new( + std::option::Option::Some(" materializer.token.accounts ".to_string()), + std::option::Option::None, + std::option::Option::Some(" spl.token ".to_string()), + std::option::Option::Some(" signature ".to_string()), + groups.clone(), + ); + assert_eq!( + filter.processor_name.as_deref(), + std::option::Option::Some("materializer.token.accounts") + ); + assert_eq!(filter.source_decoder_name.as_deref(), std::option::Option::Some("spl.token")); + assert_eq!(filter.signature_contains.as_deref(), std::option::Option::Some("signature")); + assert_eq!(filter.payload_match_groups, groups); + } + #[test] fn decode_schema_provenance_requires_reproducible_identity() { let valid = crate::DecodeSchemaProvenance { diff --git a/ks-store/src/contracts/pagination.rs b/ks-store/src/contracts/pagination.rs index cd88bef..fa6b9f7 100644 --- a/ks-store/src/contracts/pagination.rs +++ b/ks-store/src/contracts/pagination.rs @@ -1,5 +1,5 @@ // file: ks-store/src/contracts/pagination.rs -// version: 4 +// version: 5 //! Backend-neutral pagination contracts for repository operations. @@ -103,6 +103,33 @@ impl crate::PageSlice { } } +/// One bounded page with a refreshed total row count for the same filter. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct CountedPageSlice { + /// Rows returned for this page. + pub rows: std::vec::Vec, + /// Opaque cursor for the next page when additional rows exist. + pub next_cursor: std::option::Option, + /// Total number of rows matching the filter at query time. + pub total_rows: u64, +} + +impl crate::CountedPageSlice { + /// Builds one counted page. + pub fn new( + rows: std::vec::Vec, + next_cursor: std::option::Option, + total_rows: u64, + ) -> Self { + return Self { rows, next_cursor, total_rows }; + } + + /// Returns true when this page has no continuation cursor. + pub fn is_last_page(&self) -> bool { + return self.next_cursor.is_none(); + } +} + #[cfg(test)] mod tests { #[test] @@ -135,4 +162,15 @@ mod tests { let page = crate::PageSlice::new(vec![1_u32], std::option::Option::None); assert!(page.is_last_page()); } + + #[test] + fn counted_page_preserves_total_rows_and_cursor_state() { + let page = crate::CountedPageSlice::new( + vec![1_u32, 2_u32], + std::option::Option::Some("cursor".to_string()), + 501, + ); + assert_eq!(page.total_rows, 501); + assert!(!page.is_last_page()); + } } diff --git a/ks-store/src/contracts/replay.rs b/ks-store/src/contracts/replay.rs index 133b952..6347c39 100644 --- a/ks-store/src/contracts/replay.rs +++ b/ks-store/src/contracts/replay.rs @@ -1,11 +1,8 @@ // file: ks-store/src/contracts/replay.rs -// version: 7 +// version: 8 //! Backend-agnostic replay candidate filters and read models. -/// Maximum number of rows returned by one replay candidate query. -pub const MAX_REPLAY_CANDIDATE_ROWS: u32 = 500; - /// Program occurrence scope used while filtering replay candidates. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReplayProgramScope { @@ -74,8 +71,6 @@ pub struct ReplayTransactionFilter { pub entity_kind: std::option::Option, /// Optional exact entity value. pub entity_value: std::option::Option, - /// Maximum returned rows. - pub limit: u32, /// Orders newest slots first when true. pub newest_first: bool, } @@ -92,7 +87,6 @@ impl crate::ReplayTransactionFilter { program_scope: crate::ReplayProgramScope, entity_kind: std::option::Option, entity_value: std::option::Option, - limit: u32, newest_first: bool, ) -> ks_core::Result { let signature_contains_value = trim_optional_text(signature_contains); @@ -104,10 +98,6 @@ impl crate::ReplayTransactionFilter { if let std::result::Result::Err(error) = slot_result { return std::result::Result::Err(error); } - let limit_result = validate_limit(limit); - if let std::result::Result::Err(error) = limit_result { - return std::result::Result::Err(error); - } let raw_state_result = validate_optional_code( raw_processing_state_value.as_deref(), &["received", "core_extracted", "decoded", "materialized", "failed"], @@ -139,7 +129,6 @@ impl crate::ReplayTransactionFilter { program_scope, entity_kind, entity_value: entity_value_value, - limit, newest_first, }); } @@ -183,23 +172,15 @@ pub struct ReplayTransactionCandidate { pub struct ReplayProgramFilter { /// Optional partial program id search. pub program_id_contains: std::option::Option, - /// Maximum returned rows. - pub limit: u32, } impl crate::ReplayProgramFilter { /// Creates and validates a bounded program summary filter. pub fn new( program_id_contains: std::option::Option, - limit: u32, ) -> ks_core::Result { - let limit_result = validate_limit(limit); - if let std::result::Result::Err(error) = limit_result { - return std::result::Result::Err(error); - } return std::result::Result::Ok(Self { program_id_contains: trim_optional_text(program_id_contains), - limit, }); } } @@ -230,8 +211,6 @@ pub struct ReplayEntityFilter { pub entity_kind: crate::ReplayEntityKind, /// Optional partial entity value search. pub entity_value_contains: std::option::Option, - /// Maximum returned rows. - pub limit: u32, } impl crate::ReplayEntityFilter { @@ -239,16 +218,10 @@ impl crate::ReplayEntityFilter { pub fn new( entity_kind: crate::ReplayEntityKind, entity_value_contains: std::option::Option, - limit: u32, ) -> ks_core::Result { - let limit_result = validate_limit(limit); - if let std::result::Result::Err(error) = limit_result { - return std::result::Result::Err(error); - } return std::result::Result::Ok(Self { entity_kind, entity_value_contains: trim_optional_text(entity_value_contains), - limit, }); } } @@ -301,16 +274,6 @@ fn validate_slot_range( return std::result::Result::Ok(()); } -fn validate_limit(limit: u32) -> ks_core::Result<()> { - if limit == 0 || limit > crate::MAX_REPLAY_CANDIDATE_ROWS { - return std::result::Result::Err(ks_core::Error::db(format!( - "replay candidate limit must be between 1 and {}", - crate::MAX_REPLAY_CANDIDATE_ROWS - ))); - } - return std::result::Result::Ok(()); -} - fn validate_optional_code( value: std::option::Option<&str>, allowed: &[&str], @@ -344,7 +307,6 @@ mod tests { crate::ReplayProgramScope::Any, std::option::Option::None, std::option::Option::None, - 100, true, ); assert!(result.is_err()); @@ -362,18 +324,8 @@ mod tests { crate::ReplayProgramScope::Any, std::option::Option::Some(crate::ReplayEntityKind::Mint), std::option::Option::None, - 100, true, ); assert!(result.is_err()); } - - #[test] - fn program_filter_rejects_limit_above_maximum() { - let result = crate::ReplayProgramFilter::new( - std::option::Option::None, - crate::MAX_REPLAY_CANDIDATE_ROWS + 1, - ); - assert!(result.is_err()); - } } diff --git a/ks-store/src/lib.rs b/ks-store/src/lib.rs index 8fa9709..bcb25c4 100644 --- a/ks-store/src/lib.rs +++ b/ks-store/src/lib.rs @@ -1,5 +1,5 @@ // file: ks-store/src/lib.rs -// version: 13 +// version: 18 //! Backend-agnostic Solana storage contracts and store facade. #![warn(missing_docs)] @@ -37,12 +37,12 @@ pub(crate) use self::postgres::DECODE_COVERAGE_DECLARATIONS_TABLE_NAME; pub(crate) use self::postgres::DECODE_COVERAGE_OBSERVATIONS_TABLE_NAME; /// Crate-internal storage symbol `DECODE_EVENTS_TABLE_NAME` shared through the crate-root facade. pub(crate) use self::postgres::DECODE_EVENTS_TABLE_NAME; -/// Crate-internal storage symbol `LEGACY_SOLANA_TABLE_PREFIX` shared through the crate-root facade. -pub(crate) use self::postgres::LEGACY_SOLANA_TABLE_PREFIX; /// Crate-internal storage symbol `MATERIALIZED_OUTPUTS_TABLE_NAME` shared through the crate-root facade. pub(crate) use self::postgres::MATERIALIZED_OUTPUTS_TABLE_NAME; /// Crate-internal storage symbol `PROCESSING_LEDGER_TABLE_NAME` shared through the crate-root facade. pub(crate) use self::postgres::PROCESSING_LEDGER_TABLE_NAME; +/// Crate-internal managed PostgreSQL schema compatibility summary shared through the crate-root facade. +pub(crate) use self::postgres::PostgresSchemaCompatibilitySummary; /// Crate-internal storage symbol `PostgresStore` shared through the crate-root facade. pub(crate) use self::postgres::PostgresStore; /// Crate-internal storage symbol `PostgresStoreOptions` shared through the crate-root facade. @@ -55,8 +55,8 @@ pub(crate) use self::postgres::RAW_TRANSACTIONS_TABLE_NAME; pub(crate) use self::postgres::STORE_SCHEMA_ADVISORY_LOCK_ID; /// Crate-internal storage symbol `TRANSACTION_OBSERVATIONS_TABLE_NAME` shared through the crate-root facade. pub(crate) use self::postgres::TRANSACTION_OBSERVATIONS_TABLE_NAME; -/// Crate-internal complete PostgreSQL baseline initializer shared through the crate-root facade. -pub(crate) use self::postgres::apply_store_schema; +/// Crate-internal additive PostgreSQL managed-schema initializer shared through the crate-root facade. +pub(crate) use self::postgres::apply_missing_store_schema; /// Crate-internal storage symbol `core_store_schema_statements` shared through the crate-root facade. pub(crate) use self::postgres::core_store_schema_statements; /// Crate-internal storage symbol `core_store_table_diagnostic_specs` shared through the crate-root facade. @@ -113,6 +113,8 @@ pub(crate) use self::postgres::list_decode_coverage_summary; pub(crate) use self::postgres::list_decode_inputs; /// Crate-internal storage symbol `list_decode_replay_inputs` shared through the crate-root facade. pub(crate) use self::postgres::list_decode_replay_inputs; +/// Crate-internal storage symbol `list_materialized_output_page` shared through the crate-root facade. +pub(crate) use self::postgres::list_materialized_output_page; /// Crate-internal storage symbol `list_materialized_outputs` shared through the crate-root facade. pub(crate) use self::postgres::list_materialized_outputs; /// Crate-internal storage symbol `list_raw_transactions_for_core_extraction` shared through the crate-root facade. @@ -127,6 +129,10 @@ pub(crate) use self::postgres::list_replay_transaction_candidates; pub(crate) use self::postgres::load_current_schema; /// Crate-internal expected PostgreSQL index counts shared through the crate-root facade. pub(crate) use self::postgres::load_expected_index_counts; +/// Crate-internal additive PostgreSQL schema statement planner shared through the crate-root facade. +pub(crate) use self::postgres::load_postgres_schema_addition_statements; +/// Crate-internal managed PostgreSQL schema compatibility loader shared through the crate-root facade. +pub(crate) use self::postgres::load_postgres_schema_compatibility; /// Crate-internal storage symbol `load_server_version` shared through the crate-root facade. pub(crate) use self::postgres::load_server_version; /// Crate-internal storage symbol `load_table_statistics` shared through the crate-root facade. @@ -158,6 +164,8 @@ pub(crate) use self::postgres::raw_store_table_diagnostic_specs; pub(crate) use self::postgres::recompute_instruction_lifecycle_in_transaction; /// Crate-internal storage symbol `run_health_check` shared through the crate-root facade. pub(crate) use self::postgres::run_health_check; +/// Crate-internal embedded PostgreSQL table DDL inventory shared through the crate-root facade. +pub(crate) use self::postgres::store_table_create_statements; /// Crate-internal storage symbol `table_exists` shared through the crate-root facade. pub(crate) use self::postgres::table_exists; /// Crate-internal storage symbol `table_stats_k_sol_core_account_keys_sql` shared through the crate-root facade. @@ -275,6 +283,8 @@ pub use self::contracts::CoreTransactionInsert; pub use self::contracts::CoreTransactionRow; /// Core Solana storage behavior. pub use self::contracts::CoreTransactionStore; +/// One bounded page with a refreshed total row count. +pub use self::contracts::CountedPageSlice; /// Default repository page size. pub use self::contracts::DEFAULT_PAGE_SIZE; /// One machine-readable decoder coverage declaration row. @@ -309,14 +319,14 @@ pub use self::contracts::MAX_MATERIALIZED_OUTPUT_QUERY_ROWS; pub use self::contracts::MAX_PAGE_CURSOR_LENGTH; /// Maximum repository page size. pub use self::contracts::MAX_PAGE_SIZE; -/// Maximum number of rows returned by one replay candidate query. -pub use self::contracts::MAX_REPLAY_CANDIDATE_ROWS; /// Atomic persistence bundle for one materializer and one decoded observation. pub use self::contracts::MaterializationPersistenceBundle; /// Bounded read-only materialized output selection. pub use self::contracts::MaterializedOutputFilter; /// One processor-owned generic materialized output row. pub use self::contracts::MaterializedOutputInsert; +/// Cursor-paginated materialized output filter. +pub use self::contracts::MaterializedOutputPageFilter; /// One materialized output returned by a bounded query. pub use self::contracts::MaterializedOutputQueryRow; /// Page request contract for repository list operations. diff --git a/ks-store/src/postgres.rs b/ks-store/src/postgres.rs index 242a63c..fa1a1d5 100644 --- a/ks-store/src/postgres.rs +++ b/ks-store/src/postgres.rs @@ -1,11 +1,12 @@ // file: ks-store/src/postgres.rs -// version: 14 +// version: 19 //! Private PostgreSQL implementation of the backend-agnostic store contracts. mod migrations; mod query; mod repository; +mod schema_contract; mod store; #[cfg(test)] mod test_serial; @@ -22,7 +23,6 @@ pub(crate) use self::migrations::CORE_TRANSACTIONS_TABLE_NAME; pub(crate) use self::migrations::DECODE_COVERAGE_DECLARATIONS_TABLE_NAME; pub(crate) use self::migrations::DECODE_COVERAGE_OBSERVATIONS_TABLE_NAME; pub(crate) use self::migrations::DECODE_EVENTS_TABLE_NAME; -pub(crate) use self::migrations::LEGACY_SOLANA_TABLE_PREFIX; pub(crate) use self::migrations::MATERIALIZED_OUTPUTS_TABLE_NAME; pub(crate) use self::migrations::PROCESSING_LEDGER_TABLE_NAME; pub(crate) use self::migrations::PostgresTableDiagnosticSpec; @@ -36,6 +36,7 @@ pub(crate) use self::migrations::decode_store_table_diagnostic_specs; pub(crate) use self::migrations::expected_postgres_index_names; pub(crate) use self::migrations::raw_store_schema_statements; pub(crate) use self::migrations::raw_store_table_diagnostic_specs; +pub(crate) use self::migrations::store_table_create_statements; pub(crate) use self::migrations::table_stats_k_sol_core_account_keys_sql; pub(crate) use self::migrations::table_stats_k_sol_core_account_states_sql; pub(crate) use self::migrations::table_stats_k_sol_core_balance_changes_sql; @@ -53,7 +54,7 @@ pub(crate) use self::migrations::table_stats_k_sol_obs_transaction_observations_ pub(crate) use self::migrations::table_stats_k_sol_ops_processing_ledger_sql; pub(crate) use self::migrations::table_stats_k_sol_raw_transactions_sql; pub(crate) use self::migrations::validate_postgres_sql_resources; -pub(crate) use self::query::apply_store_schema; +pub(crate) use self::query::apply_missing_store_schema; pub(crate) use self::query::has_account_observation_key; pub(crate) use self::query::has_raw_transaction_signature; pub(crate) use self::query::has_transaction_observation_key; @@ -77,6 +78,7 @@ pub(crate) use self::query::list_core_instructions_for_replay; pub(crate) use self::query::list_decode_coverage_summary; pub(crate) use self::query::list_decode_inputs; pub(crate) use self::query::list_decode_replay_inputs; +pub(crate) use self::query::list_materialized_output_page; pub(crate) use self::query::list_materialized_outputs; pub(crate) use self::query::list_raw_transactions_for_core_extraction; pub(crate) use self::query::list_replay_entity_summaries; @@ -99,6 +101,9 @@ pub(crate) use self::query::run_health_check; pub(crate) use self::query::table_exists; pub(crate) use self::query::update_core_instruction_lifecycle; pub(crate) use self::query::update_raw_payload_lifecycle; +pub(crate) use self::schema_contract::PostgresSchemaCompatibilitySummary; +pub(crate) use self::schema_contract::load_postgres_schema_addition_statements; +pub(crate) use self::schema_contract::load_postgres_schema_compatibility; pub(crate) use self::store::PostgresStore; pub(crate) use self::store::PostgresStoreOptions; diff --git a/ks-store/src/postgres/migrations.rs b/ks-store/src/postgres/migrations.rs index 7c6c672..c43a55f 100644 --- a/ks-store/src/postgres/migrations.rs +++ b/ks-store/src/postgres/migrations.rs @@ -1,13 +1,11 @@ // file: ks-store/src/postgres/migrations.rs -// version: 11 +// version: 12 //! PostgreSQL schema resources and migration conventions for the storage backend. /// Canonical Solana table prefix. #[cfg(test)] const SOLANA_TABLE_PREFIX: &str = "k_sol_"; -/// Historical table prefix rejected by the `0.5.3` baseline initializer. -pub(crate) const LEGACY_SOLANA_TABLE_PREFIX: &str = "kb_sol_"; /// Advisory lock id used while applying idempotent store schema statements. pub(crate) const STORE_SCHEMA_ADVISORY_LOCK_ID: i64 = 2_024_000_503; /// PostgreSQL table `k_sol_raw_transactions`. @@ -386,6 +384,28 @@ fn create_pg_table_if_not_exists_k_sol_raw_transactions() -> &'static str { ); } +/// Embedded `CREATE TABLE` resources defining the current managed PostgreSQL column contract. +pub(crate) fn store_table_create_statements() -> std::vec::Vec<&'static str> { + return vec![ + create_pg_table_if_not_exists_k_sol_core_account_keys(), + create_pg_table_if_not_exists_k_sol_core_account_states(), + create_pg_table_if_not_exists_k_sol_core_balance_changes(), + create_pg_table_if_not_exists_k_sol_core_inner_instructions(), + create_pg_table_if_not_exists_k_sol_core_instructions(), + create_pg_table_if_not_exists_k_sol_core_logs(), + create_pg_table_if_not_exists_k_sol_core_return_data(), + create_pg_table_if_not_exists_k_sol_core_transactions(), + create_pg_table_if_not_exists_k_sol_decode_coverage_declarations(), + create_pg_table_if_not_exists_k_sol_decode_coverage_observations(), + create_pg_table_if_not_exists_k_sol_decode_events(), + create_pg_table_if_not_exists_k_sol_mat_outputs(), + create_pg_table_if_not_exists_k_sol_obs_account_observations(), + create_pg_table_if_not_exists_k_sol_obs_transaction_observations(), + create_pg_table_if_not_exists_k_sol_ops_processing_ledger(), + create_pg_table_if_not_exists_k_sol_raw_transactions(), + ]; +} + fn add_pg_constraint_if_not_exists_ck_k_sol_core_account_keys_index_non_negative() -> &'static str { return include_str!( "../../migrations/postgres/schema/constraints/add_constraint_if_not_exists_ck_k_sol_core_account_keys_index_non_negative.sql" @@ -2245,6 +2265,7 @@ mod tests { + super::CORE_STORE_TABLE_NAMES.len() + super::DECODE_STORE_TABLE_NAMES.len(); assert_eq!(table_count, 16); + assert_eq!(crate::store_table_create_statements().len(), table_count); assert_eq!(crate::expected_postgres_index_names().len(), 79); } diff --git a/ks-store/src/postgres/query.rs b/ks-store/src/postgres/query.rs index 7e212ce..8e14d23 100644 --- a/ks-store/src/postgres/query.rs +++ b/ks-store/src/postgres/query.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/query.rs -// version: 6 +// version: 9 //! PostgreSQL query modules. @@ -35,6 +35,7 @@ pub(crate) use self::core_queries::update_core_instruction_lifecycle; pub(crate) use self::decode_pipeline_queries::is_decode_current; pub(crate) use self::decode_pipeline_queries::list_decode_coverage_summary; pub(crate) use self::decode_pipeline_queries::list_decode_inputs; +pub(crate) use self::decode_pipeline_queries::list_materialized_output_page; pub(crate) use self::decode_pipeline_queries::list_materialized_outputs; pub(crate) use self::decode_pipeline_queries::mark_decode_failed; pub(crate) use self::decode_pipeline_queries::persist_decode_coverage_declarations; @@ -56,7 +57,7 @@ pub(crate) use self::raw_queries::update_raw_payload_lifecycle; pub(crate) use self::replay_candidate_queries::list_replay_entity_summaries; pub(crate) use self::replay_candidate_queries::list_replay_program_summaries; pub(crate) use self::replay_candidate_queries::list_replay_transaction_candidates; -pub(crate) use self::schema_queries::apply_store_schema; +pub(crate) use self::schema_queries::apply_missing_store_schema; pub(crate) use self::schema_queries::load_expected_index_counts; pub(crate) use self::table_diagnostics_queries::load_table_statistics; pub(crate) use self::table_diagnostics_queries::table_exists; diff --git a/ks-store/src/postgres/query/core_extraction_queries.rs b/ks-store/src/postgres/query/core_extraction_queries.rs index 9d316c1..8c8e86c 100644 --- a/ks-store/src/postgres/query/core_extraction_queries.rs +++ b/ks-store/src/postgres/query/core_extraction_queries.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/query/core_extraction_queries.rs -// version: 9 +// version: 10 //! PostgreSQL queries for atomic canonical transaction to core extraction. @@ -871,7 +871,7 @@ mod tests { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("unexpected connect error: {error}"), }; - let schema_result = store.initialize_store_schema().await; + let schema_result = store.initialize_missing_store_schema().await; if let std::result::Result::Err(error) = schema_result { panic!("unexpected schema error: {error}"); } diff --git a/ks-store/src/postgres/query/core_queries.rs b/ks-store/src/postgres/query/core_queries.rs index d67c7d8..ecddf1a 100644 --- a/ks-store/src/postgres/query/core_queries.rs +++ b/ks-store/src/postgres/query/core_queries.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/query/core_queries.rs -// version: 11 +// version: 12 //! PostgreSQL queries for normalized Solana core storage. @@ -1181,7 +1181,7 @@ mod tests { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("unexpected connect error: {error}"), }; - let schema_result = store.initialize_store_schema().await; + let schema_result = store.initialize_missing_store_schema().await; if let std::result::Result::Err(error) = schema_result { panic!("unexpected schema error: {error}"); } diff --git a/ks-store/src/postgres/query/decode_pipeline_queries.rs b/ks-store/src/postgres/query/decode_pipeline_queries.rs index 81138cc..e0764dc 100644 --- a/ks-store/src/postgres/query/decode_pipeline_queries.rs +++ b/ks-store/src/postgres/query/decode_pipeline_queries.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/query/decode_pipeline_queries.rs -// version: 12 +// version: 14 //! PostgreSQL queries for contextual decode, coverage and materialization persistence. @@ -22,6 +22,30 @@ struct MaterializedOutputDatabaseRow { updated_at: std::string::String, } +#[derive(sqlx::FromRow)] +struct MaterializedOutputPageDatabaseRow { + id: i64, + processor_name: std::string::String, + processor_version: std::string::String, + input_key: std::string::String, + output_key: std::string::String, + source_event_key: std::string::String, + source_decoder_name: std::string::String, + source_decoder_version: std::string::String, + signature: std::string::String, + slot: i64, + materialized_family: std::string::String, + payload_json: serde_json::Value, + created_at: std::string::String, + updated_at: std::string::String, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct MaterializedOutputPageCursor { + slot: i64, + id: i64, +} + pub(crate) async fn list_decode_inputs( pool: &sqlx::PgPool, filter: &crate::DecodeSelectionFilter, @@ -89,6 +113,229 @@ pub(crate) async fn list_materialized_outputs( return std::result::Result::Ok(output); } +pub(crate) async fn list_materialized_output_page( + pool: &sqlx::PgPool, + filter: &crate::MaterializedOutputPageFilter, + page_request: &crate::PageRequest, +) -> ks_core::Result> { + if page_request.limit == 0 || page_request.limit > crate::MAX_PAGE_SIZE { + return std::result::Result::Err(ks_core::Error::db( + "materialized output page size is outside repository bounds", + )); + } + let cursor_result = decode_materialized_output_page_cursor(page_request.cursor.as_deref()); + let cursor = match cursor_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let payload_match_groups_result = materialized_output_payload_match_groups(filter); + let payload_match_groups = match payload_match_groups_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let count_started_at = std::time::Instant::now(); + let count_result = sqlx::query_scalar::( + "SELECT COUNT(*)::BIGINT FROM k_sol_mat_outputs WHERE ($1::TEXT IS NULL OR processor_name = $1) AND ($2::TEXT IS NULL OR materialized_family = $2) AND ($3::TEXT IS NULL OR source_decoder_name = $3) AND ($4::TEXT IS NULL OR POSITION(LOWER($4) IN LOWER(signature)) > 0) AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements($5::JSONB) AS match_group(value) WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements(match_group.value) AS candidate(value) WHERE payload_jsonb @> candidate.value))", + ) + .bind(filter.processor_name.as_deref()) + .bind(filter.materialized_family.as_deref()) + .bind(filter.source_decoder_name.as_deref()) + .bind(filter.signature_contains.as_deref()) + .bind(&payload_match_groups) + .fetch_one(pool) + .await; + let count = match count_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + trace_query_failure("count_materialized_output_page", count_started_at, &error); + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres materialized output page count failed: {error}" + ))); + }, + }; + let total_rows_result = u64::try_from(count); + let total_rows = match total_rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres materialized output page count conversion failed: {error}" + ))); + }, + }; + trace_query_success("count_materialized_output_page", count_started_at, 1); + let query_limit = i64::from(page_request.limit) + 1; + let started_at = std::time::Instant::now(); + let query_result = sqlx::query_as::( + "SELECT id, processor_name, processor_version, input_key, output_key, source_event_key, source_decoder_name, source_decoder_version, signature, slot, materialized_family, payload_jsonb AS payload_json, created_at::TEXT AS created_at, updated_at::TEXT AS updated_at FROM k_sol_mat_outputs WHERE ($1::TEXT IS NULL OR processor_name = $1) AND ($2::TEXT IS NULL OR materialized_family = $2) AND ($3::TEXT IS NULL OR source_decoder_name = $3) AND ($4::TEXT IS NULL OR POSITION(LOWER($4) IN LOWER(signature)) > 0) AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements($5::JSONB) AS match_group(value) WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements(match_group.value) AS candidate(value) WHERE payload_jsonb @> candidate.value)) AND ($6::BIGINT IS NULL OR slot < $6 OR (slot = $6 AND id < $7)) ORDER BY slot DESC, id DESC LIMIT $8", + ) + .bind(filter.processor_name.as_deref()) + .bind(filter.materialized_family.as_deref()) + .bind(filter.source_decoder_name.as_deref()) + .bind(filter.signature_contains.as_deref()) + .bind(&payload_match_groups) + .bind(cursor.as_ref().map(|value| return value.slot)) + .bind(cursor.as_ref().map(|value| return value.id)) + .bind(query_limit) + .fetch_all(pool) + .await; + let rows = match query_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + trace_query_failure("list_materialized_output_page", started_at, &error); + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres materialized output page query failed: {error}" + ))); + }, + }; + let mut output = std::vec::Vec::with_capacity(rows.len().min(usize::from(page_request.limit))); + for row in rows.iter().take(usize::from(page_request.limit)) { + let mapped_result = materialized_output_page_row(row); + let mapped = match mapped_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + output.push(mapped); + } + let next_cursor = if rows.len() > usize::from(page_request.limit) { + let last_index = usize::from(page_request.limit) - 1; + match rows.get(last_index) { + std::option::Option::Some(value) => { + let encoded_result = encode_materialized_output_page_cursor(value.slot, value.id); + match encoded_result { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => std::option::Option::None, + } + } else { + std::option::Option::None + }; + trace_query_success("list_materialized_output_page", started_at, output.len()); + return std::result::Result::Ok(crate::CountedPageSlice::new(output, next_cursor, total_rows)); +} + +fn materialized_output_payload_match_groups( + filter: &crate::MaterializedOutputPageFilter, +) -> ks_core::Result { + if filter.payload_match_groups.len() > 8 { + return std::result::Result::Err(ks_core::Error::db( + "materialized output payload filter has too many match groups", + )); + } + for group in &filter.payload_match_groups { + if group.is_empty() || group.len() > 4 { + return std::result::Result::Err(ks_core::Error::db( + "materialized output payload filter group size is outside repository bounds", + )); + } + if group.iter().any(|candidate| return !candidate.is_object()) { + return std::result::Result::Err(ks_core::Error::db( + "materialized output payload filter alternatives must be JSON objects", + )); + } + } + let value = serde_json::Value::Array( + filter + .payload_match_groups + .iter() + .map(|group| return serde_json::Value::Array(group.clone())) + .collect(), + ); + let encoded_result = serde_json::to_vec(&value); + let encoded = match encoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(ks_core::Error::db(format!( + "materialized output payload filter serialization failed: {error}" + ))); + }, + }; + if encoded.len() > 8_192 { + return std::result::Result::Err(ks_core::Error::db( + "materialized output payload filter exceeds repository bounds", + )); + } + return std::result::Result::Ok(value); +} + +fn materialized_output_page_row( + row: &MaterializedOutputPageDatabaseRow, +) -> ks_core::Result { + let slot_result = u64::try_from(row.slot); + let slot = match slot_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres materialized output page slot conversion failed: {error}" + ))); + }, + }; + return std::result::Result::Ok(crate::MaterializedOutputQueryRow { + processor_name: row.processor_name.clone(), + processor_version: row.processor_version.clone(), + input_key: row.input_key.clone(), + output_key: row.output_key.clone(), + source_event_key: row.source_event_key.clone(), + source_decoder_name: row.source_decoder_name.clone(), + source_decoder_version: row.source_decoder_version.clone(), + signature: row.signature.clone(), + slot, + materialized_family: row.materialized_family.clone(), + payload_json: row.payload_json.clone(), + created_at: row.created_at.clone(), + updated_at: row.updated_at.clone(), + }); +} + +fn decode_materialized_output_page_cursor( + cursor: std::option::Option<&str>, +) -> ks_core::Result> { + let value = match cursor { + std::option::Option::Some(raw) => raw, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + if value.len() > crate::MAX_PAGE_CURSOR_LENGTH { + return std::result::Result::Err(ks_core::Error::db( + "materialized output page cursor exceeds maximum encoded length", + )); + } + let decode_result = serde_json::from_str::(value); + let decoded = match decode_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(ks_core::Error::db(format!( + "materialized output page cursor is invalid: {error}" + ))); + }, + }; + if decoded.slot < 0 || decoded.id <= 0 { + return std::result::Result::Err(ks_core::Error::db( + "materialized output page cursor values are invalid", + )); + } + return std::result::Result::Ok(std::option::Option::Some(decoded)); +} + +fn encode_materialized_output_page_cursor( + slot: i64, + id: i64, +) -> ks_core::Result { + let cursor = MaterializedOutputPageCursor { slot, id }; + let encode_result = serde_json::to_string(&cursor); + return match encode_result { + std::result::Result::Ok(value) if value.len() <= crate::MAX_PAGE_CURSOR_LENGTH => { + std::result::Result::Ok(value) + }, + std::result::Result::Ok(_value) => std::result::Result::Err(ks_core::Error::db( + "materialized output page cursor exceeds maximum encoded length", + )), + std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!( + "materialized output page cursor serialization failed: {error}" + ))), + }; +} + pub(crate) async fn is_decode_current( pool: &sqlx::PgPool, identity: &crate::ProcessingLedgerIdentity, @@ -1004,10 +1251,55 @@ mod tests { }; let pool_result = sqlx::PgPool::connect(url.as_str()).await; let pool = result_or_panic(pool_result); - result_or_panic(crate::apply_store_schema(&pool).await); + result_or_panic(crate::apply_missing_store_schema(&pool).await); return std::option::Option::Some(pool); } + #[test] + fn materialized_output_page_uses_bounded_structural_filters_without_offset() { + let filter = crate::MaterializedOutputPageFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::vec![std::vec![serde_json::json!({"operation": "transfer"})]], + ); + let encoded_result = super::materialized_output_payload_match_groups(&filter); + let encoded = match encoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected payload filter error: {error}"), + }; + assert_eq!(encoded, serde_json::json!([[{"operation": "transfer"}]])); + let source = include_str!("decode_pipeline_queries.rs"); + let tests_marker = source.find("#[cfg(test)]"); + let production_source = match tests_marker { + std::option::Option::Some(index) => &source[..index], + std::option::Option::None => source, + }; + assert_eq!(production_source.matches("jsonb_array_elements($5::JSONB)").count(), 2); + assert!(!production_source.contains(" OFFSET ")); + } + + #[test] + fn materialized_output_payload_filter_rejects_invalid_group_shapes() { + let empty_group = crate::MaterializedOutputPageFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::vec![std::vec::Vec::new()], + ); + assert!(super::materialized_output_payload_match_groups(&empty_group).is_err()); + let scalar = crate::MaterializedOutputPageFilter::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::vec![std::vec![serde_json::json!("invalid")]], + ); + assert!(super::materialized_output_payload_match_groups(&scalar).is_err()); + } + #[tokio::test] async fn optional_postgres_coverage_declarations_report_insert_skip_and_update_from_env() { let _postgres_guard = crate::postgres_test_guard().await; @@ -1152,7 +1444,7 @@ mod tests { let _postgres_guard = crate::postgres_test_guard().await; let pool_result = sqlx::PgPool::connect(url.as_str()).await; let pool = result_or_panic(pool_result); - result_or_panic(crate::apply_store_schema(&pool).await); + result_or_panic(crate::apply_missing_store_schema(&pool).await); execute_sql( &pool, "DELETE FROM k_sol_decode_events WHERE processor_name = 'decode_atomic_rollback_test'", diff --git a/ks-store/src/postgres/query/raw_queries.rs b/ks-store/src/postgres/query/raw_queries.rs index 40bdb83..e8d56f7 100644 --- a/ks-store/src/postgres/query/raw_queries.rs +++ b/ks-store/src/postgres/query/raw_queries.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/query/raw_queries.rs -// version: 9 +// version: 10 //! PostgreSQL canonical transaction and acquisition observation SQL queries. @@ -463,7 +463,7 @@ mod tests { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("unexpected connect error: {error}"), }; - let schema_result = store.initialize_store_schema().await; + let schema_result = store.initialize_missing_store_schema().await; if let std::result::Result::Err(error) = schema_result { panic!("unexpected schema error: {error}"); } diff --git a/ks-store/src/postgres/query/replay_candidate_queries.rs b/ks-store/src/postgres/query/replay_candidate_queries.rs index 278f696..1f9d197 100644 --- a/ks-store/src/postgres/query/replay_candidate_queries.rs +++ b/ks-store/src/postgres/query/replay_candidate_queries.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/query/replay_candidate_queries.rs -// version: 8 +// version: 10 //! Read-only PostgreSQL queries for replay candidate discovery. @@ -42,18 +42,41 @@ struct ReplayEntitySummaryRow { max_slot: i64, } +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct ReplayTransactionPageCursor { + slot: i64, + signature: std::string::String, + newest_first: bool, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct ReplayProgramPageCursor { + transaction_count: i64, + program_id: std::string::String, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct ReplayEntityPageCursor { + entity_kind: std::string::String, + transaction_count: i64, + entity_value: std::string::String, +} + pub(crate) async fn list_replay_transaction_candidates( pool: &sqlx::PgPool, filter: &crate::ReplayTransactionFilter, -) -> ks_core::Result> { - let min_slot_result = - crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.min_slot); + page_request: &crate::PageRequest, +) -> ks_core::Result> { + let page_result = validate_page_request(page_request); + if let std::result::Result::Err(error) = page_result { + return std::result::Result::Err(error); + } + let min_slot_result = optional_sql_bigint(filter.min_slot); let min_slot = match min_slot_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let max_slot_result = - crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.max_slot); + let max_slot_result = optional_sql_bigint(filter.max_slot); let max_slot = match max_slot_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), @@ -62,103 +85,127 @@ pub(crate) async fn list_replay_transaction_candidates( std::option::Option::Some(value) => std::option::Option::Some(value.as_sql()), std::option::Option::None => std::option::Option::None, }; - let sql = if filter.newest_first { - crate::postgres::query::replay_candidate_queries::transaction_candidate_sql_desc() - } else { - crate::postgres::query::replay_candidate_queries::transaction_candidate_sql_asc() + let cursor_result = + decode_transaction_cursor(page_request.cursor.as_deref(), filter.newest_first); + let cursor = match cursor_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), }; + let total_rows_result = + count_replay_transaction_candidates(pool, filter, min_slot, max_slot, entity_kind).await; + let total_rows = match total_rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let sql = if filter.newest_first { + transaction_candidate_sql_desc() + } else { + transaction_candidate_sql_asc() + }; + let query_limit = i64::from(page_request.limit) + 1; let started_at = std::time::Instant::now(); - let query_result = sqlx::query_as::< - sqlx::Postgres, - crate::postgres::query::replay_candidate_queries::ReplayTransactionCandidateRow, - >(sql) - .bind(filter.signature_contains.as_deref()) - .bind(min_slot) - .bind(max_slot) - .bind(filter.raw_processing_state.as_deref()) - .bind(filter.ledger_status.as_deref()) - .bind(filter.program_id.as_deref()) - .bind(filter.program_scope.as_sql()) - .bind(entity_kind) - .bind(filter.entity_value.as_deref()) - .bind(i64::from(filter.limit)) - .fetch_all(pool) - .await; + let query_result = sqlx::query_as::(sql) + .bind(filter.signature_contains.as_deref()) + .bind(min_slot) + .bind(max_slot) + .bind(filter.raw_processing_state.as_deref()) + .bind(filter.ledger_status.as_deref()) + .bind(filter.program_id.as_deref()) + .bind(filter.program_scope.as_sql()) + .bind(entity_kind) + .bind(filter.entity_value.as_deref()) + .bind(cursor.as_ref().map(|value| return value.slot)) + .bind(cursor.as_ref().map(|value| return value.signature.as_str())) + .bind(query_limit) + .fetch_all(pool) + .await; let rows = match query_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { - trace_query_failure("list_replay_transaction_candidates", started_at, &error); + trace_query_failure("list_replay_transaction_candidate_page", started_at, &error); return std::result::Result::Err(ks_core::Error::db(format!( "postgres replay transaction candidate query failed: {error}" ))); }, }; - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { + let mut output = std::vec::Vec::with_capacity(rows.len().min(usize::from(page_request.limit))); + for row in rows.iter().take(usize::from(page_request.limit)) { output.push(crate::ReplayTransactionCandidate { - signature: row.signature, + signature: row.signature.clone(), slot: row.slot, - raw_processing_state: row.raw_processing_state, - retention_state: row.retention_state, + raw_processing_state: row.raw_processing_state.clone(), + retention_state: row.retention_state.clone(), has_core_transaction: row.has_core_transaction, transaction_failed: row.transaction_failed, - ledger_status: row.ledger_status, - processor_version: row.processor_version, + ledger_status: row.ledger_status.clone(), + processor_version: row.processor_version.clone(), attempt_count: row.attempt_count, top_level_instruction_count: row.top_level_instruction_count, inner_instruction_count: row.inner_instruction_count, top_level_program_count: row.top_level_program_count, inner_program_count: row.inner_program_count, - updated_at: row.updated_at, + updated_at: row.updated_at.clone(), }); } - trace_query_success("list_replay_transaction_candidates", started_at, output.len()); - return std::result::Result::Ok(output); + let next_cursor = if rows.len() > usize::from(page_request.limit) { + match output.last() { + std::option::Option::Some(value) => { + let encoded_result = encode_transaction_cursor(value, filter.newest_first); + match encoded_result { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => std::option::Option::None, + } + } else { + std::option::Option::None + }; + trace_query_success("list_replay_transaction_candidate_page", started_at, output.len()); + return std::result::Result::Ok(crate::CountedPageSlice::new(output, next_cursor, total_rows)); } pub(crate) async fn list_replay_program_summaries( pool: &sqlx::PgPool, filter: &crate::ReplayProgramFilter, -) -> ks_core::Result> { + page_request: &crate::PageRequest, +) -> ks_core::Result> { + let page_result = validate_page_request(page_request); + if let std::result::Result::Err(error) = page_result { + return std::result::Result::Err(error); + } + let cursor_result = decode_program_cursor(page_request.cursor.as_deref()); + let cursor = match cursor_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let total_rows_result = count_replay_program_summaries(pool, filter).await; + let total_rows = match total_rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let query_limit = i64::from(page_request.limit) + 1; let started_at = std::time::Instant::now(); - let query_result = sqlx::query_as::( - r#"WITH occurrences AS ( - SELECT program_id, signature, slot, 'top_level'::TEXT AS scope FROM k_sol_core_instructions - UNION ALL - SELECT program_id, signature, slot, 'inner'::TEXT AS scope FROM k_sol_core_inner_instructions - UNION ALL - SELECT program_id, signature, slot, 'logs'::TEXT AS scope FROM k_sol_core_logs WHERE program_id IS NOT NULL - ) - SELECT program_id, - COUNT(DISTINCT signature)::BIGINT AS transaction_count, - COUNT(*) FILTER (WHERE scope = 'top_level')::BIGINT AS top_level_instruction_count, - COUNT(*) FILTER (WHERE scope = 'inner')::BIGINT AS inner_instruction_count, - COUNT(*) FILTER (WHERE scope = 'logs')::BIGINT AS log_count, - MIN(slot)::BIGINT AS min_slot, - MAX(slot)::BIGINT AS max_slot - FROM occurrences - WHERE ($1::TEXT IS NULL OR program_id ILIKE '%' || $1 || '%') - GROUP BY program_id - ORDER BY transaction_count DESC, program_id ASC - LIMIT $2"#, - ) - .bind(filter.program_id_contains.as_deref()) - .bind(i64::from(filter.limit)) - .fetch_all(pool) - .await; + let query_result = sqlx::query_as::(PROGRAM_PAGE_SQL) + .bind(filter.program_id_contains.as_deref()) + .bind(cursor.as_ref().map(|value| return value.transaction_count)) + .bind(cursor.as_ref().map(|value| return value.program_id.as_str())) + .bind(query_limit) + .fetch_all(pool) + .await; let rows = match query_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { - trace_query_failure("list_replay_program_summaries", started_at, &error); + trace_query_failure("list_replay_program_summary_page", started_at, &error); return std::result::Result::Err(ks_core::Error::db(format!( "postgres replay program summary query failed: {error}" ))); }, }; - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { + let mut output = std::vec::Vec::with_capacity(rows.len().min(usize::from(page_request.limit))); + for row in rows.iter().take(usize::from(page_request.limit)) { output.push(crate::ReplayProgramSummary { - program_id: row.program_id, + program_id: row.program_id.clone(), transaction_count: row.transaction_count, top_level_instruction_count: row.top_level_instruction_count, inner_instruction_count: row.inner_instruction_count, @@ -167,74 +214,429 @@ pub(crate) async fn list_replay_program_summaries( max_slot: row.max_slot, }); } - trace_query_success("list_replay_program_summaries", started_at, output.len()); - return std::result::Result::Ok(output); + let next_cursor = if rows.len() > usize::from(page_request.limit) { + match output.last() { + std::option::Option::Some(value) => { + let encoded_result = encode_program_cursor(value); + match encoded_result { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => std::option::Option::None, + } + } else { + std::option::Option::None + }; + trace_query_success("list_replay_program_summary_page", started_at, output.len()); + return std::result::Result::Ok(crate::CountedPageSlice::new(output, next_cursor, total_rows)); } pub(crate) async fn list_replay_entity_summaries( pool: &sqlx::PgPool, filter: &crate::ReplayEntityFilter, -) -> ks_core::Result> { + page_request: &crate::PageRequest, +) -> ks_core::Result> { + let page_result = validate_page_request(page_request); + if let std::result::Result::Err(error) = page_result { + return std::result::Result::Err(error); + } let entity_kind = filter.entity_kind.as_sql(); + let cursor_result = decode_entity_cursor(page_request.cursor.as_deref(), entity_kind); + let cursor = match cursor_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let total_rows_result = count_replay_entity_summaries(pool, filter).await; + let total_rows = match total_rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let query_limit = i64::from(page_request.limit) + 1; let started_at = std::time::Instant::now(); - let query_result = sqlx::query_as::< - sqlx::Postgres, - crate::postgres::query::replay_candidate_queries::ReplayEntitySummaryRow, - >( - r#"WITH entities AS ( - SELECT 'mint'::TEXT AS entity_kind, mint AS entity_value, signature, slot - FROM k_sol_core_balance_changes - WHERE mint IS NOT NULL - UNION ALL - SELECT 'owner'::TEXT AS entity_kind, owner AS entity_value, signature, slot - FROM k_sol_core_balance_changes - WHERE owner IS NOT NULL - UNION ALL - SELECT 'account_key'::TEXT AS entity_kind, account_key AS entity_value, signature, slot - FROM k_sol_core_account_keys - ) - SELECT entity_kind, - entity_value, - COUNT(DISTINCT signature)::BIGINT AS transaction_count, - COUNT(*)::BIGINT AS occurrence_count, - MIN(slot)::BIGINT AS min_slot, - MAX(slot)::BIGINT AS max_slot - FROM entities - WHERE entity_kind = $1 - AND ($2::TEXT IS NULL OR entity_value ILIKE '%' || $2 || '%') - GROUP BY entity_kind, entity_value - ORDER BY transaction_count DESC, entity_value ASC - LIMIT $3"#, - ) - .bind(entity_kind) - .bind(filter.entity_value_contains.as_deref()) - .bind(i64::from(filter.limit)) - .fetch_all(pool) - .await; + let query_result = sqlx::query_as::(ENTITY_PAGE_SQL) + .bind(entity_kind) + .bind(filter.entity_value_contains.as_deref()) + .bind(cursor.as_ref().map(|value| return value.transaction_count)) + .bind(cursor.as_ref().map(|value| return value.entity_value.as_str())) + .bind(query_limit) + .fetch_all(pool) + .await; let rows = match query_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { - trace_query_failure("list_replay_entity_summaries", started_at, &error); + trace_query_failure("list_replay_entity_summary_page", started_at, &error); return std::result::Result::Err(ks_core::Error::db(format!( "postgres replay entity summary query failed: {error}" ))); }, }; - let mut output = std::vec::Vec::with_capacity(rows.len()); - for row in rows { + let mut output = std::vec::Vec::with_capacity(rows.len().min(usize::from(page_request.limit))); + for row in rows.iter().take(usize::from(page_request.limit)) { output.push(crate::ReplayEntitySummary { - entity_kind: row.entity_kind, - entity_value: row.entity_value, + entity_kind: row.entity_kind.clone(), + entity_value: row.entity_value.clone(), transaction_count: row.transaction_count, occurrence_count: row.occurrence_count, min_slot: row.min_slot, max_slot: row.max_slot, }); } - trace_query_success("list_replay_entity_summaries", started_at, output.len()); - return std::result::Result::Ok(output); + let next_cursor = if rows.len() > usize::from(page_request.limit) { + match output.last() { + std::option::Option::Some(value) => { + let encoded_result = encode_entity_cursor(value); + match encoded_result { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => std::option::Option::None, + } + } else { + std::option::Option::None + }; + trace_query_success("list_replay_entity_summary_page", started_at, output.len()); + return std::result::Result::Ok(crate::CountedPageSlice::new(output, next_cursor, total_rows)); } +async fn count_replay_transaction_candidates( + pool: &sqlx::PgPool, + filter: &crate::ReplayTransactionFilter, + min_slot: std::option::Option, + max_slot: std::option::Option, + entity_kind: std::option::Option<&str>, +) -> ks_core::Result { + let started_at = std::time::Instant::now(); + let query_result = sqlx::query_scalar::(TRANSACTION_COUNT_SQL) + .bind(filter.signature_contains.as_deref()) + .bind(min_slot) + .bind(max_slot) + .bind(filter.raw_processing_state.as_deref()) + .bind(filter.ledger_status.as_deref()) + .bind(filter.program_id.as_deref()) + .bind(filter.program_scope.as_sql()) + .bind(entity_kind) + .bind(filter.entity_value.as_deref()) + .fetch_one(pool) + .await; + let count = match query_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + trace_query_failure("count_replay_transaction_candidates", started_at, &error); + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres replay transaction candidate count failed: {error}" + ))); + }, + }; + trace_query_success("count_replay_transaction_candidates", started_at, 1); + return unsigned_count(count, "replay transaction candidate count is negative"); +} + +async fn count_replay_program_summaries( + pool: &sqlx::PgPool, + filter: &crate::ReplayProgramFilter, +) -> ks_core::Result { + let started_at = std::time::Instant::now(); + let query_result = sqlx::query_scalar::(PROGRAM_COUNT_SQL) + .bind(filter.program_id_contains.as_deref()) + .fetch_one(pool) + .await; + let count = match query_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + trace_query_failure("count_replay_program_summaries", started_at, &error); + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres replay program summary count failed: {error}" + ))); + }, + }; + trace_query_success("count_replay_program_summaries", started_at, 1); + return unsigned_count(count, "replay program summary count is negative"); +} + +async fn count_replay_entity_summaries( + pool: &sqlx::PgPool, + filter: &crate::ReplayEntityFilter, +) -> ks_core::Result { + let started_at = std::time::Instant::now(); + let query_result = sqlx::query_scalar::(ENTITY_COUNT_SQL) + .bind(filter.entity_kind.as_sql()) + .bind(filter.entity_value_contains.as_deref()) + .fetch_one(pool) + .await; + let count = match query_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + trace_query_failure("count_replay_entity_summaries", started_at, &error); + return std::result::Result::Err(ks_core::Error::db(format!( + "postgres replay entity summary count failed: {error}" + ))); + }, + }; + trace_query_success("count_replay_entity_summaries", started_at, 1); + return unsigned_count(count, "replay entity summary count is negative"); +} + +fn validate_page_request(page_request: &crate::PageRequest) -> ks_core::Result<()> { + if page_request.limit == 0 || page_request.limit > crate::MAX_PAGE_SIZE { + return std::result::Result::Err(ks_core::Error::db( + "replay candidate page size is outside repository bounds", + )); + } + return std::result::Result::Ok(()); +} + +fn unsigned_count(value: i64, message: &str) -> ks_core::Result { + let conversion_result = u64::try_from(value); + return match conversion_result { + std::result::Result::Ok(converted) => std::result::Result::Ok(converted), + std::result::Result::Err(_error) => std::result::Result::Err(ks_core::Error::db(message)), + }; +} + +fn decode_transaction_cursor( + cursor: std::option::Option<&str>, + newest_first: bool, +) -> ks_core::Result> { + let decoded_result = decode_cursor::(cursor, "transaction"); + let decoded = match decoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if decoded.as_ref().is_some_and(|value| { + return value.slot < 0 + || value.signature.trim().is_empty() + || value.newest_first != newest_first; + }) { + return std::result::Result::Err(ks_core::Error::db( + "replay transaction page cursor values are invalid", + )); + } + return std::result::Result::Ok(decoded); +} + +fn decode_program_cursor( + cursor: std::option::Option<&str>, +) -> ks_core::Result> { + let decoded_result = decode_cursor::(cursor, "program"); + let decoded = match decoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if decoded.as_ref().is_some_and(|value| { + return value.transaction_count < 0 || value.program_id.trim().is_empty(); + }) { + return std::result::Result::Err(ks_core::Error::db( + "replay program page cursor values are invalid", + )); + } + return std::result::Result::Ok(decoded); +} + +fn decode_entity_cursor( + cursor: std::option::Option<&str>, + entity_kind: &str, +) -> ks_core::Result> { + let decoded_result = decode_cursor::(cursor, "entity"); + let decoded = match decoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if decoded.as_ref().is_some_and(|value| { + return value.transaction_count < 0 + || value.entity_value.trim().is_empty() + || value.entity_kind != entity_kind; + }) { + return std::result::Result::Err(ks_core::Error::db( + "replay entity page cursor values are invalid", + )); + } + return std::result::Result::Ok(decoded); +} + +fn decode_cursor( + cursor: std::option::Option<&str>, + label: &str, +) -> ks_core::Result> +where + T: serde::de::DeserializeOwned, +{ + let raw = match cursor { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + let decode_result = serde_json::from_str::(raw); + return match decode_result { + std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)), + std::result::Result::Err(_error) => std::result::Result::Err(ks_core::Error::db(format!( + "replay {label} page cursor is invalid" + ))), + }; +} + +fn encode_transaction_cursor( + row: &crate::ReplayTransactionCandidate, + newest_first: bool, +) -> ks_core::Result { + return encode_cursor( + &ReplayTransactionPageCursor { + slot: row.slot, + signature: row.signature.clone(), + newest_first, + }, + "transaction", + ); +} + +fn encode_program_cursor( + row: &crate::ReplayProgramSummary, +) -> ks_core::Result { + return encode_cursor( + &ReplayProgramPageCursor { + transaction_count: row.transaction_count, + program_id: row.program_id.clone(), + }, + "program", + ); +} + +fn encode_entity_cursor(row: &crate::ReplayEntitySummary) -> ks_core::Result { + return encode_cursor( + &ReplayEntityPageCursor { + entity_kind: row.entity_kind.clone(), + transaction_count: row.transaction_count, + entity_value: row.entity_value.clone(), + }, + "entity", + ); +} + +fn encode_cursor(value: &T, label: &str) -> ks_core::Result +where + T: serde::Serialize, +{ + let encode_result = serde_json::to_string(value); + return match encode_result { + std::result::Result::Ok(encoded) if encoded.len() <= crate::MAX_PAGE_CURSOR_LENGTH => { + std::result::Result::Ok(encoded) + }, + std::result::Result::Ok(_encoded) => std::result::Result::Err(ks_core::Error::db(format!( + "replay {label} page cursor exceeds maximum encoded length" + ))), + std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!( + "replay {label} page cursor serialization failed: {error}" + ))), + }; +} + +const TRANSACTION_COUNT_SQL: &str = r#"SELECT COUNT(*)::BIGINT +FROM k_sol_raw_transactions raw +LEFT JOIN LATERAL ( + SELECT status + FROM k_sol_ops_processing_ledger + WHERE stage = 'core_extraction' + AND processor_name = 'canonical_to_core' + AND input_key = raw.signature + ORDER BY updated_at DESC, id DESC + LIMIT 1 +) ledger ON TRUE +WHERE ($1::TEXT IS NULL OR raw.signature ILIKE '%' || $1 || '%') + AND ($2::BIGINT IS NULL OR raw.slot >= $2) + AND ($3::BIGINT IS NULL OR raw.slot <= $3) + AND ($4::TEXT IS NULL OR raw.processing_state = $4) + AND ($5::TEXT IS NULL OR ($5 = 'not_started' AND ledger.status IS NULL) OR ledger.status = $5) + AND ($6::TEXT IS NULL OR + ($7 = 'any' AND ( + EXISTS (SELECT 1 FROM k_sol_core_instructions candidate_top_level WHERE candidate_top_level.signature = raw.signature AND candidate_top_level.program_id = $6) OR + EXISTS (SELECT 1 FROM k_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6) OR + EXISTS (SELECT 1 FROM k_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6) + )) OR + ($7 = 'top_level' AND EXISTS (SELECT 1 FROM k_sol_core_instructions candidate_top_level WHERE candidate_top_level.signature = raw.signature AND candidate_top_level.program_id = $6)) OR + ($7 = 'inner' AND EXISTS (SELECT 1 FROM k_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6)) OR + ($7 = 'logs' AND EXISTS (SELECT 1 FROM k_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6))) + AND ($8::TEXT IS NULL OR + ($8 = 'mint' AND EXISTS (SELECT 1 FROM k_sol_core_balance_changes candidate_balance WHERE candidate_balance.signature = raw.signature AND candidate_balance.mint = $9)) OR + ($8 = 'owner' AND EXISTS (SELECT 1 FROM k_sol_core_balance_changes candidate_balance WHERE candidate_balance.signature = raw.signature AND candidate_balance.owner = $9)) OR + ($8 = 'account_key' AND EXISTS (SELECT 1 FROM k_sol_core_account_keys candidate_account WHERE candidate_account.signature = raw.signature AND candidate_account.account_key = $9)))"#; + +const PROGRAM_PAGE_SQL: &str = r#"WITH occurrences AS ( + SELECT program_id, signature, slot, 'top_level'::TEXT AS scope FROM k_sol_core_instructions + UNION ALL + SELECT program_id, signature, slot, 'inner'::TEXT AS scope FROM k_sol_core_inner_instructions + UNION ALL + SELECT program_id, signature, slot, 'logs'::TEXT AS scope FROM k_sol_core_logs WHERE program_id IS NOT NULL +), summaries AS ( + SELECT program_id, + COUNT(DISTINCT signature)::BIGINT AS transaction_count, + COUNT(*) FILTER (WHERE scope = 'top_level')::BIGINT AS top_level_instruction_count, + COUNT(*) FILTER (WHERE scope = 'inner')::BIGINT AS inner_instruction_count, + COUNT(*) FILTER (WHERE scope = 'logs')::BIGINT AS log_count, + MIN(slot)::BIGINT AS min_slot, + MAX(slot)::BIGINT AS max_slot + FROM occurrences + WHERE ($1::TEXT IS NULL OR program_id ILIKE '%' || $1 || '%') + GROUP BY program_id +) +SELECT program_id, transaction_count, top_level_instruction_count, inner_instruction_count, log_count, min_slot, max_slot +FROM summaries +WHERE ($2::BIGINT IS NULL OR transaction_count < $2 OR (transaction_count = $2 AND program_id > $3)) +ORDER BY transaction_count DESC, program_id ASC +LIMIT $4"#; + +const PROGRAM_COUNT_SQL: &str = r#"WITH occurrences AS ( + SELECT program_id FROM k_sol_core_instructions + UNION ALL + SELECT program_id FROM k_sol_core_inner_instructions + UNION ALL + SELECT program_id FROM k_sol_core_logs WHERE program_id IS NOT NULL +), summaries AS ( + SELECT program_id + FROM occurrences + WHERE ($1::TEXT IS NULL OR program_id ILIKE '%' || $1 || '%') + GROUP BY program_id +) +SELECT COUNT(*)::BIGINT FROM summaries"#; + +const ENTITY_PAGE_SQL: &str = r#"WITH entities AS ( + SELECT 'mint'::TEXT AS entity_kind, mint AS entity_value, signature, slot FROM k_sol_core_balance_changes WHERE mint IS NOT NULL + UNION ALL + SELECT 'owner'::TEXT AS entity_kind, owner AS entity_value, signature, slot FROM k_sol_core_balance_changes WHERE owner IS NOT NULL + UNION ALL + SELECT 'account_key'::TEXT AS entity_kind, account_key AS entity_value, signature, slot FROM k_sol_core_account_keys +), summaries AS ( + SELECT entity_kind, + entity_value, + COUNT(DISTINCT signature)::BIGINT AS transaction_count, + COUNT(*)::BIGINT AS occurrence_count, + MIN(slot)::BIGINT AS min_slot, + MAX(slot)::BIGINT AS max_slot + FROM entities + WHERE entity_kind = $1 + AND ($2::TEXT IS NULL OR entity_value ILIKE '%' || $2 || '%') + GROUP BY entity_kind, entity_value +) +SELECT entity_kind, entity_value, transaction_count, occurrence_count, min_slot, max_slot +FROM summaries +WHERE ($3::BIGINT IS NULL OR transaction_count < $3 OR (transaction_count = $3 AND entity_value > $4)) +ORDER BY transaction_count DESC, entity_value ASC +LIMIT $5"#; + +const ENTITY_COUNT_SQL: &str = r#"WITH entities AS ( + SELECT 'mint'::TEXT AS entity_kind, mint AS entity_value FROM k_sol_core_balance_changes WHERE mint IS NOT NULL + UNION ALL + SELECT 'owner'::TEXT AS entity_kind, owner AS entity_value FROM k_sol_core_balance_changes WHERE owner IS NOT NULL + UNION ALL + SELECT 'account_key'::TEXT AS entity_kind, account_key AS entity_value FROM k_sol_core_account_keys +), summaries AS ( + SELECT entity_kind, entity_value + FROM entities + WHERE entity_kind = $1 + AND ($2::TEXT IS NULL OR entity_value ILIKE '%' || $2 || '%') + GROUP BY entity_kind, entity_value +) +SELECT COUNT(*)::BIGINT FROM summaries"#; + fn transaction_candidate_sql_desc() -> &'static str { return crate::postgres::query::replay_candidate_queries::transaction_candidate_sql("DESC"); } @@ -310,8 +712,9 @@ fn transaction_candidate_sql(order: &str) -> &'static str { WHERE candidate_account.signature = raw.signature AND candidate_account.account_key = $9 ))) + AND ($10::BIGINT IS NULL OR raw.slot < $10 OR (raw.slot = $10 AND raw.signature < $11)) ORDER BY raw.slot DESC, raw.signature DESC - LIMIT $10"#; + LIMIT $12"#; } return r#"SELECT raw.signature, raw.slot, @@ -378,8 +781,9 @@ fn transaction_candidate_sql(order: &str) -> &'static str { WHERE candidate_account.signature = raw.signature AND candidate_account.account_key = $9 ))) + AND ($10::BIGINT IS NULL OR raw.slot > $10 OR (raw.slot = $10 AND raw.signature > $11)) ORDER BY raw.slot ASC, raw.signature ASC - LIMIT $10"#; + LIMIT $12"#; } fn optional_sql_bigint( @@ -413,6 +817,49 @@ fn trace_query_failure(action: &str, started_at: std::time::Instant, error: &sql #[cfg(test)] mod tests { + #[test] + fn replay_candidate_queries_are_cursor_based_and_counted() { + let source = include_str!("replay_candidate_queries.rs"); + let tests_marker = source.find("#[cfg(test)]"); + let production_source = match tests_marker { + std::option::Option::Some(index) => &source[..index], + std::option::Option::None => source, + }; + assert!(!production_source.contains(" OFFSET ")); + assert!(production_source.contains("TRANSACTION_COUNT_SQL")); + assert!(production_source.contains("PROGRAM_COUNT_SQL")); + assert!(production_source.contains("ENTITY_COUNT_SQL")); + assert!(production_source.contains("CountedPageSlice::new")); + } + + #[test] + fn transaction_cursor_rejects_order_direction_changes() { + let row = crate::ReplayTransactionCandidate { + signature: "signature".to_string(), + slot: 42, + raw_processing_state: "received".to_string(), + retention_state: "retained".to_string(), + has_core_transaction: false, + transaction_failed: std::option::Option::None, + ledger_status: "not_started".to_string(), + processor_version: std::option::Option::None, + attempt_count: 0, + top_level_instruction_count: 0, + inner_instruction_count: 0, + top_level_program_count: 0, + inner_program_count: 0, + updated_at: "now".to_string(), + }; + let encoded_result = super::encode_transaction_cursor(&row, true); + let encoded = match encoded_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected encode error: {error}"), + }; + let decoded_result = + super::decode_transaction_cursor(std::option::Option::Some(encoded.as_str()), false); + assert!(decoded_result.is_err()); + } + #[tokio::test] async fn optional_postgres_replay_candidate_queries_from_env() { let database_url = match std::env::var("KS_SECRET_POSTGRES_TEST_URL") { @@ -430,7 +877,7 @@ mod tests { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("unexpected connect error: {error}"), }; - let schema_result = store.initialize_store_schema().await; + let schema_result = store.initialize_missing_store_schema().await; if let std::result::Result::Err(error) = schema_result { panic!("unexpected store schema error: {error}"); } @@ -444,7 +891,6 @@ mod tests { crate::ReplayProgramScope::Any, std::option::Option::None, std::option::Option::None, - 10, true, ); let transaction_filter = match transaction_filter_result { @@ -453,16 +899,24 @@ mod tests { panic!("unexpected transaction filter error: {error}") }, }; - let transaction_result = store.replay_transaction_candidates(&transaction_filter).await; + let transaction_page = crate::PageRequest::new(10, std::option::Option::None); + let transaction_page = match transaction_page { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected transaction page error: {error}"), + }; + let transaction_result = store + .replay_transaction_candidates(&transaction_filter, &transaction_page) + .await; if let std::result::Result::Err(error) = transaction_result { panic!("unexpected transaction candidate query error: {error}"); } - let program_filter_result = crate::ReplayProgramFilter::new(std::option::Option::None, 10); + let program_filter_result = crate::ReplayProgramFilter::new(std::option::Option::None); let program_filter = match program_filter_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("unexpected program filter error: {error}"), }; - let program_result = store.replay_program_summaries(&program_filter).await; + let program_result = + store.replay_program_summaries(&program_filter, &transaction_page).await; if let std::result::Result::Err(error) = program_result { panic!("unexpected program summary query error: {error}"); } @@ -472,14 +926,15 @@ mod tests { crate::ReplayEntityKind::AccountKey, ] { let entity_filter_result = - crate::ReplayEntityFilter::new(entity_kind, std::option::Option::None, 10); + crate::ReplayEntityFilter::new(entity_kind, std::option::Option::None); let entity_filter = match entity_filter_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { panic!("unexpected entity filter error: {error}") }, }; - let entity_result = store.replay_entity_summaries(&entity_filter).await; + let entity_result = + store.replay_entity_summaries(&entity_filter, &transaction_page).await; if let std::result::Result::Err(error) = entity_result { panic!("unexpected entity summary query error: {error}"); } diff --git a/ks-store/src/postgres/query/schema_queries.rs b/ks-store/src/postgres/query/schema_queries.rs index 8eada31..11bf7c2 100644 --- a/ks-store/src/postgres/query/schema_queries.rs +++ b/ks-store/src/postgres/query/schema_queries.rs @@ -1,23 +1,20 @@ // file: ks-store/src/postgres/query/schema_queries.rs -// version: 1 +// version: 5 -//! PostgreSQL orchestration for the complete store baseline schema. +//! PostgreSQL additive-only orchestration for the managed store schema. -pub(crate) async fn apply_store_schema(pool: &sqlx::PgPool) -> ks_core::Result<()> { +pub(crate) async fn apply_missing_store_schema(pool: &sqlx::PgPool) -> ks_core::Result<()> { let resource_validation_result = crate::validate_postgres_sql_resources(); if let std::result::Result::Err(error) = resource_validation_result { return std::result::Result::Err(error); } - let legacy_result = legacy_store_schema_detected(pool).await; - let legacy_detected = match legacy_result { + let statements_result = crate::load_postgres_schema_addition_statements(pool).await; + let statements = match statements_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - if legacy_detected { - return std::result::Result::Err(crate::storage_contract_error( - "store_legacy_schema_detected", - "historical store schema detected; rebuild the database before initialization", - )); + if statements.is_empty() { + return std::result::Result::Ok(()); } let transaction_result = pool.begin().await; let mut transaction = match transaction_result { @@ -37,20 +34,8 @@ pub(crate) async fn apply_store_schema(pool: &sqlx::PgPool) -> ks_core::Result<( "postgres store schema advisory lock failed: {error}" ))); } - let raw_result = - execute_schema_group(&mut transaction, "raw", crate::raw_store_schema_statements()).await; - if let std::result::Result::Err(error) = raw_result { - return std::result::Result::Err(error); - } - let core_result = - execute_schema_group(&mut transaction, "core", crate::core_store_schema_statements()).await; - if let std::result::Result::Err(error) = core_result { - return std::result::Result::Err(error); - } - let decode_result = - execute_schema_group(&mut transaction, "decode", crate::decode_store_schema_statements()) - .await; - if let std::result::Result::Err(error) = decode_result { + let execution_result = execute_additive_schema_group(&mut transaction, statements).await; + if let std::result::Result::Err(error) = execution_result { return std::result::Result::Err(error); } let commit_result = transaction.commit().await; @@ -62,57 +47,39 @@ pub(crate) async fn apply_store_schema(pool: &sqlx::PgPool) -> ks_core::Result<( return std::result::Result::Ok(()); } -async fn execute_schema_group( +async fn execute_additive_schema_group( transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - model: &str, - statements: std::vec::Vec<&'static str>, + statements: std::vec::Vec, ) -> ks_core::Result<()> { for (statement_index, statement) in statements.into_iter().enumerate() { tracing::trace!( target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", - action = "schema_statement", - model, + action = "schema_additive_statement", statement_index, sql = statement, - "execute PostgreSQL schema statement" + "execute additive PostgreSQL schema statement" ); - let execution_result = sqlx::query(statement).execute(&mut **transaction).await; + let execution_result = + sqlx::query(sqlx::AssertSqlSafe(statement)).execute(&mut **transaction).await; if let std::result::Result::Err(error) = execution_result { tracing::error!( target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", - action = "schema_statement", - model, + action = "schema_additive_statement", statement_index, - "PostgreSQL schema statement failed" + "additive PostgreSQL schema statement failed" ); return std::result::Result::Err(ks_core::Error::db(format!( - "postgres store schema statement failed for model {model} at index {statement_index}: {error}" + "postgres additive store schema statement failed at index {statement_index}: {error}" ))); } } return std::result::Result::Ok(()); } -async fn legacy_store_schema_detected(pool: &sqlx::PgPool) -> ks_core::Result { - let pattern = format!("{}%", crate::LEGACY_SOLANA_TABLE_PREFIX); - let query_result = sqlx::query_scalar::( - "SELECT EXISTS(SELECT 1 FROM pg_class object JOIN pg_namespace namespace ON namespace.oid = object.relnamespace WHERE namespace.nspname = current_schema() AND object.relkind IN ('r', 'p', 'S', 'i') AND object.relname LIKE $1)", - ) - .bind(pattern) - .fetch_one(pool) - .await; - return match query_result { - std::result::Result::Ok(value) => std::result::Result::Ok(value), - std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!( - "postgres historical schema detection failed: {error}" - ))), - }; -} - pub(crate) async fn load_expected_index_counts(pool: &sqlx::PgPool) -> ks_core::Result<(u32, u32)> { let names = crate::expected_postgres_index_names() .iter() @@ -150,13 +117,3 @@ pub(crate) async fn load_expected_index_counts(pool: &sqlx::PgPool) -> ks_core:: }; return std::result::Result::Ok((expected_count, available_count)); } - -#[cfg(test)] -mod tests { - #[test] - fn legacy_prefix_is_distinct_from_the_candidate_baseline_prefix() { - assert_eq!(crate::LEGACY_SOLANA_TABLE_PREFIX, "kb_sol_"); - assert!(crate::RAW_TRANSACTIONS_TABLE_NAME.starts_with("k_sol_")); - assert!(!crate::RAW_TRANSACTIONS_TABLE_NAME.starts_with(crate::LEGACY_SOLANA_TABLE_PREFIX)); - } -} diff --git a/ks-store/src/postgres/schema_contract.rs b/ks-store/src/postgres/schema_contract.rs new file mode 100644 index 0000000..f9acf9d --- /dev/null +++ b/ks-store/src/postgres/schema_contract.rs @@ -0,0 +1,1645 @@ +// file: ks-store/src/postgres/schema_contract.rs +// version: 4 + +//! PostgreSQL managed-schema compatibility contract derived from embedded schema resources. + +use sqlx::Row; // rust-rules: trait-import + +/// Compatibility summary for the managed PostgreSQL schema. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PostgresSchemaCompatibilitySummary { + /// Number of managed tables required by the current contract. + pub expected_table_count: u32, + /// Number of managed tables currently present and structurally compatible. + pub compatible_table_count: u32, + /// Number of required columns derived from the current table DDL. + pub expected_column_count: u32, + /// Number of required columns proven compatible in PostgreSQL. + pub compatible_column_count: u32, + /// Number of required primary and foreign keys. + pub expected_key_count: u32, + /// Number of required primary and foreign keys proven compatible. + pub compatible_key_count: u32, + /// Number of required physical indexes, including primary-key indexes. + pub expected_index_count: u32, + /// Number of required physical indexes proven compatible. + pub compatible_index_count: u32, + /// Managed tables absent from the current schema. + pub missing_table_names: std::vec::Vec, +} + +impl PostgresSchemaCompatibilitySummary { + /// Returns whether every managed table is present and compatible. + pub(crate) fn is_complete(&self) -> bool { + return self.missing_table_names.is_empty() + && self.compatible_table_count == self.expected_table_count + && self.compatible_column_count == self.expected_column_count + && self.compatible_key_count == self.expected_key_count + && self.compatible_index_count == self.expected_index_count; + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PostgresColumnContractSpec { + table_name: std::string::String, + column_name: std::string::String, + udt_name: std::string::String, + nullable: bool, + default_required: bool, + numeric_precision: std::option::Option, + numeric_scale: std::option::Option, + definition_sql: std::string::String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PostgresColumnCatalogState { + table_name: std::string::String, + column_name: std::string::String, + udt_name: std::string::String, + nullable: bool, + has_default: bool, + numeric_precision: std::option::Option, + numeric_scale: std::option::Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PostgresKeyContractSpec { + table_name: std::string::String, + constraint_name: std::string::String, + key_kind: std::string::String, + columns: std::vec::Vec, + referenced_table_name: std::option::Option, + referenced_columns: std::vec::Vec, + delete_action: std::string::String, + update_action: std::string::String, + deferrable: bool, + initially_deferred: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PostgresIndexContractSpec { + table_name: std::string::String, + index_name: std::string::String, + unique: bool, + access_method: std::string::String, + expressions: std::vec::Vec, + predicate: std::option::Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PostgresSchemaCatalogState { + table_names: std::vec::Vec, + columns: std::vec::Vec, + keys: std::vec::Vec, + indexes: std::vec::Vec, +} + +/// Loads and validates the current PostgreSQL catalog for resources owned by `ks-store`. +pub(crate) async fn load_postgres_schema_compatibility( + pool: &sqlx::PgPool, +) -> ks_core::Result { + let table_names_result = expected_postgres_table_names(); + let expected_table_names = match table_names_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let key_contracts_result = expected_postgres_key_contracts(); + let expected_keys = match key_contracts_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let index_contracts_result = expected_postgres_index_contracts(); + let expected_indexes = match index_contracts_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actual_tables_result = load_managed_table_names(pool, &expected_table_names).await; + let actual_tables = match actual_tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actual_columns_result = load_managed_columns(pool, &expected_table_names).await; + let actual_columns = match actual_columns_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actual_keys_result = load_managed_keys(pool, &expected_table_names).await; + let actual_keys = match actual_keys_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_index_names = expected_indexes + .iter() + .map(|index| return index.index_name.clone()) + .collect::>(); + let actual_indexes_result = load_managed_indexes(pool, &expected_index_names).await; + let actual_indexes = match actual_indexes_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return validate_postgres_schema_compatibility( + &PostgresSchemaCatalogState { + table_names: actual_tables, + columns: actual_columns, + keys: actual_keys, + indexes: actual_indexes, + }, + &expected_keys, + &expected_indexes, + ); +} + +fn expected_postgres_column_contracts() -> ks_core::Result> +{ + let mut contracts = std::vec::Vec::new(); + for statement in crate::store_table_create_statements() { + let parsed_result = parse_table_contract(statement); + let parsed = match parsed_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + contracts.extend(parsed); + } + return std::result::Result::Ok(contracts); +} + +fn expected_postgres_table_names() -> ks_core::Result> { + let contracts_result = expected_postgres_column_contracts(); + let contracts = match contracts_result { + 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 contract in contracts { + if !names.contains(&contract.table_name) { + names.push(contract.table_name); + } + } + names.sort(); + return std::result::Result::Ok(names); +} + +fn expected_postgres_key_contracts() -> ks_core::Result> { + let mut contracts = std::vec::Vec::new(); + for statement in store_schema_statements() { + let contract_result = parse_key_contract(statement); + let contract = match contract_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if let std::option::Option::Some(value) = contract { + contracts.push(value); + } + } + contracts.sort_by(|left, right| return left.constraint_name.cmp(&right.constraint_name)); + return std::result::Result::Ok(contracts); +} + +fn expected_postgres_index_contracts() -> ks_core::Result> +{ + let mut contracts = std::vec::Vec::new(); + for statement in store_schema_statements() { + let contract_result = parse_index_contract(statement); + let contract = match contract_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if let std::option::Option::Some(value) = contract { + contracts.push(value); + } + } + let keys_result = expected_postgres_key_contracts(); + let keys = match keys_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + for key in keys { + if key.key_kind != "primary" { + continue; + } + contracts.push(PostgresIndexContractSpec { + table_name: key.table_name, + index_name: key.constraint_name, + unique: true, + access_method: "btree".to_string(), + expressions: key.columns, + predicate: std::option::Option::None, + }); + } + contracts.sort_by(|left, right| return left.index_name.cmp(&right.index_name)); + if contracts.len() != crate::expected_postgres_index_names().len() { + return std::result::Result::Err(schema_contract_error()); + } + for expected_name in crate::expected_postgres_index_names() { + if !contracts.iter().any(|contract| return contract.index_name == *expected_name) { + return std::result::Result::Err(schema_contract_error()); + } + } + return std::result::Result::Ok(contracts); +} + +fn store_schema_statements() -> std::vec::Vec<&'static str> { + let mut statements = crate::raw_store_schema_statements(); + statements.extend(crate::core_store_schema_statements()); + statements.extend(crate::decode_store_schema_statements()); + return statements; +} + +async fn load_managed_table_names( + pool: &sqlx::PgPool, + expected_table_names: &[std::string::String], +) -> ks_core::Result> { + let query_result = sqlx::query_scalar::( + "SELECT table_name::TEXT FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name = ANY($1::TEXT[]) ORDER BY table_name", + ) + .bind(expected_table_names) + .fetch_all(pool) + .await; + return match query_result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(catalog_query_error(error)), + }; +} + +async fn load_managed_columns( + pool: &sqlx::PgPool, + expected_table_names: &[std::string::String], +) -> ks_core::Result> { + let rows_result = sqlx::query( + "SELECT table_name::TEXT AS table_name, column_name::TEXT AS column_name, udt_name::TEXT AS udt_name, (is_nullable = 'YES') AS nullable, (column_default IS NOT NULL) AS has_default, numeric_precision::INTEGER AS numeric_precision, numeric_scale::INTEGER AS numeric_scale FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ANY($1::TEXT[]) ORDER BY table_name, ordinal_position", + ) + .bind(expected_table_names) + .fetch_all(pool) + .await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_query_error(error)); + }, + }; + let mut columns = std::vec::Vec::new(); + for row in rows { + let state_result = column_catalog_state_from_row(&row); + let state = match state_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + columns.push(state); + } + return std::result::Result::Ok(columns); +} + +async fn load_managed_keys( + pool: &sqlx::PgPool, + expected_table_names: &[std::string::String], +) -> ks_core::Result> { + let rows_result = sqlx::query( + "SELECT rel.relname::TEXT AS table_name, con.conname::TEXT AS constraint_name, CASE con.contype WHEN 'p' THEN 'primary' WHEN 'f' THEN 'foreign' ELSE 'unsupported' END::TEXT AS key_kind, ARRAY(SELECT att.attname::TEXT FROM unnest(con.conkey) WITH ORDINALITY AS key_part(attnum, ord) JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = key_part.attnum ORDER BY key_part.ord)::TEXT[] AS columns, ref.relname::TEXT AS referenced_table_name, CASE WHEN con.contype = 'f' THEN ARRAY(SELECT att.attname::TEXT FROM unnest(con.confkey) WITH ORDINALITY AS key_part(attnum, ord) JOIN pg_attribute att ON att.attrelid = con.confrelid AND att.attnum = key_part.attnum ORDER BY key_part.ord)::TEXT[] ELSE ARRAY[]::TEXT[] END AS referenced_columns, CASE con.confdeltype WHEN 'c' THEN 'cascade' WHEN 'n' THEN 'set_null' WHEN 'd' THEN 'set_default' WHEN 'r' THEN 'restrict' ELSE 'no_action' END::TEXT AS delete_action, CASE con.confupdtype WHEN 'c' THEN 'cascade' WHEN 'n' THEN 'set_null' WHEN 'd' THEN 'set_default' WHEN 'r' THEN 'restrict' ELSE 'no_action' END::TEXT AS update_action, con.condeferrable AS deferrable, con.condeferred AS initially_deferred FROM pg_constraint con JOIN pg_class rel ON rel.oid = con.conrelid JOIN pg_namespace ns ON ns.oid = rel.relnamespace LEFT JOIN pg_class ref ON ref.oid = con.confrelid WHERE ns.nspname = current_schema() AND rel.relname = ANY($1::TEXT[]) AND con.contype IN ('p', 'f') ORDER BY rel.relname, con.conname", + ) + .bind(expected_table_names) + .fetch_all(pool) + .await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_query_error(error)); + }, + }; + let mut keys = std::vec::Vec::new(); + for row in rows { + let state_result = key_catalog_state_from_row(&row); + let state = match state_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + keys.push(state); + } + return std::result::Result::Ok(keys); +} + +async fn load_managed_indexes( + pool: &sqlx::PgPool, + expected_index_names: &[std::string::String], +) -> ks_core::Result> { + let rows_result = sqlx::query( + "SELECT tablename::TEXT AS table_name, indexname::TEXT AS index_name, indexdef::TEXT AS index_definition FROM pg_indexes WHERE schemaname = current_schema() AND indexname = ANY($1::TEXT[]) ORDER BY indexname", + ) + .bind(expected_index_names) + .fetch_all(pool) + .await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_query_error(error)); + }, + }; + let mut indexes = std::vec::Vec::new(); + for row in rows { + let table_name = match row.try_get::("table_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let index_name = match row.try_get::("index_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let index_definition = match row.try_get::("index_definition") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let parsed_result = parse_index_contract(index_definition.as_str()); + let parsed = match parsed_result { + std::result::Result::Ok(std::option::Option::Some(value)) => value, + std::result::Result::Ok(std::option::Option::None) => { + return std::result::Result::Err(schema_contract_error()); + }, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if parsed.table_name != table_name || parsed.index_name != index_name { + return std::result::Result::Err(schema_contract_error()); + } + indexes.push(parsed); + } + return std::result::Result::Ok(indexes); +} + +fn validate_postgres_schema_compatibility( + actual: &PostgresSchemaCatalogState, + expected_keys: &[PostgresKeyContractSpec], + expected_indexes: &[PostgresIndexContractSpec], +) -> ks_core::Result { + let expected_columns_result = expected_postgres_column_contracts(); + let expected_columns = match expected_columns_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_tables_result = expected_postgres_table_names(); + let expected_tables = match expected_tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_table_count = match usize_to_u32(expected_tables.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_column_count = match usize_to_u32(expected_columns.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_key_count = match usize_to_u32(expected_keys.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_index_count = match usize_to_u32(expected_indexes.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut missing_table_names = std::vec::Vec::new(); + for table_name in &expected_tables { + if !actual.table_names.contains(table_name) { + missing_table_names.push(table_name.clone()); + } + } + let missing_table_count = match usize_to_u32(missing_table_names.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let compatible_table_count = expected_table_count.saturating_sub(missing_table_count); + let mut compatible_column_count = 0_u32; + for expected in &expected_columns { + if missing_table_names.contains(&expected.table_name) { + continue; + } + let actual_column = actual.columns.iter().find(|candidate| { + return candidate.table_name == expected.table_name + && candidate.column_name == expected.column_name; + }); + let actual_column = match actual_column { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + if actual_column.udt_name != expected.udt_name + || actual_column.nullable != expected.nullable + || (expected.default_required && !actual_column.has_default) + || !numeric_shape_is_compatible(expected, actual_column) + { + return std::result::Result::Err(schema_contract_error()); + } + compatible_column_count = compatible_column_count.saturating_add(1); + } + for actual_column in &actual.columns { + if !actual.table_names.contains(&actual_column.table_name) { + continue; + } + let expected = expected_columns.iter().find(|candidate| { + return candidate.table_name == actual_column.table_name + && candidate.column_name == actual_column.column_name; + }); + if expected.is_none() && !actual_column.nullable && !actual_column.has_default { + return std::result::Result::Err(schema_contract_error()); + } + } + let mut compatible_key_count = 0_u32; + for expected in expected_keys { + if missing_table_names.contains(&expected.table_name) { + continue; + } + let actual_key = actual.keys.iter().find(|candidate| { + return candidate.table_name == expected.table_name + && candidate.constraint_name == expected.constraint_name; + }); + let actual_key = match actual_key { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + if actual_key != expected { + return std::result::Result::Err(schema_contract_error()); + } + compatible_key_count = compatible_key_count.saturating_add(1); + } + let mut compatible_index_count = 0_u32; + for expected in expected_indexes { + if missing_table_names.contains(&expected.table_name) { + continue; + } + let actual_index = actual.indexes.iter().find(|candidate| { + return candidate.table_name == expected.table_name + && candidate.index_name == expected.index_name; + }); + let actual_index = match actual_index { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + if actual_index != expected { + return std::result::Result::Err(schema_contract_error()); + } + compatible_index_count = compatible_index_count.saturating_add(1); + } + return std::result::Result::Ok(crate::PostgresSchemaCompatibilitySummary { + expected_table_count, + compatible_table_count, + expected_column_count, + compatible_column_count, + expected_key_count, + compatible_key_count, + expected_index_count, + compatible_index_count, + missing_table_names, + }); +} + +/// Builds additive-only SQL statements for managed resources absent from the current catalog. +pub(crate) async fn load_postgres_schema_addition_statements( + pool: &sqlx::PgPool, +) -> ks_core::Result> { + let expected_tables_result = expected_postgres_table_names(); + let expected_tables = match expected_tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_keys_result = expected_postgres_key_contracts(); + let expected_keys = match expected_keys_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_indexes_result = expected_postgres_index_contracts(); + let expected_indexes = match expected_indexes_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actual_tables_result = load_managed_table_names(pool, &expected_tables).await; + let actual_tables = match actual_tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actual_columns_result = load_managed_columns(pool, &expected_tables).await; + let actual_columns = match actual_columns_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actual_keys_result = load_managed_keys(pool, &expected_tables).await; + let actual_keys = match actual_keys_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let expected_index_names = expected_indexes + .iter() + .map(|index| return index.index_name.clone()) + .collect::>(); + let actual_indexes_result = load_managed_indexes(pool, &expected_index_names).await; + let actual_indexes = match actual_indexes_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return build_postgres_schema_addition_statements( + &PostgresSchemaCatalogState { + table_names: actual_tables, + columns: actual_columns, + keys: actual_keys, + indexes: actual_indexes, + }, + &expected_keys, + &expected_indexes, + ); +} + +fn build_postgres_schema_addition_statements( + actual: &PostgresSchemaCatalogState, + expected_keys: &[PostgresKeyContractSpec], + expected_indexes: &[PostgresIndexContractSpec], +) -> ks_core::Result> { + let compatibility_result = + validate_postgres_schema_compatibility(actual, expected_keys, expected_indexes); + let compatibility = match compatibility_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if compatibility.is_complete() { + return std::result::Result::Ok(std::vec::Vec::new()); + } + let expected_columns_result = expected_postgres_column_contracts(); + let expected_columns = match expected_columns_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut statements = std::vec::Vec::new(); + for table_statement in crate::store_table_create_statements() { + let contracts_result = parse_table_contract(table_statement); + let contracts = match contracts_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let table_name = match contracts.first() { + std::option::Option::Some(value) => value.table_name.as_str(), + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + if compatibility.missing_table_names.iter().any(|value| return value == table_name) { + statements.push(table_statement.to_string()); + } + } + for expected in &expected_columns { + if compatibility.missing_table_names.contains(&expected.table_name) { + continue; + } + let present = actual.columns.iter().any(|candidate| { + return candidate.table_name == expected.table_name + && candidate.column_name == expected.column_name; + }); + if !present { + statements.push(format!( + "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {};", + expected.table_name, expected.definition_sql + )); + } + } + for statement in store_schema_statements() { + let contains_constraint = statement.lines().map(str::trim).any(|line| { + return line.starts_with("ALTER TABLE ") && line.contains(" ADD CONSTRAINT "); + }); + if contains_constraint { + statements.push(statement.to_string()); + } + } + for statement in store_schema_statements() { + let index_result = parse_index_contract(statement); + let index = match index_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if index.is_some() { + statements.push(statement.to_string()); + } + } + return std::result::Result::Ok(statements); +} + +fn column_catalog_state_from_row( + row: &sqlx::postgres::PgRow, +) -> ks_core::Result { + let table_name = match row.try_get::("table_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let column_name = match row.try_get::("column_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let udt_name = match row.try_get::("udt_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let nullable = match row.try_get::("nullable") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let has_default = match row.try_get::("has_default") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let numeric_precision = match row.try_get::, _>("numeric_precision") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let numeric_scale = match row.try_get::, _>("numeric_scale") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + return std::result::Result::Ok(PostgresColumnCatalogState { + table_name, + column_name, + udt_name, + nullable, + has_default, + numeric_precision, + numeric_scale, + }); +} + +fn key_catalog_state_from_row( + row: &sqlx::postgres::PgRow, +) -> ks_core::Result { + let table_name = match row_string(row, "table_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let constraint_name = match row_string(row, "constraint_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let key_kind = match row_string(row, "key_kind") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let columns = match row.try_get::, _>("columns") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let referenced_table_name = + match row.try_get::, _>("referenced_table_name") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let referenced_columns = + match row.try_get::, _>("referenced_columns") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let delete_action = match row_string(row, "delete_action") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let update_action = match row_string(row, "update_action") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let deferrable = match row.try_get::("deferrable") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + let initially_deferred = match row.try_get::("initially_deferred") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(catalog_mapping_error(error)); + }, + }; + return std::result::Result::Ok(PostgresKeyContractSpec { + table_name, + constraint_name, + key_kind, + columns, + referenced_table_name, + referenced_columns, + delete_action, + update_action, + deferrable, + initially_deferred, + }); +} + +fn row_string(row: &sqlx::postgres::PgRow, field: &str) -> ks_core::Result { + return match row.try_get::(field) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(catalog_mapping_error(error)), + }; +} + +fn parse_table_contract(sql: &str) -> ks_core::Result> { + let mut table_name = std::option::Option::None; + let mut inside_table = false; + let mut contracts = std::vec::Vec::new(); + for line in sql.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("CREATE TABLE IF NOT EXISTS ") { + let name_result = parse_table_name(trimmed); + table_name = match name_result { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + inside_table = true; + continue; + } + if !inside_table { + continue; + } + if trimmed == ");" { + inside_table = false; + continue; + } + if trimmed.is_empty() || trimmed.starts_with("--") { + continue; + } + let table_name = match &table_name { + std::option::Option::Some(value) => value.clone(), + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let column_result = parse_column_contract(table_name, trimmed); + let column = match column_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + contracts.push(column); + } + if inside_table || table_name.is_none() || contracts.is_empty() { + return std::result::Result::Err(schema_contract_error()); + } + return std::result::Result::Ok(contracts); +} + +fn parse_table_name(line: &str) -> ks_core::Result { + let prefix = "CREATE TABLE IF NOT EXISTS "; + let suffix = " ("; + let remainder = match line.strip_prefix(prefix) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let name = match remainder.strip_suffix(suffix) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + if name.trim().is_empty() { + return std::result::Result::Err(schema_contract_error()); + } + return std::result::Result::Ok(name.to_string()); +} + +fn parse_column_contract( + table_name: std::string::String, + line: &str, +) -> ks_core::Result { + let definition = line.trim_end_matches(','); + let mut parts = definition.split_whitespace(); + let column_name = match parts.next() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let sql_type = match parts.next() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let type_result = postgres_udt_contract(sql_type); + let (udt_name, numeric_precision, numeric_scale, serial_not_null) = match type_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(PostgresColumnContractSpec { + table_name, + column_name: column_name.to_string(), + udt_name: udt_name.to_string(), + nullable: !serial_not_null && !definition.contains(" NOT NULL"), + default_required: serial_not_null || definition.contains(" DEFAULT "), + numeric_precision, + numeric_scale, + definition_sql: definition.to_string(), + }); +} + +fn parse_key_contract(sql: &str) -> ks_core::Result> { + let definition = match sql + .lines() + .map(str::trim) + .find(|line| return line.contains("ALTER TABLE ") && line.contains(" ADD CONSTRAINT ")) + { + std::option::Option::Some(value) => value.trim_end_matches(';'), + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + if !definition.contains(" PRIMARY KEY ") && !definition.contains(" FOREIGN KEY ") { + return std::result::Result::Ok(std::option::Option::None); + } + let remainder = match definition.strip_prefix("ALTER TABLE ") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let (table_name, after_table) = match remainder.split_once(" ADD CONSTRAINT ") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let (constraint_name, constraint_definition) = match after_table.split_once(' ') { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + if let std::option::Option::Some(primary) = constraint_definition.strip_prefix("PRIMARY KEY ") { + let columns_result = parse_parenthesized_list(primary); + let (columns, tail) = match columns_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !tail.trim().is_empty() { + return std::result::Result::Err(schema_contract_error()); + } + return std::result::Result::Ok(std::option::Option::Some(PostgresKeyContractSpec { + table_name: table_name.to_string(), + constraint_name: constraint_name.to_string(), + key_kind: "primary".to_string(), + columns, + referenced_table_name: std::option::Option::None, + referenced_columns: std::vec::Vec::new(), + delete_action: "no_action".to_string(), + update_action: "no_action".to_string(), + deferrable: false, + initially_deferred: false, + })); + } + let foreign = match constraint_definition.strip_prefix("FOREIGN KEY ") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let local_result = parse_parenthesized_list(foreign); + let (columns, after_local) = match local_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let reference = match after_local.trim().strip_prefix("REFERENCES ") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let reference_open = match reference.find('(') { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let referenced_table_name = reference[..reference_open].trim(); + let reference_result = parse_parenthesized_list(&reference[reference_open..]); + let (referenced_columns, after_reference) = match reference_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let actions_result = parse_reference_actions(after_reference.as_str()); + let (delete_action, update_action) = match actions_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(std::option::Option::Some(PostgresKeyContractSpec { + table_name: table_name.to_string(), + constraint_name: constraint_name.to_string(), + key_kind: "foreign".to_string(), + columns, + referenced_table_name: std::option::Option::Some(referenced_table_name.to_string()), + referenced_columns, + delete_action, + update_action, + deferrable: false, + initially_deferred: false, + })); +} + +fn parse_reference_actions( + value: &str, +) -> ks_core::Result<(std::string::String, std::string::String)> { + let normalized = normalize_sql_fragment(value); + let delete_action = if normalized.contains("on delete cascade") { + "cascade" + } else if normalized.contains("on delete set null") { + "set_null" + } else if normalized.contains("on delete set default") { + "set_default" + } else if normalized.contains("on delete restrict") { + "restrict" + } else { + "no_action" + }; + let update_action = if normalized.contains("on update cascade") { + "cascade" + } else if normalized.contains("on update set null") { + "set_null" + } else if normalized.contains("on update set default") { + "set_default" + } else if normalized.contains("on update restrict") { + "restrict" + } else { + "no_action" + }; + if normalized.contains("deferrable") { + return std::result::Result::Err(schema_contract_error()); + } + return std::result::Result::Ok((delete_action.to_string(), update_action.to_string())); +} + +fn parse_index_contract( + sql: &str, +) -> ks_core::Result> { + let definition = match sql.lines().map(str::trim).find(|line| { + return line.starts_with("CREATE INDEX ") || line.starts_with("CREATE UNIQUE INDEX "); + }) { + std::option::Option::Some(value) => value.trim_end_matches(';'), + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + let (unique, after_create) = + if let std::option::Option::Some(value) = definition.strip_prefix("CREATE UNIQUE INDEX ") { + (true, value) + } else if let std::option::Option::Some(value) = definition.strip_prefix("CREATE INDEX ") { + (false, value) + } else { + return std::result::Result::Err(schema_contract_error()); + }; + let after_optional = match after_create.strip_prefix("IF NOT EXISTS ") { + std::option::Option::Some(value) => value, + std::option::Option::None => after_create, + }; + let (index_name, after_name) = match after_optional.split_once(" ON ") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let open_index = match after_name.find('(') { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let table_and_method = after_name[..open_index].trim(); + let (table_token, access_method) = if let std::option::Option::Some((table, method)) = + table_and_method.split_once(" USING ") + { + (table.trim(), method.trim().to_ascii_lowercase()) + } else { + (table_and_method, "btree".to_string()) + }; + if access_method != "btree" { + return std::result::Result::Err(schema_contract_error()); + } + let keys_result = parse_parenthesized_expression_list(&after_name[open_index..]); + let (expressions, tail) = match keys_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let predicate = if tail.trim().is_empty() { + std::option::Option::None + } else { + let predicate_text = match tail.trim().strip_prefix("WHERE ") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + std::option::Option::Some(normalize_predicate(predicate_text)) + }; + return std::result::Result::Ok(std::option::Option::Some(PostgresIndexContractSpec { + table_name: normalize_relation_name(table_token), + index_name: normalize_identifier(index_name), + unique, + access_method, + expressions, + predicate, + })); +} + +fn parse_parenthesized_list( + value: &str, +) -> ks_core::Result<(std::vec::Vec, std::string::String)> { + let expression_result = parse_parenthesized_expression_list(value); + let (expressions, tail) = match expression_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut identifiers = std::vec::Vec::new(); + for expression in expressions { + if expression.is_empty() + || expression.contains(' ') + || expression.contains('(') + || expression.contains(')') + { + return std::result::Result::Err(schema_contract_error()); + } + identifiers.push(normalize_identifier(expression.as_str())); + } + return std::result::Result::Ok((identifiers, tail)); +} + +fn parse_parenthesized_expression_list( + value: &str, +) -> ks_core::Result<(std::vec::Vec, std::string::String)> { + let trimmed = value.trim(); + if !trimmed.starts_with('(') { + return std::result::Result::Err(schema_contract_error()); + } + let close = match matching_parenthesis_index(trimmed) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(schema_contract_error()), + }; + let inner = &trimmed[1..close]; + let raw_expressions = split_top_level_commas(inner); + if raw_expressions.is_empty() { + return std::result::Result::Err(schema_contract_error()); + } + let expressions = raw_expressions + .into_iter() + .map(|expression| return normalize_index_expression(expression.as_str())) + .collect::>(); + return std::result::Result::Ok((expressions, trimmed[(close + 1)..].trim().to_string())); +} + +fn matching_parenthesis_index(value: &str) -> std::option::Option { + let mut depth = 0_i32; + let mut in_string = false; + let chars = value.char_indices().collect::>(); + let mut index = 0_usize; + while index < chars.len() { + let (byte_index, character) = chars[index]; + if character == '\'' { + if in_string && index + 1 < chars.len() && chars[index + 1].1 == '\'' { + index += 2; + continue; + } + in_string = !in_string; + } else if !in_string && character == '(' { + depth += 1; + } else if !in_string && character == ')' { + depth -= 1; + if depth == 0 { + return std::option::Option::Some(byte_index); + } + if depth < 0 { + return std::option::Option::None; + } + } + index += 1; + } + return std::option::Option::None; +} + +fn split_top_level_commas(value: &str) -> std::vec::Vec { + let mut values = std::vec::Vec::new(); + let mut current = std::string::String::new(); + let mut depth = 0_i32; + let mut in_string = false; + let chars = value.chars().collect::>(); + let mut index = 0_usize; + while index < chars.len() { + let character = chars[index]; + if character == '\'' { + current.push(character); + if in_string && index + 1 < chars.len() && chars[index + 1] == '\'' { + current.push(chars[index + 1]); + index += 2; + continue; + } + in_string = !in_string; + } else if !in_string && character == '(' { + depth += 1; + current.push(character); + } else if !in_string && character == ')' { + depth -= 1; + current.push(character); + } else if !in_string && character == ',' && depth == 0 { + values.push(current.trim().to_string()); + current.clear(); + } else { + current.push(character); + } + index += 1; + } + if !current.trim().is_empty() { + values.push(current.trim().to_string()); + } + return values; +} + +fn normalize_index_expression(value: &str) -> std::string::String { + let normalized = normalize_sql_fragment(value); + let normalized = normalized.replace(" desc nulls first", " desc"); + let normalized = normalized.replace(" asc nulls last", " asc"); + return match normalized.strip_suffix(" asc") { + std::option::Option::Some(value) => value.to_string(), + std::option::Option::None => normalized, + }; +} + +fn normalize_predicate(value: &str) -> std::string::String { + let mut normalized = normalize_sql_fragment(value); + loop { + if normalized.starts_with('(') + && normalized.ends_with(')') + && matching_parenthesis_index(normalized.as_str()) + == std::option::Option::Some(normalized.len() - 1) + { + normalized = normalized[1..(normalized.len() - 1)].trim().to_string(); + continue; + } + break; + } + return normalized; +} + +fn normalize_sql_fragment(value: &str) -> std::string::String { + let mut normalized = value.replace('"', "").to_ascii_lowercase(); + normalized = normalized.replace("::text", ""); + normalized = normalized.split_whitespace().collect::>().join(" "); + normalized = normalized.replace(" (", "("); + normalized = normalized.replace("( ", "("); + normalized = normalized.replace(" )", ")"); + normalized = normalized.replace(" ,", ","); + normalized = normalized.replace(", ", ","); + return normalized; +} + +fn normalize_relation_name(value: &str) -> std::string::String { + let normalized = value.replace('"', ""); + return match normalized.rsplit('.').next() { + std::option::Option::Some(name) => name.trim().to_ascii_lowercase(), + std::option::Option::None => normalized.trim().to_ascii_lowercase(), + }; +} + +fn normalize_identifier(value: &str) -> std::string::String { + return value.trim().trim_matches('"').to_ascii_lowercase(); +} + +fn postgres_udt_contract( + sql_type: &str, +) -> ks_core::Result<(&'static str, std::option::Option, std::option::Option, bool)> { + return match sql_type { + "BIGSERIAL" => std::result::Result::Ok(( + "int8", + std::option::Option::None, + std::option::Option::None, + true, + )), + "BIGINT" => std::result::Result::Ok(( + "int8", + std::option::Option::None, + std::option::Option::None, + false, + )), + "INTEGER" => std::result::Result::Ok(( + "int4", + std::option::Option::None, + std::option::Option::None, + false, + )), + "TEXT" => std::result::Result::Ok(( + "text", + std::option::Option::None, + std::option::Option::None, + false, + )), + "BOOLEAN" => std::result::Result::Ok(( + "bool", + std::option::Option::None, + std::option::Option::None, + false, + )), + "JSONB" => std::result::Result::Ok(( + "jsonb", + std::option::Option::None, + std::option::Option::None, + false, + )), + "TIMESTAMPTZ" => std::result::Result::Ok(( + "timestamptz", + std::option::Option::None, + std::option::Option::None, + false, + )), + "NUMERIC(20,0)" => std::result::Result::Ok(( + "numeric", + std::option::Option::Some(20), + std::option::Option::Some(0), + false, + )), + _ => std::result::Result::Err(schema_contract_error()), + }; +} + +fn numeric_shape_is_compatible( + expected: &PostgresColumnContractSpec, + actual: &PostgresColumnCatalogState, +) -> bool { + if expected.numeric_precision.is_none() && expected.numeric_scale.is_none() { + return true; + } + return actual.numeric_precision == expected.numeric_precision + && actual.numeric_scale == expected.numeric_scale; +} + +fn usize_to_u32(value: usize) -> ks_core::Result { + return match u32::try_from(value) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_error) => std::result::Result::Err(schema_contract_error()), + }; +} + +fn catalog_query_error(error: sqlx::Error) -> ks_core::Error { + return ks_core::Error::db(format!("postgres managed schema catalog query failed: {error}")); +} + +fn catalog_mapping_error(error: sqlx::Error) -> ks_core::Error { + return ks_core::Error::db(format!("postgres managed schema catalog mapping failed: {error}")); +} + +fn schema_contract_error() -> ks_core::Error { + return ks_core::Error::db( + "postgres managed store schema is incompatible with the current contract", + ); +} + +#[cfg(test)] +mod tests { + fn compatible_catalog() -> super::PostgresSchemaCatalogState { + let expected_tables = result_or_panic(super::expected_postgres_table_names()); + let expected_columns = result_or_panic(super::expected_postgres_column_contracts()); + let expected_keys = result_or_panic(super::expected_postgres_key_contracts()); + let expected_indexes = result_or_panic(super::expected_postgres_index_contracts()); + let columns = expected_columns + .into_iter() + .map(|column| { + return super::PostgresColumnCatalogState { + table_name: column.table_name, + column_name: column.column_name, + udt_name: column.udt_name, + nullable: column.nullable, + has_default: column.default_required, + numeric_precision: column.numeric_precision, + numeric_scale: column.numeric_scale, + }; + }) + .collect(); + return super::PostgresSchemaCatalogState { + table_names: expected_tables, + columns, + keys: expected_keys, + indexes: expected_indexes, + }; + } + + fn validate( + actual: &super::PostgresSchemaCatalogState, + ) -> ks_core::Result { + let expected_keys = result_or_panic(super::expected_postgres_key_contracts()); + let expected_indexes = result_or_panic(super::expected_postgres_index_contracts()); + return super::validate_postgres_schema_compatibility( + actual, + &expected_keys, + &expected_indexes, + ); + } + + fn result_or_panic(result: ks_core::Result) -> T { + return match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected schema contract error: {error}"), + }; + } + + #[test] + fn embedded_schema_derives_sixteen_tables_two_hundred_forty_eight_columns_twenty_five_keys_and_seventy_nine_indexes() + { + let tables = result_or_panic(super::expected_postgres_table_names()); + let columns = result_or_panic(super::expected_postgres_column_contracts()); + let keys = result_or_panic(super::expected_postgres_key_contracts()); + let indexes = result_or_panic(super::expected_postgres_index_contracts()); + assert_eq!(tables.len(), 16); + assert_eq!(columns.len(), 248); + assert_eq!(keys.len(), 25); + assert_eq!(indexes.len(), 79); + } + + #[test] + fn compatible_schema_accepts_additive_nullable_columns() { + let mut actual = compatible_catalog(); + actual.columns.push(super::PostgresColumnCatalogState { + table_name: crate::RAW_TRANSACTIONS_TABLE_NAME.to_string(), + column_name: "future_n4_link".to_string(), + udt_name: "text".to_string(), + nullable: true, + has_default: false, + numeric_precision: std::option::Option::None, + numeric_scale: std::option::Option::None, + }); + let result = validate(&actual); + assert!(result.is_ok()); + } + + #[test] + fn missing_managed_table_is_reported_without_reclassifying_existing_tables_as_incompatible() { + let mut actual = compatible_catalog(); + let missing = crate::RAW_TRANSACTIONS_TABLE_NAME.to_string(); + actual.table_names.retain(|table| return table != &missing); + actual.columns.retain(|column| return column.table_name != missing); + actual.keys.retain(|key| return key.table_name != missing); + actual.indexes.retain(|index| return index.table_name != missing); + let result = validate(&actual); + let summary = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("missing table should be classified, not rejected: {error}") + }, + }; + assert!(!summary.is_complete()); + assert_eq!( + summary.missing_table_names, + vec![crate::RAW_TRANSACTIONS_TABLE_NAME.to_string()] + ); + } + + #[test] + fn existing_managed_table_classifies_missing_required_column_as_additive_drift() { + let mut actual = compatible_catalog(); + actual.columns.retain(|column| { + return !(column.table_name == crate::RAW_TRANSACTIONS_TABLE_NAME + && column.column_name == "signature"); + }); + let result = validate(&actual); + let summary = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("missing column should be additive drift: {error}") + }, + }; + assert!(!summary.is_complete()); + assert_eq!(summary.compatible_column_count + 1, summary.expected_column_count); + } + + #[test] + fn existing_managed_table_rejects_wrong_type_nullability_and_numeric_shape() { + let mut wrong_type = compatible_catalog(); + let type_column = wrong_type.columns.iter_mut().find(|column| { + return column.table_name == crate::RAW_TRANSACTIONS_TABLE_NAME + && column.column_name == "slot"; + }); + if let std::option::Option::Some(column) = type_column { + column.udt_name = "text".to_string(); + } + assert!(validate(&wrong_type).is_err()); + let mut wrong_nullability = compatible_catalog(); + let nullable_column = wrong_nullability.columns.iter_mut().find(|column| { + return column.table_name == crate::RAW_TRANSACTIONS_TABLE_NAME + && column.column_name == "signature"; + }); + if let std::option::Option::Some(column) = nullable_column { + column.nullable = true; + } + assert!(validate(&wrong_nullability).is_err()); + let mut wrong_numeric = compatible_catalog(); + let numeric_column = wrong_numeric.columns.iter_mut().find(|column| { + return column.table_name == crate::ACCOUNT_OBSERVATIONS_TABLE_NAME + && column.column_name == "lamports"; + }); + if let std::option::Option::Some(column) = numeric_column { + column.numeric_precision = std::option::Option::Some(19); + } + assert!(validate(&wrong_numeric).is_err()); + } + + #[test] + fn existing_managed_table_classifies_missing_key_as_additive_but_rejects_modified_key() { + let mut missing_primary = compatible_catalog(); + missing_primary + .keys + .retain(|key| return key.constraint_name != "pk_k_sol_raw_transactions"); + let missing_result = validate(&missing_primary); + let summary = match missing_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("missing key should be additive drift: {error}") + }, + }; + assert!(!summary.is_complete()); + assert_eq!(summary.compatible_key_count + 1, summary.expected_key_count); + let mut wrong_foreign = compatible_catalog(); + let foreign = wrong_foreign + .keys + .iter_mut() + .find(|key| return key.constraint_name == "fk_k_sol_core_transactions_raw_transaction"); + if let std::option::Option::Some(key) = foreign { + key.delete_action = "cascade".to_string(); + } + assert!(validate(&wrong_foreign).is_err()); + } + + #[test] + fn existing_managed_table_classifies_missing_index_as_additive_but_rejects_modified_index() { + let mut missing = compatible_catalog(); + missing + .indexes + .retain(|index| return index.index_name != "ix_k_sol_core_transactions_slot"); + let missing_result = validate(&missing); + let summary = match missing_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("missing index should be additive drift: {error}") + }, + }; + assert!(!summary.is_complete()); + assert_eq!(summary.compatible_index_count + 1, summary.expected_index_count); + let mut wrong = compatible_catalog(); + let index = wrong + .indexes + .iter_mut() + .find(|index| return index.index_name == "ix_k_sol_core_transactions_slot"); + if let std::option::Option::Some(value) = index { + value.expressions = vec!["created_at".to_string()]; + } + assert!(validate(&wrong).is_err()); + } + + #[test] + fn managed_schema_requires_defaults_needed_by_current_writes() { + let mut actual = compatible_catalog(); + let column = actual.columns.iter_mut().find(|candidate| { + return candidate.table_name == crate::RAW_TRANSACTIONS_TABLE_NAME + && candidate.column_name == "created_at"; + }); + if let std::option::Option::Some(value) = column { + value.has_default = false; + } + assert!(validate(&actual).is_err()); + } + + #[test] + fn additive_required_column_without_default_is_incompatible_with_current_inserts() { + let mut actual = compatible_catalog(); + actual.columns.push(super::PostgresColumnCatalogState { + table_name: crate::RAW_TRANSACTIONS_TABLE_NAME.to_string(), + column_name: "future_required_value".to_string(), + udt_name: "text".to_string(), + nullable: false, + has_default: false, + numeric_precision: std::option::Option::None, + numeric_scale: std::option::Option::None, + }); + assert!(validate(&actual).is_err()); + } + + #[test] + fn additive_plan_adds_missing_column_without_modifying_existing_columns() { + let mut actual = compatible_catalog(); + actual.columns.retain(|column| { + return !(column.table_name == crate::RAW_TRANSACTIONS_TABLE_NAME + && column.column_name == "signature"); + }); + let expected_keys = result_or_panic(super::expected_postgres_key_contracts()); + let expected_indexes = result_or_panic(super::expected_postgres_index_contracts()); + let statements = result_or_panic(super::build_postgres_schema_addition_statements( + &actual, + &expected_keys, + &expected_indexes, + )); + assert!(statements.iter().any(|statement| { + return statement == "ALTER TABLE k_sol_raw_transactions ADD COLUMN IF NOT EXISTS signature TEXT NOT NULL;"; + })); + assert!(!statements.iter().any(|statement| { + let normalized = statement.to_ascii_uppercase(); + return normalized.contains(" DROP COLUMN ") + || normalized.contains(" ALTER COLUMN ") + || normalized.contains(" RENAME COLUMN "); + })); + } + + #[test] + fn additive_plan_creates_missing_table_and_reapplies_idempotent_constraints_and_indexes() { + let mut actual = compatible_catalog(); + let missing = crate::RAW_TRANSACTIONS_TABLE_NAME.to_string(); + actual.table_names.retain(|table| return table != &missing); + actual.columns.retain(|column| return column.table_name != missing); + actual.keys.retain(|key| return key.table_name != missing); + actual.indexes.retain(|index| return index.table_name != missing); + let expected_keys = result_or_panic(super::expected_postgres_key_contracts()); + let expected_indexes = result_or_panic(super::expected_postgres_index_contracts()); + let statements = result_or_panic(super::build_postgres_schema_addition_statements( + &actual, + &expected_keys, + &expected_indexes, + )); + assert!(statements.iter().any(|statement| { + return statement.contains("CREATE TABLE IF NOT EXISTS k_sol_raw_transactions"); + })); + assert!(statements.iter().any(|statement| { + return statement.contains("ADD CONSTRAINT pk_k_sol_raw_transactions PRIMARY KEY"); + })); + assert!(statements.iter().any(|statement| { + return statement + .contains("CREATE UNIQUE INDEX IF NOT EXISTS ux_k_sol_raw_transactions_signature"); + })); + assert!(!statements.iter().any(|statement| { + return statement + .starts_with("ALTER TABLE k_sol_raw_transactions ADD COLUMN IF NOT EXISTS "); + })); + } + + #[test] + fn additive_plan_repairs_missing_key_and_index_but_rejects_incompatible_existing_definition() { + let mut actual = compatible_catalog(); + actual + .keys + .retain(|key| return key.constraint_name != "pk_k_sol_raw_transactions"); + actual + .indexes + .retain(|index| return index.index_name != "ix_k_sol_core_transactions_slot"); + let expected_keys = result_or_panic(super::expected_postgres_key_contracts()); + let expected_indexes = result_or_panic(super::expected_postgres_index_contracts()); + let statements = result_or_panic(super::build_postgres_schema_addition_statements( + &actual, + &expected_keys, + &expected_indexes, + )); + assert!(statements.iter().any(|statement| { + return statement.contains("ADD CONSTRAINT pk_k_sol_raw_transactions PRIMARY KEY"); + })); + assert!(statements.iter().any(|statement| { + return statement + .contains("CREATE INDEX IF NOT EXISTS ix_k_sol_core_transactions_slot"); + })); + let mut incompatible = compatible_catalog(); + let column = incompatible.columns.iter_mut().find(|column| { + return column.table_name == crate::RAW_TRANSACTIONS_TABLE_NAME + && column.column_name == "slot"; + }); + if let std::option::Option::Some(value) = column { + value.udt_name = "text".to_string(); + } + assert!( + super::build_postgres_schema_addition_statements( + &incompatible, + &expected_keys, + &expected_indexes, + ) + .is_err() + ); + } + + #[test] + fn index_parser_normalizes_postgres_btree_schema_casts_and_predicates() { + let expected = result_or_panic(super::parse_index_contract( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_test ON k_sol_decode_events (processor_name, COALESCE(surface_code, '')) WHERE surface_code IS NOT NULL;", + )); + let actual = result_or_panic(super::parse_index_contract( + "CREATE UNIQUE INDEX ux_test ON public.k_sol_decode_events USING btree (processor_name, COALESCE(surface_code, ''::text)) WHERE (surface_code IS NOT NULL)", + )); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn optional_postgres_managed_schema_contract_from_env() { + let database_url = match std::env::var("KS_SECRET_POSTGRES_TEST_URL") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_error) => return, + }; + let _postgres_guard = crate::postgres_test_guard().await; + let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false); + let options = match options_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected options error: {error}"), + }; + let store_result = crate::PostgresStore::connect(options).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected connect error: {error}"), + }; + let schema_result = store.initialize_missing_store_schema().await; + if let std::result::Result::Err(error) = schema_result { + panic!("unexpected schema error: {error}"); + } + let compatibility_result = super::load_postgres_schema_compatibility(store.pool()).await; + let compatibility = match compatibility_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected compatibility error: {error}"), + }; + assert!(compatibility.is_complete()); + assert_eq!(compatibility.expected_table_count, 16); + assert_eq!(compatibility.expected_column_count, 248); + assert_eq!(compatibility.expected_key_count, 25); + assert_eq!(compatibility.expected_index_count, 79); + } +} diff --git a/ks-store/src/postgres/store.rs b/ks-store/src/postgres/store.rs index e7b7dcf..ed6535b 100644 --- a/ks-store/src/postgres/store.rs +++ b/ks-store/src/postgres/store.rs @@ -1,5 +1,5 @@ // file: ks-store/src/postgres/store.rs -// version: 13 +// version: 17 //! PostgreSQL store implementation kept behind the backend-agnostic `Store` facade. @@ -214,12 +214,29 @@ impl crate::PostgresStore { return crate::load_expected_index_counts(&self.pool).await; } - /// Applies each idempotent store schema once per invocation in dependency order. - pub(crate) async fn initialize_store_schema(&self) -> ks_core::Result<()> { - tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "schema_initialize", "initialize PostgreSQL store schema"); - let result = crate::apply_store_schema(&self.pool).await; + /// Verifies existing managed PostgreSQL tables, columns, keys, and indexes. + pub(crate) async fn verify_managed_schema_compatibility( + &self, + ) -> ks_core::Result { + let result = crate::load_postgres_schema_compatibility(&self.pool).await; + return match result { + std::result::Result::Ok(summary) => { + tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "schema_compatibility_verify", compatible = true, complete = summary.is_complete(), expected_tables = summary.expected_table_count, compatible_tables = summary.compatible_table_count, expected_columns = summary.expected_column_count, compatible_columns = summary.compatible_column_count, expected_keys = summary.expected_key_count, compatible_keys = summary.compatible_key_count, expected_indexes = summary.expected_index_count, compatible_indexes = summary.compatible_index_count, missing_tables = summary.missing_table_names.len(), "PostgreSQL managed schema compatibility verified"); + std::result::Result::Ok(summary) + }, + std::result::Result::Err(error) => { + tracing::error!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "schema_compatibility_verify", compatible = false, "PostgreSQL managed schema compatibility verification failed"); + std::result::Result::Err(error) + }, + }; + } + + /// Applies additive-only initialization for absent managed tables, columns, keys, and indexes. + pub(crate) async fn initialize_missing_store_schema(&self) -> ks_core::Result<()> { + tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "schema_initialize_missing", "initialize missing PostgreSQL store schema resources additively"); + let result = crate::apply_missing_store_schema(&self.pool).await; if result.is_ok() { - tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "schema_initialize", initialized = true, "PostgreSQL store schema initialized"); + tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "schema_initialize_missing", initialized = true, "missing PostgreSQL store schema resources initialized additively"); } return result; } @@ -282,6 +299,30 @@ impl crate::PostgresStore { )); } if available_count == expected_count { + let compatibility_result = self.verify_managed_schema_compatibility().await; + let compatibility = match compatibility_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_error) => { + return std::result::Result::Ok(crate::StoreMigrationSnapshot::new( + crate::StoreMigrationStatus::Drift, + std::option::Option::None, + std::vec::Vec::new(), + std::option::Option::Some(std::string::String::from( + "managed store schema is structurally incompatible", + )), + )); + }, + }; + if !compatibility.is_complete() { + return std::result::Result::Ok(crate::StoreMigrationSnapshot::new( + crate::StoreMigrationStatus::Drift, + std::option::Option::None, + std::vec::Vec::new(), + std::option::Option::Some(std::string::String::from( + "managed store schema is incomplete", + )), + )); + } return std::result::Result::Ok(crate::StoreMigrationSnapshot::new( crate::StoreMigrationStatus::Current, std::option::Option::Some(crate::STORE_SCHEMA_CONTRACT_VERSION.to_string()), @@ -329,31 +370,40 @@ impl crate::PostgresStore { }); } - /// Lists bounded raw transaction candidates enriched with Core and ledger diagnostics. + /// Lists one counted raw-transaction candidate block. pub(crate) async fn replay_transaction_candidates( &self, filter: &crate::ReplayTransactionFilter, - ) -> ks_core::Result> { - tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "replay_transaction_candidates", "query PostgreSQL replay transaction candidates"); - return crate::list_replay_transaction_candidates(&self.pool, filter).await; + page_request: &crate::PageRequest, + ) -> ks_core::Result> { + return crate::list_replay_transaction_candidates(&self.pool, filter, page_request).await; } - /// Lists bounded program summaries across top-level, inner and reliably linked logs. + /// Lists one counted program-summary block. pub(crate) async fn replay_program_summaries( &self, filter: &crate::ReplayProgramFilter, - ) -> ks_core::Result> { - tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "replay_program_summaries", "query PostgreSQL replay program summaries"); - return crate::list_replay_program_summaries(&self.pool, filter).await; + page_request: &crate::PageRequest, + ) -> ks_core::Result> { + return crate::list_replay_program_summaries(&self.pool, filter, page_request).await; } - /// Lists bounded mint, owner or account-key summaries from Core facts. + /// Lists one counted mint, owner or account-key summary block. pub(crate) async fn replay_entity_summaries( &self, filter: &crate::ReplayEntityFilter, - ) -> ks_core::Result> { - tracing::debug!(target: crate::TRACING_TARGET, backend = "postgres", domain = "ks-store.pg", action = "replay_entity_summaries", "query PostgreSQL replay entity summaries"); - return crate::list_replay_entity_summaries(&self.pool, filter).await; + page_request: &crate::PageRequest, + ) -> ks_core::Result> { + return crate::list_replay_entity_summaries(&self.pool, filter, page_request).await; + } + + /// Lists one counted materialized-output block using stable cursor pagination. + pub(crate) async fn materialized_output_page( + &self, + filter: &crate::MaterializedOutputPageFilter, + page_request: &crate::PageRequest, + ) -> ks_core::Result> { + return crate::list_materialized_output_page(&self.pool, filter, page_request).await; } /// Reads diagnostics for raw store resources without changing the schema. diff --git a/ks-store/src/store.rs b/ks-store/src/store.rs index cd101ce..df978df 100644 --- a/ks-store/src/store.rs +++ b/ks-store/src/store.rs @@ -1,5 +1,5 @@ // file: ks-store/src/store.rs -// version: 11 +// version: 14 //! Backend-agnostic store facade and connection ownership. @@ -211,38 +211,83 @@ impl crate::Store { }; } - /// Lists bounded replay transaction candidates. + /// Lists one counted replay-transaction block using stable cursor pagination. pub async fn replay_transaction_candidates( &self, filter: &crate::ReplayTransactionFilter, - ) -> ks_core::Result> { + page_request: &crate::PageRequest, + ) -> ks_core::Result> { return match &self.backend { StoreBackend::Disabled => std::result::Result::Err(disabled_store_error()), - StoreBackend::Postgres(store) => store.replay_transaction_candidates(filter).await, + StoreBackend::Postgres(store) => { + store.replay_transaction_candidates(filter, page_request).await + }, }; } - /// Lists bounded replay program summaries. + /// Lists one counted replay-program block using stable cursor pagination. pub async fn replay_program_summaries( &self, filter: &crate::ReplayProgramFilter, - ) -> ks_core::Result> { + page_request: &crate::PageRequest, + ) -> ks_core::Result> { return match &self.backend { StoreBackend::Disabled => std::result::Result::Err(disabled_store_error()), - StoreBackend::Postgres(store) => store.replay_program_summaries(filter).await, + StoreBackend::Postgres(store) => { + store.replay_program_summaries(filter, page_request).await + }, }; } - /// Lists bounded replay entity summaries. + /// Lists one counted replay-entity block using stable cursor pagination. pub async fn replay_entity_summaries( &self, filter: &crate::ReplayEntityFilter, - ) -> ks_core::Result> { + page_request: &crate::PageRequest, + ) -> ks_core::Result> { return match &self.backend { StoreBackend::Disabled => std::result::Result::Err(disabled_store_error()), - StoreBackend::Postgres(store) => store.replay_entity_summaries(filter).await, + StoreBackend::Postgres(store) => { + store.replay_entity_summaries(filter, page_request).await + }, }; } + + /// Lists one counted materialized-output block using stable cursor pagination. + pub async fn materialized_output_page( + &self, + filter: &crate::MaterializedOutputPageFilter, + page_request: &crate::PageRequest, + ) -> ks_core::Result> { + return match &self.backend { + StoreBackend::Disabled => std::result::Result::Err(disabled_store_error()), + StoreBackend::Postgres(store) => { + store.materialized_output_page(filter, page_request).await + }, + }; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ManagedSchemaOpenAction { + Ready, + InitializeMissing, +} + +fn managed_schema_open_action( + compatibility: &crate::PostgresSchemaCompatibilitySummary, + auto_initialize_schema: bool, +) -> ks_core::Result { + if compatibility.is_complete() { + return std::result::Result::Ok(ManagedSchemaOpenAction::Ready); + } + if auto_initialize_schema { + return std::result::Result::Ok(ManagedSchemaOpenAction::InitializeMissing); + } + return std::result::Result::Err(crate::storage_contract_error( + "store_schema_missing", + "managed store schema is incomplete and automatic initialization is disabled", + )); } async fn open_postgres(backend_options: serde_json::Value) -> ks_core::Result { @@ -275,14 +320,45 @@ async fn open_postgres(backend_options: serde_json::Value) -> ks_core::Result value, + std::result::Result::Err(_error) => { + return std::result::Result::Err(crate::storage_contract_error( + "store_schema_incompatible", + "managed store schema is incompatible with the current contract", + )); + }, + }; + let action_result = managed_schema_open_action(&compatibility_before, auto_initialize_schema); + let action = match action_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if action == ManagedSchemaOpenAction::InitializeMissing { + let initialize_result = store.initialize_missing_store_schema().await; if let std::result::Result::Err(_error) = initialize_result { return std::result::Result::Err(crate::storage_contract_error( "store_initialization_failed", "store schema initialization failed", )); } + let compatibility_after_result = store.verify_managed_schema_compatibility().await; + let compatibility_after = match compatibility_after_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_error) => { + return std::result::Result::Err(crate::storage_contract_error( + "store_schema_incompatible", + "managed store schema is incompatible after automatic initialization", + )); + }, + }; + if !compatibility_after.is_complete() { + return std::result::Result::Err(crate::storage_contract_error( + "store_initialization_failed", + "managed store schema remains incomplete after additive automatic initialization", + )); + } } let after = match store.known_resource_diagnostics().await { std::result::Result::Ok(value) => value, @@ -1060,6 +1136,78 @@ impl crate::DecodePipelineStore for crate::Store { #[cfg(test)] mod tests { + fn compatibility_summary( + missing_table_names: std::vec::Vec, + ) -> crate::PostgresSchemaCompatibilitySummary { + let missing_count = match u32::try_from(missing_table_names.len()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_error) => panic!("test missing table count must fit u32"), + }; + return crate::PostgresSchemaCompatibilitySummary { + expected_table_count: 16, + compatible_table_count: 16_u32.saturating_sub(missing_count), + expected_column_count: 248, + compatible_column_count: if missing_count == 0 { 248 } else { 240 }, + expected_key_count: 25, + compatible_key_count: if missing_count == 0 { 25 } else { 24 }, + expected_index_count: 79, + compatible_index_count: if missing_count == 0 { 79 } else { 75 }, + missing_table_names, + }; + } + + #[test] + fn complete_managed_schema_never_requests_automatic_initialization() { + let summary = compatibility_summary(std::vec::Vec::new()); + let enabled = super::managed_schema_open_action(&summary, true); + let disabled = super::managed_schema_open_action(&summary, false); + match enabled { + std::result::Result::Ok(value) => { + assert_eq!(value, super::ManagedSchemaOpenAction::Ready) + }, + std::result::Result::Err(error) => panic!("complete schema must be ready: {error}"), + } + match disabled { + std::result::Result::Ok(value) => { + assert_eq!(value, super::ManagedSchemaOpenAction::Ready) + }, + std::result::Result::Err(error) => panic!("complete schema must be ready: {error}"), + } + } + + #[test] + fn missing_managed_table_requires_auto_initialize_before_schema_creation() { + let summary = compatibility_summary(vec![crate::RAW_TRANSACTIONS_TABLE_NAME.to_string()]); + let disabled = super::managed_schema_open_action(&summary, false); + let enabled = super::managed_schema_open_action(&summary, true); + assert!(disabled.is_err()); + match enabled { + std::result::Result::Ok(value) => { + assert_eq!(value, super::ManagedSchemaOpenAction::InitializeMissing) + }, + std::result::Result::Err(error) => { + panic!("missing table must be initializable: {error}") + }, + } + } + + #[test] + fn additive_managed_schema_drift_requires_auto_initialize() { + let mut summary = compatibility_summary(std::vec::Vec::new()); + summary.compatible_column_count = summary.expected_column_count.saturating_sub(1); + let disabled = super::managed_schema_open_action(&summary, false); + let enabled = super::managed_schema_open_action(&summary, true); + assert!(disabled.is_err()); + match enabled { + std::result::Result::Ok(value) => { + assert_eq!(value, super::ManagedSchemaOpenAction::InitializeMissing) + }, + std::result::Result::Err(error) => { + panic!("missing additive resource must be initializable: {error}") + }, + } + } + #[test] fn backend_diagnostic_errors_are_sanitized_at_the_facade_boundary() { let result: ks_core::Result<()> = std::result::Result::Err(ks_core::Error::db( diff --git a/test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json b/test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json index 77b4c5b..6d4be56 100644 --- a/test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json +++ b/test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json @@ -123,7 +123,7 @@ }, { "id": "postgres_double_replay", - "environment": "postgres", + "environment": "store", "status": "not_run", "requiredEvidence": [ "postgres_tests",