v0.5.3-pre.005-fix010

This commit is contained in:
2026-08-14 14:40:36 +02:00
parent b6583bd9fb
commit 734a27f2ca
59 changed files with 4337 additions and 747 deletions

View File

@@ -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"

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md -->
<!-- version: 43 -->
<!-- version: 44 -->
# 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 daccounts 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 ;

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/V0_5_3_KS_STORE_NORMALIZATION_PLAN.md -->
<!-- version: 9 -->
<!-- version: 12 -->
# 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 nest 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 quaucune ressource/requête active de `ks-store` nutilise `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` nutilise `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.

View File

@@ -1,8 +1,24 @@
<!-- file: kb-app-demo-desktop/CHANGELOG.md -->
<!-- version: 66 -->
<!-- version: 71 -->
# 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é dune absence de requête ;
- corrige la préparation automatique des profils Execution Devnet afin quelle utilise elle aussi le `ks_store::Store` partagé par profil : louverture dune 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 quun 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 dobjets 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 ;

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/README.md -->
<!-- version: 23 -->
<!-- version: 24 -->
# 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. Lorsquune 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.

View File

@@ -1,11 +1,11 @@
<!-- file: kb-app-demo-desktop/TODO.md -->
<!-- version: 22 -->
<!-- version: 23 -->
# 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 lorsquelle 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.

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/USAGE.md -->
<!-- version: 24 -->
<!-- version: 25 -->
# 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 nexposent plus de champ `limit` éditable lorsquils 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 nest jamais saisi par lopérateur. Modifier un filtre serveur invalide les curseurs mémorisés et impose de recharger le premier bloc. Lorsquune 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 sapplique 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 linterface applicative frontend/backend. Elles ne sont pas des APIs Rust publiques pour les autres crates.

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/demo_decode_replay.html -->
<!-- version: 9 -->
<!-- version: 10 -->
<!DOCTYPE html>
<html lang="fr">
@@ -131,11 +131,13 @@
<label class="form-label" for="annotationSignatureInput">Journal des annotations — signature contient</label>
<input id="annotationSignatureInput" class="form-control font-monospace" placeholder="Filtre facultatif" />
</div>
<div>
<label class="form-label" for="annotationLimitInput">Limite</label>
<input id="annotationLimitInput" class="form-control" type="number" min="1" max="500" value="100" />
</div>
<button id="loadAnnotationsButton" class="btn btn-outline-primary" type="button">Charger les annotations</button>
<div class="btn-group" role="group" aria-label="Navigation des blocs Store dannotations">
<button id="annotationFirstBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Premier bloc</button>
<button id="annotationPreviousBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc précédent</button>
<button id="annotationNextBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc suivant</button>
</div>
<span id="annotationBlockSummary" class="badge text-bg-light border">Bloc — / —</span>
</div>
<p id="annotationJournalStatus" class="text-body-secondary mb-3">Annotations non chargées.</p>
<div class="table-responsive">

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/demo_execution_spl.html -->
<!-- version: 15 -->
<!-- version: 16 -->
<!DOCTYPE html>
<html lang="fr">
@@ -306,7 +306,12 @@
<h2 class="h5 mb-0">Journal métier SPL Token</h2>
<div class="form-text">Événements commités uniquement, sans solde final reconstruit ni OHLC.</div>
</div>
<button class="btn btn-sm btn-outline-primary" id="refreshTokenJournalButton" type="button">Actualiser</button>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-sm btn-outline-primary" id="refreshTokenJournalButton" type="button">Charger</button>
<button class="btn btn-sm btn-outline-secondary" data-store-block-navigation="true" disabled="" id="tokenJournalFirstBlockButton" type="button">Premier bloc</button>
<button class="btn btn-sm btn-outline-secondary" data-store-block-navigation="true" disabled="" id="tokenJournalPreviousBlockButton" type="button">Bloc précédent</button>
<button class="btn btn-sm btn-outline-secondary" data-store-block-navigation="true" disabled="" id="tokenJournalNextBlockButton" type="button">Bloc suivant</button>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-lg-6">
@@ -330,15 +335,15 @@
<option value="risk">risk</option>
</select>
</div>
<div class="col-6 col-lg-3">
<label class="form-label" for="tokenJournalLimitInput">Limite</label>
<input class="form-control" id="tokenJournalLimitInput" max="500" min="1" step="1" type="number" value="100" />
</div>
<div class="col-12">
<label class="form-label" for="tokenJournalOperationInput">Opération exacte</label>
<input class="form-control font-monospace" id="tokenJournalOperationInput" placeholder="transfer_checked" type="text" />
</div>
</div>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<span class="badge text-bg-light border" id="tokenJournalBlockSummary">Bloc 0 / 0 · 0 résultat · 500/bloc</span>
<span class="form-text">Le total de blocs est recalculé à chaque chargement ou navigation.</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead>
@@ -375,7 +380,12 @@
<h2 class="h5 mb-0">Journal lifecycle ATA</h2>
<div class="form-text">Création, réutilisation idempotente et récupération nested, sans duplication des CPI Token.</div>
</div>
<button class="btn btn-sm btn-outline-primary" id="refreshAtaJournalButton" type="button">Actualiser</button>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-sm btn-outline-primary" id="refreshAtaJournalButton" type="button">Charger</button>
<button class="btn btn-sm btn-outline-secondary" data-store-block-navigation="true" disabled="" id="ataJournalFirstBlockButton" type="button">Premier bloc</button>
<button class="btn btn-sm btn-outline-secondary" data-store-block-navigation="true" disabled="" id="ataJournalPreviousBlockButton" type="button">Bloc précédent</button>
<button class="btn btn-sm btn-outline-secondary" data-store-block-navigation="true" disabled="" id="ataJournalNextBlockButton" type="button">Bloc suivant</button>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-lg-6">
@@ -390,14 +400,14 @@
<label class="form-label" for="ataJournalAddressInput">ATA exact</label>
<input class="form-control font-monospace" id="ataJournalAddressInput" type="text" />
</div>
<div class="col-8 col-lg-4">
<div class="col-12 col-lg-6">
<label class="form-label" for="ataJournalOperationInput">Opération</label>
<input class="form-control font-monospace" id="ataJournalOperationInput" type="text" />
</div>
<div class="col-4 col-lg-2">
<label class="form-label" for="ataJournalLimitInput">Limite</label>
<input class="form-control" id="ataJournalLimitInput" max="500" min="1" type="number" value="100" />
</div>
</div>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<span class="badge text-bg-light border" id="ataJournalBlockSummary">Bloc 0 / 0 · 0 résultat · 500/bloc</span>
<span class="form-text">Le total de blocs est recalculé à chaque chargement ou navigation.</span>
</div>
<textarea class="form-control font-monospace" data-json-viewer="true" id="ataJournalOutput" readonly="" rows="14">{"title":"Journal lifecycle ATA","status":"idle","message":"Aucune lecture."}</textarea>
</div>

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/demo_store_core.html -->
<!-- version: 8 -->
<!-- version: 9 -->
<!DOCTYPE html>
<html lang="fr">
@@ -32,7 +32,7 @@
<div class="card-body">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<h1 class="h3 card-title mb-0">Core store Solana</h1>
<button id="refreshSqlCoreButton" class="btn btn-primary" type="button">Rafraîchir</button>
<button id="refreshStoreCoreButton" class="btn btn-primary" type="button">Rafraîchir</button>
</div>
<dl class="row mb-0">
<dt class="col-sm-3">Statut</dt>
@@ -94,4 +94,4 @@
<script type="module" src="ts/demo_store_core.ts" defer></script>
</body>
</html>
</html>

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/demo_store_diag.html -->
<!-- version: 7 -->
<!-- version: 8 -->
<!DOCTYPE html>
<html lang="fr">
@@ -33,8 +33,8 @@
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<h1 class="h3 card-title mb-0">Diagnostic du store</h1>
<div class="d-flex gap-2">
<button id="refreshSqlDiagButton" class="btn btn-primary" type="button">Rafraîchir</button>
<button id="copySqlDiagButton" class="btn btn-outline-secondary" type="button">Copier JSON</button>
<button id="refreshStoreDiagButton" class="btn btn-primary" type="button">Rafraîchir</button>
<button id="copyStoreDiagButton" class="btn btn-outline-secondary" type="button">Copier JSON</button>
</div>
</div>
<dl class="row mb-0">
@@ -107,4 +107,4 @@
<script type="module" src="ts/demo_store_diag.ts" defer></script>
</body>
</html>
</html>

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/demo_store_raw.html -->
<!-- version: 9 -->
<!-- version: 10 -->
<!DOCTYPE html>
<html lang="fr">
@@ -32,7 +32,7 @@
<div class="card-body">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<h1 class="h3 card-title mb-0">Acquisition transactionnelle Solana</h1>
<button id="refreshSqlRawButton" class="btn btn-primary" type="button">Rafraîchir</button>
<button id="refreshStoreRawButton" class="btn btn-primary" type="button">Rafraîchir</button>
</div>
<dl class="row mb-0">
<dt class="col-sm-3">Statut</dt>
@@ -94,4 +94,4 @@
<script type="module" src="ts/demo_store_raw.ts" defer></script>
</body>
</html>
</html>

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/demo_store_replay_candidates.html -->
<!-- version: 17 -->
<!-- version: 18 -->
<!DOCTYPE html>
<html lang="fr">
@@ -36,12 +36,12 @@
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<h1 class="h3 card-title">Exploration read-only des candidats</h1>
<p class="text-body-secondary mb-2">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 lexport 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 <code>./data/exports_csv/</code>. LibreOffice Calc peut toujours afficher son dialogue dimport, car le format CSV ne décrit pas formellement son encodage ni son séparateur.</p>
<p class="text-body-secondary mb-2">Les requêtes sont paramétrées, bornées et indépendantes du backend concret. La navigation <strong>Premier bloc / Bloc précédent / Bloc suivant</strong> 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 lexport 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 <code>./data/exports_csv/</code>. LibreOffice Calc peut toujours afficher son dialogue dimport, car le format CSV ne décrit pas formellement son encodage ni son séparateur.</p>
<div class="small text-body-secondary">
<span class="fw-semibold">Connexion :</span>
<code id="replayCandidateDsn"></code>
<span class="ms-3 fw-semibold">Limite serveur maximale :</span>
<code id="replayCandidateMaximumLimit"></code>
<span class="ms-3 fw-semibold">Taille fixe dun bloc Store :</span>
<code id="replayCandidateBlockSize"></code>
</div>
</div>
</div>
@@ -129,10 +129,6 @@
<label for="transactionEntityValueInput" class="form-label">Entité exacte</label>
<input id="transactionEntityValueInput" class="form-control font-monospace" type="text" autocomplete="off" />
</div>
<div class="col-6 col-md-3 col-xl-1">
<label for="transactionLimitInput" class="form-label">Limite</label>
<input id="transactionLimitInput" class="form-control" type="number" min="1" max="500" step="1" value="500" />
</div>
<div class="col-6 col-md-3 col-xl-2">
<div class="form-check form-switch mb-2">
<input id="transactionNewestFirstInput" class="form-check-input" type="checkbox" role="switch" checked />
@@ -155,6 +151,14 @@
<div>
<h2 class="h5 card-title mb-1">Transactions candidates</h2>
<div id="transactionResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2" aria-label="Pagination Store transaction">
<div class="btn-group btn-group-sm" role="group">
<button id="transactionFirstBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Premier bloc</button>
<button id="transactionPreviousBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc précédent</button>
<button id="transactionNextBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc suivant</button>
</div>
<span id="transactionBlockSummary" class="badge text-bg-light border">Bloc — / —</span>
</div>
</div>
<div>
<div class="btn-group flex-wrap" role="group" aria-label="Actions signatures">
@@ -199,15 +203,11 @@
<option value="">Tous les programmes connus</option>
</select>
</div>
<div class="col-12 col-lg-4">
<div class="col-12 col-lg-6">
<label for="programContainsInput" class="form-label">Program ID contient</label>
<input id="programContainsInput" class="form-control font-monospace" type="text" list="knownProgramDatalist" autocomplete="off" />
<datalist id="knownProgramDatalist"></datalist>
</div>
<div class="col-6 col-lg-1">
<label for="programLimitInput" class="form-label">Limite</label>
<input id="programLimitInput" class="form-control" type="number" min="1" max="500" step="1" value="500" />
</div>
<div class="col-12 col-lg-2">
<div class="d-grid gap-2">
<button id="resetProgramsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
@@ -223,6 +223,14 @@
<div>
<h2 class="h5 card-title mb-1">Programmes indexés</h2>
<div id="programResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2" aria-label="Pagination Store program">
<div class="btn-group btn-group-sm" role="group">
<button id="programFirstBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Premier bloc</button>
<button id="programPreviousBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc précédent</button>
<button id="programNextBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc suivant</button>
</div>
<span id="programBlockSummary" class="badge text-bg-light border">Bloc — / —</span>
</div>
</div>
<div>
<div class="btn-group flex-wrap" role="group" aria-label="Actions programmes">
@@ -258,14 +266,10 @@
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-7">
<div class="col-12 col-lg-8">
<label for="mintContainsInput" class="form-label">Mint contient</label>
<input id="mintContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
</div>
<div class="col-6 col-lg-2">
<label for="mintLimitInput" class="form-label">Limite</label>
<input id="mintLimitInput" class="form-control" type="number" min="1" max="500" step="1" value="500" />
</div>
<div class="col-12 col-lg-3">
<div class="d-grid d-sm-flex gap-2">
<button id="resetMintsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
@@ -281,6 +285,14 @@
<div>
<h2 class="h5 card-title mb-1">Mints</h2>
<div id="mintResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2" aria-label="Pagination Store mint">
<div class="btn-group btn-group-sm" role="group">
<button id="mintFirstBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Premier bloc</button>
<button id="mintPreviousBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc précédent</button>
<button id="mintNextBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc suivant</button>
</div>
<span id="mintBlockSummary" class="badge text-bg-light border">Bloc — / —</span>
</div>
<div class="small text-body-secondary">Source : faits Core de variation de balance (mint).</div>
</div>
<div>
@@ -316,14 +328,10 @@
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-7">
<div class="col-12 col-lg-8">
<label for="ownerContainsInput" class="form-label">Owner contient</label>
<input id="ownerContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
</div>
<div class="col-6 col-lg-2">
<label for="ownerLimitInput" class="form-label">Limite</label>
<input id="ownerLimitInput" class="form-control" type="number" min="1" max="500" step="1" value="500" />
</div>
<div class="col-12 col-lg-3">
<div class="d-grid d-sm-flex gap-2">
<button id="resetOwnersButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
@@ -339,6 +347,14 @@
<div>
<h2 class="h5 card-title mb-1">Owners</h2>
<div id="ownerResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2" aria-label="Pagination Store owner">
<div class="btn-group btn-group-sm" role="group">
<button id="ownerFirstBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Premier bloc</button>
<button id="ownerPreviousBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc précédent</button>
<button id="ownerNextBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc suivant</button>
</div>
<span id="ownerBlockSummary" class="badge text-bg-light border">Bloc — / —</span>
</div>
<div class="small text-body-secondary">Source : faits Core de variation de balance (owner).</div>
</div>
<div>
@@ -374,14 +390,10 @@
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-7">
<div class="col-12 col-lg-8">
<label for="accountContainsInput" class="form-label">Account key contient</label>
<input id="accountContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
</div>
<div class="col-6 col-lg-2">
<label for="accountLimitInput" class="form-label">Limite</label>
<input id="accountLimitInput" class="form-control" type="number" min="1" max="500" step="1" value="500" />
</div>
<div class="col-12 col-lg-3">
<div class="d-grid d-sm-flex gap-2">
<button id="resetAccountsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
@@ -397,6 +409,14 @@
<div>
<h2 class="h5 card-title mb-1">Comptes</h2>
<div id="accountResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2" aria-label="Pagination Store account">
<div class="btn-group btn-group-sm" role="group">
<button id="accountFirstBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Premier bloc</button>
<button id="accountPreviousBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc précédent</button>
<button id="accountNextBlockButton" class="btn btn-outline-secondary" type="button" data-store-block-navigation="true" disabled>Bloc suivant</button>
</div>
<span id="accountBlockSummary" class="badge text-bg-light border">Bloc — / —</span>
</div>
<div class="small text-body-secondary">Source : clés de comptes Core résolues.</div>
</div>
<div>
@@ -444,4 +464,4 @@
<script type="module" src="ts/demo_store_replay_candidates.ts" defer></script>
</body>
</html>
</html>

View File

@@ -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<DemoTransactionAnnotationRequest, "cursor" | "blockIndex">;
let annotationBaseRequest: AnnotationBaseRequest | null = null;
function element<T extends HTMLElement>(selector: string): T {
const value = document.querySelector<T>(selector);
@@ -250,7 +269,7 @@ function renderAnnotations(rows: DemoTransactionAnnotationRow[]): void {
annotationJournalTable = new DataTable<DemoTransactionAnnotationRow>("#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<HTMLElement>("#annotationJournalStatus").textContent = `${rows.length} annotation(s) committed chargée(s).`;
}
async function loadAnnotations(): Promise<void> {
const request: DemoTransactionAnnotationRequest = {
async function startAnnotationLoad(): Promise<void> {
annotationBaseRequest = {
signatureContains: inputValue("#annotationSignatureInput") || null,
limit: integerValue("#annotationLimitInput"),
};
resetStoreBlockState(annotationBlockState);
await loadAnnotationBlock(1, null);
}
async function loadAnnotationBlock(blockIndex: number, cursor: string | null): Promise<void> {
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<DemoTransactionAnnotationRow[]>("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<DemoTransactionAnnotationPagePayload>("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<HTMLElement>("#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<void> {
const targetBlock = annotationBlockState.currentBlock - 1;
const cursor = cursorForBlock(annotationBlockState, targetBlock);
if (targetBlock < 1 || cursor === undefined) {
return;
}
await loadAnnotationBlock(targetBlock, cursor);
}
async function loadNextAnnotationBlock(): Promise<void> {
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<void> {
try {
frontendDebug("kb_app_demo_desktop.frontend.demo_decode_replay", `Invoke ${command}`);
@@ -357,7 +406,14 @@ document.addEventListener("DOMContentLoaded", () => {
element<HTMLButtonElement>("#startDecodeReplayButton").addEventListener("click", () => void executeDecodeReplay());
element<HTMLButtonElement>("#cancelDecodeReplayButton").addEventListener("click", () => void cancelDecodeReplay());
element<HTMLButtonElement>("#loadDecodeDiagnosticsButton").addEventListener("click", () => void loadDiagnostics());
element<HTMLButtonElement>("#loadAnnotationsButton").addEventListener("click", () => void loadAnnotations());
element<HTMLButtonElement>("#loadAnnotationsButton").addEventListener("click", () => void startAnnotationLoad());
element<HTMLButtonElement>(annotationBlockControls.firstSelector).addEventListener("click", () => void loadAnnotationBlock(1, null));
element<HTMLButtonElement>(annotationBlockControls.previousSelector).addEventListener("click", () => void loadPreviousAnnotationBlock());
element<HTMLButtonElement>(annotationBlockControls.nextSelector).addEventListener("click", () => void loadNextAnnotationBlock());
element<HTMLInputElement>("#annotationSignatureInput").addEventListener("input", () => {
annotationBaseRequest = null;
markStoreBlockFiltersDirty(annotationBlockState, annotationBlockControls);
});
element<HTMLButtonElement>("#openReplayCandidatesFromDecodeButton").addEventListener("click", () => void openWindow("open_demo_store_replay_candidates_window"));
element<HTMLButtonElement>("#openCoreDiagnosticsFromDecodeButton").addEventListener("click", () => void openWindow("open_demo_store_core_window"));
void listen<DemoDecodeReplayProgressPayload>("demo-decode-replay-progress", event => appendLog(event.payload));

View File

@@ -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<void> {
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<DemoExecutionDevnetStoreReadinessPayload>("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<HTMLButtonElement>("#prepareMetadataProfileButton").addEventListener("click", () => void prepareProfile());
element<HTMLButtonElement>("#prepareToken2022MetadataProfileButton").addEventListener("click", () => void prepareProfile());
element<HTMLButtonElement>("#prepareSolanaProgramMetadataProfileButton").addEventListener("click", () => void prepareProfile());
element<HTMLButtonElement>("#prepareMetadataProfileButton").addEventListener("click", () => {
frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Metaplex profile load button clicked");
void prepareProfile();
});
element<HTMLButtonElement>("#prepareToken2022MetadataProfileButton").addEventListener("click", () => {
frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Token-2022 Metadata profile load button clicked");
void prepareProfile();
});
element<HTMLButtonElement>("#prepareSolanaProgramMetadataProfileButton").addEventListener("click", () => {
frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Solana Program Metadata profile load button clicked");
void prepareProfile();
});
element<HTMLButtonElement>("#refreshMetadataScenariosButton").addEventListener("click", () => void loadScenarios());
element<HTMLTextAreaElement>("#metadataExecutionLogOutput").addEventListener("app-log-clear", () => { metadataLogLines.length = 0; });
void listen<DemoExecutionMetadataProgressPayload>("demo-execution-metadata-progress", event => appendMetadataLog(event.payload));

View File

@@ -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<DemoSplTokenJournalRequest, "cursor" | "blockIndex">;
type AtaJournalBaseRequest = Omit<DemoSplAtaJournalRequest, "cursor" | "blockIndex">;
let tokenJournalBaseRequest: TokenJournalBaseRequest | null = null;
let ataJournalBaseRequest: AtaJournalBaseRequest | null = null;
function element<T extends HTMLElement>(selector: string): T {
const value = document.querySelector<T>(selector);
@@ -400,7 +428,7 @@ async function executeAta(submit: boolean): Promise<void> {
element<HTMLInputElement>("#ataJournalSignatureInput").value = summary.transactionSignature;
element<HTMLInputElement>("#ataJournalMintInput").value = request.mint;
element<HTMLInputElement>("#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<void> {
}
}
function ataJournalRequest(): DemoSplAtaJournalRequest {
function ataJournalBaseFilters(): AtaJournalBaseRequest {
return {
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
signatureContains: optionalText("#ataJournalSignatureInput"),
mint: optionalText("#ataJournalMintInput"),
associatedTokenAccount: optionalText("#ataJournalAddressInput"),
operation: optionalText("#ataJournalOperationInput"),
limit: integerValue("#ataJournalLimitInput"),
};
}
async function loadAtaJournal(): Promise<void> {
async function startAtaJournalLoad(): Promise<void> {
ataJournalBaseRequest = ataJournalBaseFilters();
resetStoreBlockState(ataJournalBlockState);
await loadAtaJournalBlock(1);
}
async function loadAtaJournalBlock(blockIndex: number): Promise<void> {
if (ataJournalBaseRequest === null) {
markStoreBlockFiltersDirty(ataJournalBlockState, ataJournalBlockControls);
return;
}
const cursor = cursorForBlock(ataJournalBlockState, blockIndex);
if (cursor === undefined) {
return;
}
const button = element<HTMLButtonElement>("#refreshAtaJournalButton");
button.disabled = true;
element<HTMLButtonElement>(ataJournalBlockControls.firstSelector).disabled = true;
element<HTMLButtonElement>(ataJournalBlockControls.previousSelector).disabled = true;
element<HTMLButtonElement>(ataJournalBlockControls.nextSelector).disabled = true;
const request: DemoSplAtaJournalRequest = {
...ataJournalBaseRequest,
cursor,
blockIndex,
};
try {
const rows = await invoke<DemoSplAtaJournalRow[]>("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<DemoSplAtaJournalPagePayload>("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<HTMLTextAreaElement>("#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<void> {
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<void> {
if (running) {
return;
@@ -567,7 +626,7 @@ async function executeToken(submit: boolean): Promise<void> {
});
if (summary.transactionSignature) {
element<HTMLInputElement>("#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<void> {
}
}
function tokenJournalRequest(): DemoSplTokenJournalRequest {
function tokenJournalBaseFilters(): TokenJournalBaseRequest {
return {
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
signatureContains: optionalText("#tokenJournalSignatureInput"),
@@ -587,7 +646,6 @@ function tokenJournalRequest(): DemoSplTokenJournalRequest {
account: optionalText("#tokenJournalAccountInput"),
family: element<HTMLSelectElement>("#tokenJournalFamilySelect").value || null,
operation: optionalText("#tokenJournalOperationInput"),
limit: integerValue("#tokenJournalLimitInput"),
};
}
@@ -831,17 +889,42 @@ async function executeToken2022(submit: boolean): Promise<void> {
}
}
async function loadTokenJournal(): Promise<void> {
async function startTokenJournalLoad(): Promise<void> {
tokenJournalBaseRequest = tokenJournalBaseFilters();
resetStoreBlockState(tokenJournalBlockState);
await loadTokenJournalBlock(1);
}
async function loadTokenJournalBlock(blockIndex: number): Promise<void> {
if (tokenJournalBaseRequest === null) {
markStoreBlockFiltersDirty(tokenJournalBlockState, tokenJournalBlockControls);
return;
}
const cursor = cursorForBlock(tokenJournalBlockState, blockIndex);
if (cursor === undefined) {
return;
}
const button = element<HTMLButtonElement>("#refreshTokenJournalButton");
button.disabled = true;
element<HTMLButtonElement>(tokenJournalBlockControls.firstSelector).disabled = true;
element<HTMLButtonElement>(tokenJournalBlockControls.previousSelector).disabled = true;
element<HTMLButtonElement>(tokenJournalBlockControls.nextSelector).disabled = true;
const request: DemoSplTokenJournalRequest = {
...tokenJournalBaseRequest,
cursor,
blockIndex,
};
try {
const rows = await invoke<DemoSplTokenJournalRow[]>("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<DemoSplTokenJournalPagePayload>("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<void> {
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<void> {
try {
const payload = await invoke<DemoExecutionSolanaCoreGeneratedRecipientPayload>("demo_execution_solana_core_generate_recipient");
@@ -898,7 +987,9 @@ async function prepareSelectedProfile(): Promise<void> {
}
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<DemoExecutionDevnetStoreReadinessPayload>("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<HTMLSelectElement>("#executionProfileSelect").addEventListener("change", () => {
updateProfileHelp();
markTokenJournalFiltersDirty();
markAtaJournalFiltersDirty();
void prepareSelectedProfile();
});
element<HTMLButtonElement>("#generateRecipientButton").addEventListener("click", () => {
@@ -982,8 +1075,27 @@ document.addEventListener("DOMContentLoaded", () => {
void executeToken2022(true);
});
element<HTMLButtonElement>("#refreshTokenJournalButton").addEventListener("click", () => {
void loadTokenJournal();
frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", "Token journal load button clicked");
void startTokenJournalLoad();
});
element<HTMLButtonElement>("#tokenJournalFirstBlockButton").addEventListener("click", () => {
void loadTokenJournalBlock(1);
});
element<HTMLButtonElement>("#tokenJournalPreviousBlockButton").addEventListener("click", () => {
void loadTokenJournalBlock(tokenJournalBlockState.currentBlock - 1);
});
element<HTMLButtonElement>("#tokenJournalNextBlockButton").addEventListener("click", () => {
void loadTokenJournalBlock(tokenJournalBlockState.currentBlock + 1);
});
for (const selector of [
"#tokenJournalSignatureInput",
"#tokenJournalMintInput",
"#tokenJournalAccountInput",
"#tokenJournalOperationInput",
]) {
element<HTMLInputElement>(selector).addEventListener("input", markTokenJournalFiltersDirty);
}
element<HTMLSelectElement>("#tokenJournalFamilySelect").addEventListener("change", markTokenJournalFiltersDirty);
element<HTMLButtonElement>("#deriveAtaButton").addEventListener("click", () => {
void deriveAta();
});
@@ -994,8 +1106,26 @@ document.addEventListener("DOMContentLoaded", () => {
void executeAta(true);
});
element<HTMLButtonElement>("#refreshAtaJournalButton").addEventListener("click", () => {
void loadAtaJournal();
frontendDebug("kb-app-demo-desktop.frontend.demo_execution_spl", "ATA journal load button clicked");
void startAtaJournalLoad();
});
element<HTMLButtonElement>("#ataJournalFirstBlockButton").addEventListener("click", () => {
void loadAtaJournalBlock(1);
});
element<HTMLButtonElement>("#ataJournalPreviousBlockButton").addEventListener("click", () => {
void loadAtaJournalBlock(ataJournalBlockState.currentBlock - 1);
});
element<HTMLButtonElement>("#ataJournalNextBlockButton").addEventListener("click", () => {
void loadAtaJournalBlock(ataJournalBlockState.currentBlock + 1);
});
for (const selector of [
"#ataJournalSignatureInput",
"#ataJournalMintInput",
"#ataJournalAddressInput",
"#ataJournalOperationInput",
]) {
element<HTMLInputElement>(selector).addEventListener("input", markAtaJournalFiltersDirty);
}
element<HTMLButtonElement>("#cancelExecutionButton").addEventListener("click", () => {
void cancelExecution();
});

View File

@@ -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<number, string | null>;
};
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<number, string | null>([[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<number, string | null>([[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<HTMLButtonElement>(controls.firstSelector);
const previousButton = document.querySelector<HTMLButtonElement>(controls.previousSelector);
const nextButton = document.querySelector<HTMLButtonElement>(controls.nextSelector);
const summary = document.querySelector<HTMLElement>(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<HTMLButtonElement>(controls.firstSelector);
const previousButton = document.querySelector<HTMLButtonElement>(controls.previousSelector);
const nextButton = document.querySelector<HTMLButtonElement>(controls.nextSelector);
const summary = document.querySelector<HTMLElement>(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);
}

View File

@@ -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<HTMLButtonElement>("#refreshSqlCoreButton")?.addEventListener("click", () => {
document.querySelector<HTMLButtonElement>("#refreshStoreCoreButton")?.addEventListener("click", () => {
void refreshCore();
});
void refreshCore();

View File

@@ -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<HTMLButtonElement>("#refreshSqlDiagButton")?.addEventListener("click", () => {
document.querySelector<HTMLButtonElement>("#refreshStoreDiagButton")?.addEventListener("click", () => {
void refreshDiagnostics();
});
document.querySelector<HTMLButtonElement>("#copySqlDiagButton")?.addEventListener("click", () => {
document.querySelector<HTMLButtonElement>("#copyStoreDiagButton")?.addEventListener("click", () => {
void copyDiagnostics();
});
void refreshDiagnostics();

View File

@@ -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<HTMLButtonElement>("#refreshSqlRawButton")?.addEventListener("click", () => {
document.querySelector<HTMLButtonElement>("#refreshStoreRawButton")?.addEventListener("click", () => {
void refreshRaw();
});
void refreshRaw();

View File

@@ -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<DemoStoreReplayEntityRow> | null = null;
let ownerTable: Api<DemoStoreReplayEntityRow> | null = null;
let accountTable: Api<DemoStoreReplayEntityRow> | 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<EntityKindCode, StoreBlockState>([
["mint", createStoreBlockState()],
["owner", createStoreBlockState()],
["account_key", createStoreBlockState()],
]);
type TransactionBaseRequest = Omit<DemoStoreReplayTransactionRequest, "cursor" | "blockIndex">;
type ProgramBaseRequest = Omit<DemoStoreReplayProgramRequest, "cursor" | "blockIndex">;
type EntityBaseRequest = Omit<DemoStoreReplayEntityRequest, "cursor" | "blockIndex">;
let transactionBaseRequest: TransactionBaseRequest | null = null;
let programBaseRequest: ProgramBaseRequest | null = null;
const entityBaseRequests = new Map<EntityKindCode, EntityBaseRequest>();
function element<T extends HTMLElement>(selector: string): T {
const value = document.querySelector<T>(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<DemoStoreReplayTransactionRow> {
],
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<DemoStoreReplayProgramRow> {
],
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<DemoStoreReplayEntityR
],
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(
`Aucun ${config.label} chargé`,
@@ -412,6 +446,21 @@ function selectedOrSingleFilteredRow<T>(table: Api<T>, 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<HTMLButtonElement>("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<HTMLInputElement>("#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<void> {
element<HTMLInputElement>("#transactionSignatureContainsInput").value = "";
element<HTMLInputElement>("#transactionMinSlotInput").value = "";
@@ -456,95 +532,190 @@ async function resetTransactionFilters(): Promise<void> {
element<HTMLSelectElement>("#transactionProgramScopeSelect").value = "any";
element<HTMLSelectElement>("#transactionEntityKindSelect").value = "";
element<HTMLInputElement>("#transactionEntityValueInput").value = "";
element<HTMLInputElement>("#transactionLimitInput").value = "500";
element<HTMLInputElement>("#transactionNewestFirstInput").checked = true;
resetTableState(transactionTable);
await loadTransactions();
await startTransactionLoad();
}
async function resetProgramFilters(): Promise<void> {
element<HTMLSelectElement>("#knownProgramSelect").value = "";
element<HTMLInputElement>("#programContainsInput").value = "";
element<HTMLInputElement>("#programLimitInput").value = "500";
resetTableState(programTable);
await loadPrograms();
await startProgramLoad();
}
async function resetEntityFilters(config: EntityViewConfig): Promise<void> {
element<HTMLInputElement>(config.containsSelector).value = "";
element<HTMLInputElement>(config.limitSelector).value = "500";
resetTableState(entityTable(config.kind));
await loadEntity(config);
await startEntityLoad(config);
}
async function loadTransactions(): Promise<void> {
async function startTransactionLoad(): Promise<void> {
transactionBaseRequest = buildTransactionBaseRequest();
resetStoreBlockState(transactionBlockState);
await loadTransactionBlock(1, null);
}
async function loadTransactionBlock(blockIndex: number, cursor: string | null): Promise<void> {
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<DemoStoreReplayTransactionRow[]>("load_demo_store_replay_transactions", { request });
replaceRows(transactionTable, rows);
element<HTMLElement>("#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<DemoStoreReplayTransactionPagePayload>(
"load_demo_store_replay_transactions",
{ request },
);
replaceRows(transactionTable, payload.rows);
applyStoreBlockMetadata(transactionBlockState, payload.page, transactionBlockControls);
element<HTMLElement>("#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<HTMLElement>("#transactionResultSummary").textContent = `Erreur : ${message}`;
renderStoreBlockControls(transactionBlockState, transactionBlockControls);
frontendError(tracingTarget, `transaction candidate loading failed: ${message}`);
} finally {
setBusy(false, "Prêt");
}
}
async function loadPrograms(): Promise<void> {
async function startProgramLoad(): Promise<void> {
programBaseRequest = buildProgramBaseRequest();
resetStoreBlockState(programBlockState);
await loadProgramBlock(1, null);
}
async function loadProgramBlock(blockIndex: number, cursor: string | null): Promise<void> {
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<DemoStoreReplayProgramRow[]>("load_demo_store_replay_programs", { request });
replaceRows(programTable, rows);
element<HTMLElement>("#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<DemoStoreReplayProgramPagePayload>(
"load_demo_store_replay_programs",
{ request },
);
replaceRows(programTable, payload.rows);
applyStoreBlockMetadata(programBlockState, payload.page, programBlockControls);
element<HTMLElement>("#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<HTMLElement>("#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<void> {
async function startEntityLoad(config: EntityViewConfig): Promise<void> {
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<void> {
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<DemoStoreReplayEntityRow[]>("load_demo_store_replay_entities", { request });
replaceRows(table, rows);
element<HTMLElement>(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<DemoStoreReplayEntityPagePayload>(
"load_demo_store_replay_entities",
{ request },
);
replaceRows(table, payload.rows);
applyStoreBlockMetadata(entityBlockState(config.kind), payload.page, config.blockControls);
element<HTMLElement>(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<HTMLElement>(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<void> {
const targetBlock = transactionBlockState.currentBlock - 1;
const cursor = cursorForBlock(transactionBlockState, targetBlock);
if (targetBlock < 1 || cursor === undefined) {
return;
}
await loadTransactionBlock(targetBlock, cursor);
}
async function loadNextTransactionBlock(): Promise<void> {
if (transactionBlockState.nextCursor === null) {
return;
}
await loadTransactionBlock(transactionBlockState.currentBlock + 1, transactionBlockState.nextCursor);
}
async function loadPreviousProgramBlock(): Promise<void> {
const targetBlock = programBlockState.currentBlock - 1;
const cursor = cursorForBlock(programBlockState, targetBlock);
if (targetBlock < 1 || cursor === undefined) {
return;
}
await loadProgramBlock(targetBlock, cursor);
}
async function loadNextProgramBlock(): Promise<void> {
if (programBlockState.nextCursor === null) {
return;
}
await loadProgramBlock(programBlockState.currentBlock + 1, programBlockState.nextCursor);
}
async function loadPreviousEntityBlock(config: EntityViewConfig): Promise<void> {
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<void> {
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<void> {
if (lines.length === 0) {
throw new Error(`Aucune ${label} à copier.`);
@@ -635,7 +806,7 @@ async function useSelectedProgram(): Promise<void> {
element<HTMLInputElement>("#transactionProgramIdInput").value = row.programId;
element<HTMLSelectElement>("#transactionProgramScopeSelect").value = "any";
showTransactionsTab();
await loadTransactions();
await startTransactionLoad();
}
async function useSelectedEntity(config: EntityViewConfig): Promise<void> {
@@ -647,7 +818,7 @@ async function useSelectedEntity(config: EntityViewConfig): Promise<void> {
element<HTMLSelectElement>("#transactionEntityKindSelect").value = config.kind;
element<HTMLInputElement>("#transactionEntityValueInput").value = row.entityValue;
showTransactionsTab();
await loadTransactions();
await startTransactionLoad();
}
async function openCoreExtraction(): Promise<void> {
@@ -661,16 +832,12 @@ async function openCoreExtraction(): Promise<void> {
async function loadOptions(): Promise<void> {
const options = await invoke<DemoStoreReplayOptionsPayload>("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<HTMLElement>("#replayCandidateProfileBadge").textContent = `Profil ${options.activeProfileName}`;
element<HTMLElement>("#replayCandidateDsn").textContent = options.connectionDescriptor;
element<HTMLElement>("#replayCandidateMaximumLimit").textContent = String(options.maximumLimit);
document.querySelectorAll<HTMLInputElement>("input[type=number][max]").forEach(input => {
input.max = String(options.maximumLimit);
});
element<HTMLElement>("#replayCandidateBlockSize").textContent = `${options.blockSize} lignes`;
populateKnownPrograms(options.knownPrograms);
}
@@ -729,14 +896,20 @@ function reportUiError(action: string, caughtError: unknown): void {
}
function installHandlers(): void {
element<HTMLButtonElement>("#loadTransactionsButton").addEventListener("click", () => void loadTransactions());
element<HTMLButtonElement>("#loadTransactionsButton").addEventListener("click", () => void startTransactionLoad());
element<HTMLButtonElement>("#resetTransactionsButton").addEventListener("click", () => {
void resetTransactionFilters().catch(error => reportUiError("reset transaction filters", error));
});
element<HTMLButtonElement>("#loadProgramsButton").addEventListener("click", () => void loadPrograms());
element<HTMLButtonElement>(transactionBlockControls.firstSelector).addEventListener("click", () => void loadTransactionBlock(1, null));
element<HTMLButtonElement>(transactionBlockControls.previousSelector).addEventListener("click", () => void loadPreviousTransactionBlock());
element<HTMLButtonElement>(transactionBlockControls.nextSelector).addEventListener("click", () => void loadNextTransactionBlock());
element<HTMLButtonElement>("#loadProgramsButton").addEventListener("click", () => void startProgramLoad());
element<HTMLButtonElement>("#resetProgramsButton").addEventListener("click", () => {
void resetProgramFilters().catch(error => reportUiError("reset program filters", error));
});
element<HTMLButtonElement>(programBlockControls.firstSelector).addEventListener("click", () => void loadProgramBlock(1, null));
element<HTMLButtonElement>(programBlockControls.previousSelector).addEventListener("click", () => void loadPreviousProgramBlock());
element<HTMLButtonElement>(programBlockControls.nextSelector).addEventListener("click", () => void loadNextProgramBlock());
element<HTMLButtonElement>("#openCoreExtractionButton").addEventListener("click", () => void openCoreExtraction());
element<HTMLButtonElement>("#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<HTMLButtonElement>(config.loadButtonSelector).addEventListener("click", () => void loadEntity(config));
element<HTMLButtonElement>(config.loadButtonSelector).addEventListener("click", () => void startEntityLoad(config));
element<HTMLButtonElement>(config.resetButtonSelector).addEventListener("click", () => {
void resetEntityFilters(config).catch(error => reportUiError(`reset ${config.pluralLabel} filters`, error));
});
element<HTMLButtonElement>(config.blockControls.firstSelector).addEventListener("click", () => void loadEntityBlock(config, 1, null));
element<HTMLButtonElement>(config.blockControls.previousSelector).addEventListener("click", () => void loadPreviousEntityBlock(config));
element<HTMLButtonElement>(config.blockControls.nextSelector).addEventListener("click", () => void loadNextEntityBlock(config));
element<HTMLButtonElement>(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<HTMLButtonElement>(config.useButtonSelector).addEventListener("click", () => {
void useSelectedEntity(config).catch(error => reportUiError(`use selected ${config.label}`, error));
});
element<HTMLInputElement>(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<HTMLInputElement | HTMLSelectElement>(selector).addEventListener("change", () => {
transactionBaseRequest = null;
markStoreBlockFiltersDirty(transactionBlockState, transactionBlockControls);
});
element<HTMLInputElement | HTMLSelectElement>(selector).addEventListener("input", () => {
transactionBaseRequest = null;
markStoreBlockFiltersDirty(transactionBlockState, transactionBlockControls);
});
}
element<HTMLInputElement>("#programContainsInput").addEventListener("input", () => {
programBaseRequest = null;
markStoreBlockFiltersDirty(programBlockState, programBlockControls);
});
element<HTMLSelectElement>("#knownProgramSelect").addEventListener("change", event => {
const target = event.currentTarget as HTMLSelectElement;
element<HTMLInputElement>("#programContainsInput").value = target.value;
programBaseRequest = null;
markStoreBlockFiltersDirty(programBlockState, programBlockControls);
});
document.querySelectorAll<HTMLButtonElement>('[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));
});

View File

@@ -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<ks_logging::LoggingGuard>,
http_pool: ks_onchain_transport::HttpEndpointPool,
store: tokio::sync::OnceCell<ks_store::Store>,
profile_stores: tokio::sync::Mutex<
std::collections::BTreeMap<
std::string::String,
std::sync::Arc<tokio::sync::OnceCell<ks_store::Store>>,
>,
>,
ws_pool:
std::sync::Mutex<std::option::Option<std::sync::Arc<ks_onchain_transport::WsEndpointPool>>>,
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<ks_store::Store> {
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,

View File

@@ -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<std::string::String>,
/// Maximum returned rows.
pub(crate) limit: u32,
/// Opaque cursor identifying the requested Store block.
pub(crate) cursor: std::option::Option<std::string::String>,
/// 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<crate::DemoTransactionAnnotationRow>,
}
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::vec::Vec<crate::DemoTransactionAnnotationRow>, std::string::String> {
let filter_result = ks_store::MaterializedOutputFilter::new(
) -> std::result::Result<crate::DemoTransactionAnnotationPagePayload, std::string::String> {
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(

View File

@@ -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<ks_config::ProfileConfig, std::string::String> {
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<DemoExecutionDevnetStoreReadinessPayload, std::string::String> {
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 {

View File

@@ -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()),
};

View File

@@ -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()),
};

View File

@@ -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<ks_config::ProfileConfig, std::string::String> {
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<crate::DemoExecutionDevnetStoreReadinessPayload, std::string::String> {
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()),
};

View File

@@ -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<std::string::String>,
/// Optional exact materialized operation.
pub(crate) operation: std::option::Option<std::string::String>,
/// Maximum returned rows.
pub(crate) limit: u32,
/// Opaque cursor identifying the requested Store block.
pub(crate) cursor: std::option::Option<std::string::String>,
/// 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<crate::DemoSplAtaJournalRow>,
}
/// 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::vec::Vec<crate::DemoSplAtaJournalRow>, 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<crate::DemoSplAtaJournalPagePayload, std::string::String> {
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!({

View File

@@ -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<std::string::String>,
/// Optional exact operation code without the `spl.token.` prefix.
pub(crate) operation: std::option::Option<std::string::String>,
/// 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<std::string::String>,
/// 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<crate::DemoSplTokenJournalRow>,
}
/// 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::vec::Vec<crate::DemoSplTokenJournalRow>, std::string::String> {
) -> std::result::Result<crate::DemoSplTokenJournalPagePayload, std::string::String> {
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<crate::DemoSplTokenJournalRequest, std::string::String> {
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());
}

View File

@@ -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()),
};

View File

@@ -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<std::string::String>,
}
/// 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::string::String>,
) -> std::result::Result<ks_store::PageRequest, std::string::String> {
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<std::string::String>,
requested_block_index: u32,
) -> std::result::Result<crate::DemoStoreBlockMetadata, std::string::String> {
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<std::string::String>,
}
/// Builds backend-agnostic store-open options from one resolved profile.
fn store_open_options_from_profile(
profile: &ks_config::ProfileConfig,
) -> ks_core::Result<ks_store::StoreOpenOptions> {
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<ks_store::Store, std::string::String> {
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<ks_store::Store, std::string::String> {
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);
}
}

View File

@@ -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<crate::DemoStoreReplayKnownProgramOption>,
}
@@ -65,8 +65,10 @@ pub(crate) struct DemoStoreReplayTransactionRequest {
pub(crate) entity_kind: std::option::Option<std::string::String>,
/// Optional exact entity value.
pub(crate) entity_value: std::option::Option<std::string::String>,
/// Maximum returned rows.
pub(crate) limit: u32,
/// Opaque cursor identifying the requested Store block.
pub(crate) cursor: std::option::Option<std::string::String>,
/// 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<crate::DemoStoreReplayTransactionRow>,
}
/// 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<std::string::String>,
/// Maximum returned rows.
pub(crate) limit: u32,
/// Opaque cursor identifying the requested Store block.
pub(crate) cursor: std::option::Option<std::string::String>,
/// 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<crate::DemoStoreReplayProgramRow>,
}
/// 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<std::string::String>,
/// Maximum returned rows.
pub(crate) limit: u32,
/// Opaque cursor identifying the requested Store block.
pub(crate) cursor: std::option::Option<std::string::String>,
/// 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<crate::DemoStoreReplayEntityRow>,
}
/// 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::vec::Vec<crate::DemoStoreReplayTransactionRow>, std::string::String> {
) -> std::result::Result<crate::DemoStoreReplayTransactionPagePayload, std::string::String> {
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::vec::Vec<crate::DemoStoreReplayProgramRow>, std::string::String> {
let filter_result =
ks_store::ReplayProgramFilter::new(request.program_id_contains, request.limit);
) -> std::result::Result<crate::DemoStoreReplayProgramPagePayload, std::string::String> {
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::vec::Vec<crate::DemoStoreReplayEntityRow>, std::string::String> {
) -> std::result::Result<crate::DemoStoreReplayEntityPagePayload, std::string::String> {
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(

View File

@@ -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.

View File

@@ -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::vec::Vec<crate::DemoTransactionAnnotationRow>, std::string::String> {
) -> std::result::Result<crate::DemoTransactionAnnotationPagePayload, std::string::String> {
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::vec::Vec<crate::DemoStoreReplayTransactionRow>, std::string::String> {
) -> std::result::Result<crate::DemoStoreReplayTransactionPagePayload, std::string::String> {
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::vec::Vec<crate::DemoStoreReplayProgramRow>, std::string::String> {
) -> std::result::Result<crate::DemoStoreReplayProgramPagePayload, std::string::String> {
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::vec::Vec<crate::DemoStoreReplayEntityRow>, std::string::String> {
) -> std::result::Result<crate::DemoStoreReplayEntityPagePayload, std::string::String> {
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::vec::Vec<crate::DemoSplTokenJournalRow>, std::string::String> {
) -> std::result::Result<crate::DemoSplTokenJournalPagePayload, std::string::String> {
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::vec::Vec<crate::DemoSplAtaJournalRow>, std::string::String> {
) -> std::result::Result<crate::DemoSplAtaJournalPagePayload, std::string::String> {
return crate::demo_spl_ata_journal(state, request).await;
}

View File

@@ -1,8 +1,12 @@
<!-- file: ks-pipeline-demo-scenarios/CHANGELOG.md -->
<!-- version: 83 -->
<!-- version: 84 -->
# CHANGELOG — ks-pipeline-demo-scenarios
## `0.5.3-pre.005`
- renomme lenvironnement 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 ;

View File

@@ -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,
}

View File

@@ -1,8 +1,26 @@
<!-- file: ks-store/CHANGELOG.md -->
<!-- version: 33 -->
<!-- version: 38 -->
# CHANGELOG — ks-store
## `0.5.3-pre.005`
- supprime lancien chemin complet `apply_store_schema` / `initialize_store_schema`, devenu mort après lintroduction de lauto-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 lexé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 dauto-initialisation additive ;
- aligne louverture 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 quune ressource simplement absente est classée comme dérive additive ;
- fait de `auto_initialize_schema` un mécanisme dextension 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 nest 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 lunicité, de la méthode B-tree, des expressions/ordres et des prédicats dindex ;
- 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<T>` ;
- 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 à lidentique 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 dordre 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 ;

View File

@@ -1,5 +1,5 @@
<!-- file: ks-store/README.md -->
<!-- version: 8 -->
<!-- version: 10 -->
# 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**. À louverture, `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 dune table absente, ajout dune colonne absente, ajout dune contrainte/clé absente et création dun index absent. Lauto-initialisation ne supprime, ne renomme et ne modifie jamais une colonne existante. Les tables étrangères et danciens objets `kb_sol_*` peuvent coexister sans blocage tant quils 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.

View File

@@ -1,5 +1,5 @@
<!-- file: ks-store/TODO.md -->
<!-- version: 15 -->
<!-- version: 17 -->
# 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

View File

@@ -1,5 +1,5 @@
<!-- file: ks-store/USAGE.md -->
<!-- version: 7 -->
<!-- version: 10 -->
# 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 doffset : 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<T>` 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 lhistorique 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<std::string::String>,
/// Optional exact materialized family code.
pub materialized_family: std::option::Option<std::string::String>,
/// Optional exact source decoder name.
pub source_decoder_name: std::option::Option<std::string::String>,
/// Optional partial transaction signature.
pub signature_contains: std::option::Option<std::string::String>,
/// Structural payload match groups. Every group must match and one alternative inside each group is sufficient.
pub payload_match_groups: std::vec::Vec<std::vec::Vec<serde_json::Value>>,
}
impl crate::MaterializedOutputPageFilter {
/// Builds a normalized materialized output page filter.
pub fn new(
processor_name: std::option::Option<std::string::String>,
materialized_family: std::option::Option<std::string::String>,
source_decoder_name: std::option::Option<std::string::String>,
signature_contains: std::option::Option<std::string::String>,
payload_match_groups: std::vec::Vec<std::vec::Vec<serde_json::Value>>,
) -> 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 {

View File

@@ -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<T> crate::PageSlice<T> {
}
}
/// One bounded page with a refreshed total row count for the same filter.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct CountedPageSlice<T> {
/// Rows returned for this page.
pub rows: std::vec::Vec<T>,
/// Opaque cursor for the next page when additional rows exist.
pub next_cursor: std::option::Option<std::string::String>,
/// Total number of rows matching the filter at query time.
pub total_rows: u64,
}
impl<T> crate::CountedPageSlice<T> {
/// Builds one counted page.
pub fn new(
rows: std::vec::Vec<T>,
next_cursor: std::option::Option<std::string::String>,
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());
}
}

View File

@@ -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<crate::ReplayEntityKind>,
/// Optional exact entity value.
pub entity_value: std::option::Option<std::string::String>,
/// 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<crate::ReplayEntityKind>,
entity_value: std::option::Option<std::string::String>,
limit: u32,
newest_first: bool,
) -> ks_core::Result<Self> {
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<std::string::String>,
/// 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<std::string::String>,
limit: u32,
) -> ks_core::Result<Self> {
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<std::string::String>,
/// 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<std::string::String>,
limit: u32,
) -> ks_core::Result<Self> {
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());
}
}

View File

@@ -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.

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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}");
}

View File

@@ -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}");
}

View File

@@ -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<crate::CountedPageSlice<crate::MaterializedOutputQueryRow>> {
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::<sqlx::Postgres, i64>(
"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::<sqlx::Postgres, MaterializedOutputPageDatabaseRow>(
"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<serde_json::Value> {
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<crate::MaterializedOutputQueryRow> {
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<std::option::Option<MaterializedOutputPageCursor>> {
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::<MaterializedOutputPageCursor>(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<std::string::String> {
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'",

View File

@@ -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}");
}

View File

@@ -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<std::vec::Vec<crate::ReplayTransactionCandidate>> {
let min_slot_result =
crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.min_slot);
page_request: &crate::PageRequest,
) -> ks_core::Result<crate::CountedPageSlice<crate::ReplayTransactionCandidate>> {
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::<sqlx::Postgres, 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(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<std::vec::Vec<crate::ReplayProgramSummary>> {
page_request: &crate::PageRequest,
) -> ks_core::Result<crate::CountedPageSlice<crate::ReplayProgramSummary>> {
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::<sqlx::Postgres, crate::postgres::query::replay_candidate_queries::ReplayProgramSummaryRow>(
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::<sqlx::Postgres, ReplayProgramSummaryRow>(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<std::vec::Vec<crate::ReplayEntitySummary>> {
page_request: &crate::PageRequest,
) -> ks_core::Result<crate::CountedPageSlice<crate::ReplayEntitySummary>> {
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::<sqlx::Postgres, ReplayEntitySummaryRow>(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<i64>,
max_slot: std::option::Option<i64>,
entity_kind: std::option::Option<&str>,
) -> ks_core::Result<u64> {
let started_at = std::time::Instant::now();
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(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<u64> {
let started_at = std::time::Instant::now();
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(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<u64> {
let started_at = std::time::Instant::now();
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(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<u64> {
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<std::option::Option<ReplayTransactionPageCursor>> {
let decoded_result = decode_cursor::<ReplayTransactionPageCursor>(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<std::option::Option<ReplayProgramPageCursor>> {
let decoded_result = decode_cursor::<ReplayProgramPageCursor>(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<std::option::Option<ReplayEntityPageCursor>> {
let decoded_result = decode_cursor::<ReplayEntityPageCursor>(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<T>(
cursor: std::option::Option<&str>,
label: &str,
) -> ks_core::Result<std::option::Option<T>>
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::<T>(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<std::string::String> {
return encode_cursor(
&ReplayTransactionPageCursor {
slot: row.slot,
signature: row.signature.clone(),
newest_first,
},
"transaction",
);
}
fn encode_program_cursor(
row: &crate::ReplayProgramSummary,
) -> ks_core::Result<std::string::String> {
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<std::string::String> {
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<T>(value: &T, label: &str) -> ks_core::Result<std::string::String>
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}");
}

View File

@@ -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<std::string::String>,
) -> 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<bool> {
let pattern = format!("{}%", crate::LEGACY_SOLANA_TABLE_PREFIX);
let query_result = sqlx::query_scalar::<sqlx::Postgres, bool>(
"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));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<crate::PostgresSchemaCompatibilitySummary> {
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<std::vec::Vec<crate::ReplayTransactionCandidate>> {
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<crate::CountedPageSlice<crate::ReplayTransactionCandidate>> {
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<std::vec::Vec<crate::ReplayProgramSummary>> {
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<crate::CountedPageSlice<crate::ReplayProgramSummary>> {
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<std::vec::Vec<crate::ReplayEntitySummary>> {
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<crate::CountedPageSlice<crate::ReplayEntitySummary>> {
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<crate::CountedPageSlice<crate::MaterializedOutputQueryRow>> {
return crate::list_materialized_output_page(&self.pool, filter, page_request).await;
}
/// Reads diagnostics for raw store resources without changing the schema.

View File

@@ -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<std::vec::Vec<crate::ReplayTransactionCandidate>> {
page_request: &crate::PageRequest,
) -> ks_core::Result<crate::CountedPageSlice<crate::ReplayTransactionCandidate>> {
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<std::vec::Vec<crate::ReplayProgramSummary>> {
page_request: &crate::PageRequest,
) -> ks_core::Result<crate::CountedPageSlice<crate::ReplayProgramSummary>> {
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<std::vec::Vec<crate::ReplayEntitySummary>> {
page_request: &crate::PageRequest,
) -> ks_core::Result<crate::CountedPageSlice<crate::ReplayEntitySummary>> {
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<crate::CountedPageSlice<crate::MaterializedOutputQueryRow>> {
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<ManagedSchemaOpenAction> {
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<crate::Store> {
@@ -275,14 +320,45 @@ async fn open_postgres(backend_options: serde_json::Value) -> ks_core::Result<cr
));
},
};
if auto_initialize_schema {
let initialize_result = store.initialize_store_schema().await;
let compatibility_before_result = store.verify_managed_schema_compatibility().await;
let compatibility_before = match compatibility_before_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 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<std::string::String>,
) -> 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(

View File

@@ -123,7 +123,7 @@
},
{
"id": "postgres_double_replay",
"environment": "postgres",
"environment": "store",
"status": "not_run",
"requiredEvidence": [
"postgres_tests",