Skip to main content

bsv_overlay_engine/
storage.rs

1//! Storage trait for the Overlay Services Engine.
2//!
3//! Defines the async storage interface that backends (D1, SQLite, in-memory) must implement.
4//! The Engine never talks to a database directly — everything goes through this trait.
5//!
6//! Ported from `~/bsv/overlay-services/src/storage/Storage.ts`.
7//! Reference implementation: `~/bsv/overlay-services/src/storage/knex/KnexStorage.ts`.
8
9#[cfg(feature = "memory-storage")]
10use crate::beef_limits;
11
12use async_trait::async_trait;
13
14use crate::types::{AppliedTransaction, Outpoint, Output};
15
16/// A stored transaction's txid plus its serialized BEEF bytes.
17///
18/// Returned by [`Storage::find_transactions_for_proof_check`] so the engine can
19/// parse the BEEF and decide whether the target tx still needs a merkle proof.
20#[derive(Debug, Clone)]
21pub struct TransactionBeef {
22    /// The transaction id (hex, lowercase).
23    pub txid: String,
24    /// The serialized BEEF bytes for this transaction.
25    pub beef: Vec<u8>,
26}
27
28/// Overlay Services storage backend.
29///
30/// All methods are async. Uses `?Send` futures for wasm32 compatibility
31/// (Cloudflare Workers D1).
32#[async_trait(?Send)]
33pub trait Storage {
34    // ========================================================================
35    // Write operations
36    // ========================================================================
37
38    /// Insert a new output into storage.
39    ///
40    /// If an output with the same (txid, outputIndex, topic) already exists, this is a no-op.
41    /// If `output.beef` is Some, also upsert the BEEF into the transactions table
42    /// (deduplicated by txid).
43    async fn insert_output(&self, output: &Output) -> Result<(), StorageError>;
44
45    /// Delete an output from storage.
46    ///
47    /// After deletion, if no other outputs reference the same txid, also delete
48    /// the transaction's BEEF from the transactions table.
49    async fn delete_output(
50        &self,
51        txid: &str,
52        output_index: u32,
53        topic: &str,
54    ) -> Result<(), StorageError>;
55
56    /// Mark an output as spent.
57    async fn mark_utxo_as_spent(
58        &self,
59        txid: &str,
60        output_index: u32,
61        topic: &str,
62    ) -> Result<(), StorageError>;
63
64    /// Update the `consumed_by` list on an existing output.
65    async fn update_consumed_by(
66        &self,
67        txid: &str,
68        output_index: u32,
69        topic: &str,
70        consumed_by: &[Outpoint],
71    ) -> Result<(), StorageError>;
72
73    /// Update the BEEF data for a transaction (used when merkle proofs arrive).
74    async fn update_transaction_beef(&self, txid: &str, beef: &[u8]) -> Result<(), StorageError>;
75
76    /// Mark a transaction as already carrying its own merkle proof, WITHOUT
77    /// rewriting its BEEF.
78    ///
79    /// This is the lightweight flag-flip used by
80    /// [`Engine::complete_missing_proofs`](crate::Engine::complete_missing_proofs)
81    /// when a scanned candidate turns out to ALREADY be proven (its stored BEEF
82    /// proves the target tx) yet its backend `has_proof` flag is still `0` —
83    /// e.g. a GASP-synced tx that arrived with a proof, written before overlay
84    /// migration 0010 added the flag (the migration defaulted every existing
85    /// row to `0`). Such rows are returned by
86    /// [`Storage::find_transactions_for_proof_check`] every tick and skipped by
87    /// the engine, clogging the candidate window and starving genuinely
88    /// proofless rows behind them. Flipping the flag here drops them out.
89    ///
90    /// The reference D1 backend runs
91    /// `UPDATE transactions SET has_proof = 1 WHERE txid = ?`. Backends that do
92    /// not track a proof flag may use this no-op default. Idempotent.
93    async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
94        let _ = txid;
95        Ok(())
96    }
97
98    /// [`Self::mark_transaction_proven`] that ALSO records the block height
99    /// the just-verified bump anchors the tx to (`proofHeight`, bsv-low M19
100    /// R2 round 3 review MED-2), so the revalidation sweep's transactions
101    /// leg can window the row. `None` records no anchor. Default: the plain
102    /// latch (a backend without the column loses only the windowing, never
103    /// correctness).
104    async fn mark_transaction_proven_at(
105        &self,
106        txid: &str,
107        height: Option<u64>,
108    ) -> Result<(), StorageError> {
109        let _ = height;
110        self.mark_transaction_proven(txid).await
111    }
112
113    /// Update the block height on an output (when it gets mined).
114    ///
115    /// Optional — backends that don't track block height can provide a no-op default.
116    async fn update_output_block_height(
117        &self,
118        txid: &str,
119        output_index: u32,
120        topic: &str,
121        block_height: u32,
122    ) -> Result<(), StorageError> {
123        let _ = (txid, output_index, topic, block_height);
124        Ok(())
125    }
126
127    /// Record that a transaction has been applied to a topic (deduplication).
128    async fn insert_applied_transaction(&self, tx: &AppliedTransaction)
129        -> Result<(), StorageError>;
130
131    /// Check if a transaction has already been applied to a topic.
132    async fn does_applied_transaction_exist(
133        &self,
134        tx: &AppliedTransaction,
135    ) -> Result<bool, StorageError>;
136
137    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): forget a (txid, topic) applied
138    /// record so a re-submit of those bytes is re-validated instead of
139    /// deduplicated away. The caller proves the row is PHANTOM (no stored
140    /// output of that txid on that topic) before asking — see
141    /// `Engine::forget_phantom_applied`.
142    async fn delete_applied_transaction(&self, tx: &AppliedTransaction)
143        -> Result<(), StorageError>;
144
145    // ========================================================================
146    // Read operations
147    // ========================================================================
148
149    /// Find a single output by txid + outputIndex, with optional topic and spent filters.
150    ///
151    /// If `include_beef` is true, load the BEEF from the transactions table and
152    /// attach it to the returned Output.
153    async fn find_output(
154        &self,
155        txid: &str,
156        output_index: u32,
157        topic: Option<&str>,
158        spent: Option<bool>,
159        include_beef: bool,
160    ) -> Result<Option<Output>, StorageError>;
161
162    /// Batch-find outputs by outpoints. More efficient than individual find_output calls.
163    ///
164    /// Default implementation falls back to individual lookups.
165    async fn find_outputs_by_outpoints(
166        &self,
167        outpoints: &[Outpoint],
168        include_beef: bool,
169    ) -> Result<Vec<Output>, StorageError> {
170        let mut results = Vec::with_capacity(outpoints.len());
171        for op in outpoints {
172            if let Some(output) = self
173                .find_output(&op.txid, op.output_index, None, None, include_beef)
174                .await?
175            {
176                results.push(output);
177            }
178        }
179        Ok(results)
180    }
181
182    /// Find all outputs for a given transaction.
183    async fn find_outputs_for_transaction(
184        &self,
185        txid: &str,
186        include_beef: bool,
187    ) -> Result<Vec<Output>, StorageError>;
188
189    /// Find unspent outputs for a topic, ordered by score ascending.
190    ///
191    /// - `since`: minimum score threshold (exclusive of scores below this)
192    /// - `limit`: maximum number of results
193    async fn find_utxos_for_topic(
194        &self,
195        topic: &str,
196        since: Option<f64>,
197        limit: Option<u64>,
198        include_beef: bool,
199    ) -> Result<Vec<Output>, StorageError>;
200
201    /// Return a bounded page of *proofless* stored transactions (txid + BEEF
202    /// bytes) for proof-completion scanning.
203    ///
204    /// Backends MUST return only transactions whose own merkle proof is still
205    /// missing — i.e. the historical/recent backlog the
206    /// [`Engine::complete_missing_proofs`](crate::Engine::complete_missing_proofs)
207    /// cron is meant to clear. The reference D1 backend keeps a `has_proof`
208    /// flag (overlay migration 0010), set on every BEEF write, and answers this
209    /// with `WHERE has_proof = 0 LIMIT {limit}` — so every proofless tx is
210    /// eventually reached (no "newest N rows only" starvation) and proven rows
211    /// are never re-fetched. The engine still defensively re-parses each
212    /// returned BEEF and skips any that turn out already-proven, so an
213    /// over-inclusive backend is merely less efficient, not incorrect.
214    ///
215    /// Backends that cannot enumerate transactions (or have nothing to
216    /// complete) may return an empty `Vec` via this default, in which case
217    /// proof completion is a no-op.
218    ///
219    /// `limit` bounds the returned page (and therefore the per-tick WoC fetch /
220    /// CPU budget).
221    ///
222    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
223    /// arcade#259): rows stored less than `min_age_secs` ago are EXCLUDED —
224    /// their proof is expected to arrive via the Arcade MINED webhook
225    /// (`/arc-ingest`) at push speed, so polling them is wasted budget. Rules:
226    /// - `0` disables the gate (poll everything — today's behaviour).
227    /// - a row whose age is UNKNOWN (pre-migration `NULL` timestamp) MUST be
228    ///   treated as OLD, i.e. eligible — the fail-safe direction is to poll
229    ///   MORE, never to starve a row of its backstop.
230    /// The reference D1 backend answers with
231    /// `AND (created_at IS NULL OR created_at <= unixepoch() - min_age_secs)`.
232    async fn find_transactions_for_proof_check(
233        &self,
234        limit: u64,
235        min_age_secs: u64,
236    ) -> Result<Vec<TransactionBeef>, StorageError> {
237        let _ = (limit, min_age_secs);
238        Ok(Vec::new())
239    }
240
241    // ========================================================================
242    // GASP sync state
243    // ========================================================================
244
245    /// Update the last interaction score for a host+topic pair (upsert).
246    async fn update_last_interaction(
247        &self,
248        host: &str,
249        topic: &str,
250        since: u64,
251    ) -> Result<(), StorageError>;
252
253    /// Get the last interaction score for a host+topic pair. Returns 0 if not found.
254    async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError>;
255
256    // ========================================================================
257    // GASP peer health (bsv-low#302 — dead-peer quarantine)
258    // ========================================================================
259
260    // The three peer-health methods are REQUIRED (bsv-low #555, the
261    // delta-2 fold's D2-L2): with defaults a wrapper that did not forward
262    // one compiled and silently turned the quarantine or the yieldless bound
263    // off. A backend that keeps no peer health answers as the old defaults
264    // did (`Ok(())`, `PeerSyncHealth::default()`, `PeerYieldStreak::default()`):
265    // fail-safe, every peer keeps being attempted. `host` is the peer's
266    // NORMALIZED ORIGIN (`crate::gasp::peer_origin`), not its URL: the engine
267    // passes it (the delta-2 fold's D2-M2).
268
269    /// Record the outcome of ONE GASP sync attempt with `host` for `topic`
270    /// (bsv-low#302). `success = true` resets the consecutive-failure count
271    /// to 0 (full re-admission); `false` increments it. The backend stamps
272    /// its own clock for the attempt time — the engine never supplies wall
273    /// time. Quarantine-SKIPPED peers are NOT recorded (a skip is not an
274    /// attempt; the last-attempt age must keep growing so the re-probe
275    /// window opens). Nor is, with a per-graph budget, a sync of a peer at
276    /// the quarantine threshold that did not fail and yielded nothing (bsv-low
277    /// #555, the delta-4 fold's D4-L2): only a yield re-admits that peer.
278    async fn record_peer_sync_outcome(
279        &self,
280        host: &str,
281        topic: &str,
282        success: bool,
283    ) -> Result<(), StorageError>;
284
285    /// Current sync health of the (host, topic) pairing (bsv-low#302). The
286    /// backend answers with its consecutive-failure count and the AGE of
287    /// the last attempt (relative seconds, so the engine needs no clock of
288    /// its own). Pristine: never attempted, never quarantined.
289    async fn get_peer_sync_health(
290        &self,
291        host: &str,
292        topic: &str,
293    ) -> Result<PeerSyncHealth, StorageError>;
294
295    /// Record whether ONE GASP sync with `host` for `topic` YIELDED (it
296    /// finalized a graph or moved the cursor) or not, and answer the
297    /// yieldless STREAK it now holds (bsv-low #555, the delta fold's D-M2 and
298    /// the delta-2 fold's D2-M1; `crate::gasp::yieldless_sync_failed`):
299    /// - `true` ends the streak: count 0, no start; answers that.
300    /// - `false` adds one to the streak and answers its count and the
301    ///   seconds since its FIRST yieldless sync, on the backend's own clock
302    ///   (0 on the sync that starts it). A streak whose LAST yieldless sync
303    ///   is more than `crate::gasp::PEER_YIELDLESS_DECAY_SECS` old starts
304    ///   again (count 1, age 0): the decay. NOT for a peer whose
305    ///   [`Self::record_peer_sync_outcome`] count is above 0 (the delta-3
306    ///   fold's D3-M1): the decay equals the reprobe window, so a quarantined
307    ///   peer's probe would start a fresh streak, succeed and lift the
308    ///   quarantine; its probe continues the streak instead and fails.
309    ///
310    /// The engine calls it only with a per-graph budget, only for a sync
311    /// the peer served work, and before [`Self::record_peer_sync_outcome`].
312    async fn record_peer_sync_yield(
313        &self,
314        host: &str,
315        topic: &str,
316        yielded: bool,
317    ) -> Result<PeerYieldStreak, StorageError>;
318
319    // ========================================================================
320    // Deferred GASP graphs (bsv-low #555)
321    // ========================================================================
322
323    /// Save (REPLACE) the record of one deferred GASP graph, keyed by
324    /// (`record.peer`, `record.topic`, `record.outpoint`). One row per graph:
325    /// a later deferral of the same graph overwrites it, never appends.
326    /// `AtCeiling`: refused at a bound of the storage's own (the worker's
327    /// global ceiling), counted `too_many`; the walk then goes on under the
328    /// per-peer budget alone, as before #555.
329    ///
330    /// The four deferred-graph methods are REQUIRED (the lens fold's M2,
331    /// the house style of `TopicManager::identify_admissible_outputs`): with
332    /// a default, a storage or WRAPPER that did not forward them compiled,
333    /// and `Engine::set_graph_budget` over it saved nothing, walked every
334    /// graph past the call budget from its root on every tick and never
335    /// admitted it (`store_fault`), a hard cap on graph size worse than no
336    /// budget. A wrapper forwards all four to the storage it wraps:
337    ///
338    /// ```ignore
339    /// async fn put_deferred_graph(&self, record: &DeferredGraph)
340    ///     -> Result<DeferredGraphSave, StorageError> {
341    ///     self.inner.put_deferred_graph(record).await
342    /// }
343    /// // and the same for find_deferred_graphs, get_deferred_graph and
344    /// // delete_deferred_graph.
345    /// ```
346    ///
347    /// A backend that keeps no records answers none from
348    /// `find_deferred_graphs` and `get_deferred_graph` and an `Err` from
349    /// this one: every graph past the budget then fails its UTXO, so it
350    /// must not be given a per-graph budget.
351    async fn put_deferred_graph(
352        &self,
353        record: &crate::gasp::DeferredGraph,
354    ) -> Result<crate::gasp::DeferredGraphSave, StorageError>;
355
356    /// The KEYS of the records of (`host`, `topic`), lowest score first. A
357    /// sync reads these up front and each record only when its UTXO is
358    /// served (the lens fold's L3).
359    async fn find_deferred_graphs(
360        &self,
361        host: &str,
362        topic: &str,
363    ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError>;
364
365    /// The record of the graph of (`host`, `topic`) rooted at `outpoint`.
366    async fn get_deferred_graph(
367        &self,
368        host: &str,
369        topic: &str,
370        outpoint: &str,
371    ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError>;
372
373    /// Delete the record of the graph rooted at `outpoint`.
374    async fn delete_deferred_graph(
375        &self,
376        host: &str,
377        topic: &str,
378        outpoint: &str,
379    ) -> Result<(), StorageError>;
380}
381
382/// A (host, topic)'s yieldless streak (bsv-low #555, the delta-2 fold's
383/// D2-M1) — the input to [`crate::gasp::yieldless_sync_failed`].
384#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
385pub struct PeerYieldStreak {
386    /// Consecutive yieldless syncs (0 after a yield).
387    pub yieldless_syncs: u64,
388    /// Seconds since the streak's first yieldless sync. `None` = no streak,
389    /// or a backend with no clock: never past the time bound.
390    pub secs_since_first: Option<u64>,
391}
392
393/// Durable per-(host, topic) GASP sync health (bsv-low#302) — the input to
394/// the pure quarantine rule [`crate::gasp::peer_sync_quarantined`].
395#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
396pub struct PeerSyncHealth {
397    /// Consecutive sync attempts that ALL failed (timeout counts as a
398    /// failure). Reset to 0 by any successful sync.
399    pub consecutive_failures: u64,
400    /// Seconds since the last recorded sync ATTEMPT (success or failure).
401    /// `None` = never attempted / no health tracked — never quarantined.
402    pub secs_since_last_attempt: Option<u64>,
403}
404
405// ============================================================================
406// Rc delegation
407// ============================================================================
408
409/// Delegating impl so an `Rc<T: Storage>` is itself a `Storage` — lets a
410/// caller keep a shared handle to the concrete backend (e.g. a test advancing
411/// [`memory::MemoryStorage::advance_clock`]) while the engine owns a
412/// `Box<dyn Storage>` over the same instance. Pure delegation, no semantics.
413#[async_trait(?Send)]
414impl<T: Storage + ?Sized> Storage for std::rc::Rc<T> {
415    async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
416        (**self).insert_output(output).await
417    }
418    async fn delete_output(
419        &self,
420        txid: &str,
421        output_index: u32,
422        topic: &str,
423    ) -> Result<(), StorageError> {
424        (**self).delete_output(txid, output_index, topic).await
425    }
426    async fn mark_utxo_as_spent(
427        &self,
428        txid: &str,
429        output_index: u32,
430        topic: &str,
431    ) -> Result<(), StorageError> {
432        (**self).mark_utxo_as_spent(txid, output_index, topic).await
433    }
434    async fn update_consumed_by(
435        &self,
436        txid: &str,
437        output_index: u32,
438        topic: &str,
439        consumed_by: &[Outpoint],
440    ) -> Result<(), StorageError> {
441        (**self)
442            .update_consumed_by(txid, output_index, topic, consumed_by)
443            .await
444    }
445    async fn update_transaction_beef(&self, txid: &str, beef: &[u8]) -> Result<(), StorageError> {
446        (**self).update_transaction_beef(txid, beef).await
447    }
448    async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
449        (**self).mark_transaction_proven(txid).await
450    }
451    async fn mark_transaction_proven_at(
452        &self,
453        txid: &str,
454        height: Option<u64>,
455    ) -> Result<(), StorageError> {
456        // review LOW-4: forward the anchored latch too — without this the
457        // Rc blanket hits the trait default (drops the height, re-introducing
458        // MED-2) for any caller routing through an `Rc<dyn Storage>`.
459        (**self).mark_transaction_proven_at(txid, height).await
460    }
461    async fn update_output_block_height(
462        &self,
463        txid: &str,
464        output_index: u32,
465        topic: &str,
466        block_height: u32,
467    ) -> Result<(), StorageError> {
468        (**self)
469            .update_output_block_height(txid, output_index, topic, block_height)
470            .await
471    }
472    async fn insert_applied_transaction(
473        &self,
474        tx: &AppliedTransaction,
475    ) -> Result<(), StorageError> {
476        (**self).insert_applied_transaction(tx).await
477    }
478    async fn does_applied_transaction_exist(
479        &self,
480        tx: &AppliedTransaction,
481    ) -> Result<bool, StorageError> {
482        (**self).does_applied_transaction_exist(tx).await
483    }
484    async fn delete_applied_transaction(
485        &self,
486        tx: &AppliedTransaction,
487    ) -> Result<(), StorageError> {
488        (**self).delete_applied_transaction(tx).await
489    }
490    async fn find_output(
491        &self,
492        txid: &str,
493        output_index: u32,
494        topic: Option<&str>,
495        spent: Option<bool>,
496        include_beef: bool,
497    ) -> Result<Option<Output>, StorageError> {
498        (**self)
499            .find_output(txid, output_index, topic, spent, include_beef)
500            .await
501    }
502    async fn find_outputs_for_transaction(
503        &self,
504        txid: &str,
505        include_beef: bool,
506    ) -> Result<Vec<Output>, StorageError> {
507        (**self)
508            .find_outputs_for_transaction(txid, include_beef)
509            .await
510    }
511    async fn find_utxos_for_topic(
512        &self,
513        topic: &str,
514        since: Option<f64>,
515        limit: Option<u64>,
516        include_beef: bool,
517    ) -> Result<Vec<Output>, StorageError> {
518        (**self)
519            .find_utxos_for_topic(topic, since, limit, include_beef)
520            .await
521    }
522    async fn find_transactions_for_proof_check(
523        &self,
524        limit: u64,
525        min_age_secs: u64,
526    ) -> Result<Vec<TransactionBeef>, StorageError> {
527        (**self)
528            .find_transactions_for_proof_check(limit, min_age_secs)
529            .await
530    }
531    async fn update_last_interaction(
532        &self,
533        host: &str,
534        topic: &str,
535        since: u64,
536    ) -> Result<(), StorageError> {
537        (**self).update_last_interaction(host, topic, since).await
538    }
539    async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
540        (**self).get_last_interaction(host, topic).await
541    }
542    async fn record_peer_sync_outcome(
543        &self,
544        host: &str,
545        topic: &str,
546        success: bool,
547    ) -> Result<(), StorageError> {
548        (**self)
549            .record_peer_sync_outcome(host, topic, success)
550            .await
551    }
552    async fn get_peer_sync_health(
553        &self,
554        host: &str,
555        topic: &str,
556    ) -> Result<PeerSyncHealth, StorageError> {
557        (**self).get_peer_sync_health(host, topic).await
558    }
559    async fn record_peer_sync_yield(
560        &self,
561        host: &str,
562        topic: &str,
563        yielded: bool,
564    ) -> Result<PeerYieldStreak, StorageError> {
565        (**self).record_peer_sync_yield(host, topic, yielded).await
566    }
567    async fn put_deferred_graph(
568        &self,
569        record: &crate::gasp::DeferredGraph,
570    ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
571        (**self).put_deferred_graph(record).await
572    }
573    async fn find_deferred_graphs(
574        &self,
575        host: &str,
576        topic: &str,
577    ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
578        (**self).find_deferred_graphs(host, topic).await
579    }
580    async fn get_deferred_graph(
581        &self,
582        host: &str,
583        topic: &str,
584        outpoint: &str,
585    ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
586        (**self).get_deferred_graph(host, topic, outpoint).await
587    }
588    async fn delete_deferred_graph(
589        &self,
590        host: &str,
591        topic: &str,
592        outpoint: &str,
593    ) -> Result<(), StorageError> {
594        (**self).delete_deferred_graph(host, topic, outpoint).await
595    }
596}
597
598// ============================================================================
599// Error type
600// ============================================================================
601
602/// Storage operation errors.
603#[derive(Debug, thiserror::Error)]
604pub enum StorageError {
605    #[error("not found: {0}")]
606    NotFound(String),
607
608    #[error("duplicate: {0}")]
609    Duplicate(String),
610
611    #[error("database error: {0}")]
612    Database(String),
613
614    #[error("serialization error: {0}")]
615    Serialization(String),
616
617    #[error("{0}")]
618    Other(String),
619}
620
621// ============================================================================
622// In-memory storage (for tests and local dev)
623// ============================================================================
624
625#[cfg(any(test, feature = "memory-storage"))]
626pub mod memory {
627    use super::*;
628    use std::collections::HashMap;
629    use std::sync::Mutex;
630
631    /// In-memory Storage implementation for testing and local development.
632    ///
633    /// NOT suitable for production — no persistence, no concurrency beyond Mutex.
634    #[derive(Debug, Default)]
635    pub struct MemoryStorage {
636        /// outputs keyed by (txid, outputIndex, topic)
637        outputs: Mutex<HashMap<(String, u32, String), Output>>,
638        /// BEEF data keyed by txid
639        transactions: Mutex<HashMap<String, Vec<u8>>>,
640        /// txids whose `has_proof` flag is set. Models the real D1
641        /// `transactions.has_proof` column (overlay migration 0010), which is
642        /// the source of truth for `find_transactions_for_proof_check` — NOT a
643        /// live re-parse of the BEEF. A txid is absent here (flag = 0) until
644        /// something explicitly proves it, so a BEEF that already carries a
645        /// proof but is not in this set is still returned as a candidate — the
646        /// exact window-clog the cron must clear via `mark_transaction_proven`.
647        proven: Mutex<std::collections::HashSet<String>>,
648        /// applied transactions keyed by (txid, topic)
649        applied: Mutex<HashMap<(String, String), bool>>,
650        /// GASP sync state keyed by (host, topic)
651        sync_state: Mutex<HashMap<(String, String), u64>>,
652        /// Deterministic logical clock (seconds) for the push-primary backstop
653        /// age gate — models the D1 backend's `unixepoch()`. Tests advance it
654        /// with [`MemoryStorage::advance_clock`]; no wall clock is ever read
655        /// (wasm-safe, deterministic).
656        clock_secs: Mutex<u64>,
657        /// First-store stamp (clock seconds) per txid — models the D1
658        /// `transactions.created_at` column. A txid ABSENT here has UNKNOWN
659        /// age and is treated as OLD (eligible) — the fail-safe direction.
660        created_at: Mutex<HashMap<String, u64>>,
661        /// GASP peer health keyed by (host, topic) —
662        /// `(consecutive_failures, last_attempt_at_clock_secs)`. Models the
663        /// D1 `gasp_peer_health` table (bsv-low#302); the logical clock
664        /// above stands in for `unixepoch()`.
665        peer_health: Mutex<HashMap<(String, String), (u64, u64)>>,
666        /// review LOW-4: records the height an `_at` latch carried, so a pin
667        /// can prove the `Rc` blanket forwards `mark_transaction_proven_at`
668        /// (the trait default would drop the height).
669        proven_at: Mutex<HashMap<String, Option<u64>>>,
670        /// Deferred GASP graphs (bsv-low #555) keyed by (host, topic,
671        /// outpoint): models the D1 `gasp_deferred_graphs` table.
672        deferred_graphs: Mutex<HashMap<(String, String, String), crate::gasp::DeferredGraph>>,
673        /// How many times a deferred graph was WRITTEN (a pin counts the
674        /// storage writes of a deferral).
675        deferred_graph_writes: Mutex<u64>,
676        /// How many single records were READ (bsv-low #555, the lens
677        /// fold's L3).
678        deferred_graph_reads: Mutex<u64>,
679        /// Test knob: the key read of the deferred graphs faults.
680        deferred_graph_keys_fault: Mutex<bool>,
681        /// Test knob: a NEW record past this many held is refused
682        /// `AtCeiling` (the worker's global ceiling).
683        deferred_graph_ceiling: Mutex<Option<usize>>,
684        /// Test knob: a save that would take the bytes of every record held
685        /// past this is refused `AtCeiling`, a held key's too (the worker's
686        /// byte bounds, bsv-low #585).
687        deferred_graph_byte_ceiling: Mutex<Option<usize>>,
688        /// Test knob: every save of a deferred graph faults.
689        deferred_graph_put_fault: Mutex<bool>,
690        /// How many saves of a deferred graph were ASKED (written, refused
691        /// or faulted).
692        deferred_graph_put_attempts: Mutex<u64>,
693        /// The yieldless streak keyed by (host, topic): `(count,
694        /// first_at, last_at)` on the logical clock. Models the D1
695        /// `gasp_peer_health` columns `yieldless_syncs`, `first_yieldless_at`
696        /// and `last_yieldless_at` (bsv-low #555, the delta fold's D-M2 and
697        /// the delta-2 fold's D2-M1).
698        peer_yieldless: Mutex<HashMap<(String, String), YieldStreakRow>>,
699    }
700
701    /// `(count, first_at, last_at)` of a yieldless streak, on the logical
702    /// clock.
703    type YieldStreakRow = (u64, u64, u64);
704
705    impl MemoryStorage {
706        pub fn new() -> Self {
707            Self::default()
708        }
709
710        /// The anchor height a `mark_transaction_proven_at` recorded for
711        /// `txid` (review LOW-4): `None` = never called `_at`; `Some(h)` =
712        /// called with height `h`.
713        pub fn proven_at_height(&self, txid: &str) -> Option<Option<u64>> {
714            self.proven_at.lock().unwrap().get(txid).copied()
715        }
716
717        /// Advance the deterministic logical clock by `secs` (test hook for
718        /// the push-primary backstop age gate — see
719        /// [`Storage::find_transactions_for_proof_check`]).
720        pub fn advance_clock(&self, secs: u64) {
721            *self.clock_secs.lock().unwrap() += secs;
722        }
723
724        /// Stamp `txid`'s first-store time at the current clock if absent
725        /// (models `COALESCE(existing, unixepoch())` in the D1 backend).
726        fn stamp_created(&self, txid: &str) {
727            let now = *self.clock_secs.lock().unwrap();
728            self.created_at
729                .lock()
730                .unwrap()
731                .entry(txid.to_string())
732                .or_insert(now);
733        }
734
735        /// Whether `txid` clears the backstop age gate: unknown age is OLD
736        /// (eligible, fail-safe); otherwise `clock - created >= min_age_secs`.
737        fn age_gate_open(&self, txid: &str, min_age_secs: u64) -> bool {
738            if min_age_secs == 0 {
739                return true;
740            }
741            let now = *self.clock_secs.lock().unwrap();
742            match self.created_at.lock().unwrap().get(txid) {
743                None => true, // unknown age → treated old → eligible
744                Some(created) => now.saturating_sub(*created) >= min_age_secs,
745            }
746        }
747
748        /// Every deferred GASP graph held, any peer or topic (bsv-low #555).
749        pub fn deferred_graphs(&self) -> Vec<crate::gasp::DeferredGraph> {
750            let mut all: Vec<_> = self
751                .deferred_graphs
752                .lock()
753                .unwrap()
754                .values()
755                .cloned()
756                .collect();
757            all.sort_by(|a, b| (a.score, &a.outpoint).cmp(&(b.score, &b.outpoint)));
758            all
759        }
760
761        /// How many deferred-graph writes were made (bsv-low #555).
762        pub fn deferred_graph_writes(&self) -> u64 {
763            *self.deferred_graph_writes.lock().unwrap()
764        }
765
766        /// How many single records were read (bsv-low #555).
767        pub fn deferred_graph_reads(&self) -> u64 {
768            *self.deferred_graph_reads.lock().unwrap()
769        }
770
771        /// Make the key read of the deferred graphs fault, or not (a test
772        /// knob, bsv-low #555).
773        pub fn set_deferred_graph_keys_fault(&self, fault: bool) {
774            *self.deferred_graph_keys_fault.lock().unwrap() = fault;
775        }
776
777        /// Refuse a NEW record `AtCeiling` once this many are held, as the
778        /// worker's global ceiling does (a test knob, bsv-low #555).
779        pub fn set_deferred_graph_ceiling(&self, ceiling: Option<usize>) {
780            *self.deferred_graph_ceiling.lock().unwrap() = ceiling;
781        }
782
783        /// Refuse `AtCeiling` a save, new or a replacement, that would take
784        /// the bytes of every record held past this, as the worker's byte
785        /// bounds do (a test knob, bsv-low #585). The held record stays.
786        pub fn set_deferred_graph_byte_ceiling(&self, ceiling: Option<usize>) {
787            *self.deferred_graph_byte_ceiling.lock().unwrap() = ceiling;
788        }
789
790        /// Make every save of a deferred graph fault, or not (a test knob,
791        /// bsv-low #555's delta fold, D-L2).
792        pub fn set_deferred_graph_put_fault(&self, fault: bool) {
793            *self.deferred_graph_put_fault.lock().unwrap() = fault;
794        }
795
796        /// How many saves of a deferred graph were asked, whatever their
797        /// answer (bsv-low #555's delta fold, D-L1).
798        pub fn deferred_graph_put_attempts(&self) -> u64 {
799            *self.deferred_graph_put_attempts.lock().unwrap()
800        }
801
802        /// The consecutive yieldless syncs held for (host, topic)
803        /// (bsv-low #555's delta fold, D-M2). `host` may be a peer URL: it
804        /// is read under its [`crate::gasp::peer_origin`], the key the
805        /// engine writes (the delta-2 fold's D2-M2).
806        pub fn peer_yieldless_syncs(&self, host: &str, topic: &str) -> u64 {
807            self.peer_yieldless
808                .lock()
809                .unwrap()
810                .get(&(crate::gasp::peer_origin(host), topic.to_string()))
811                .map_or(0, |(count, _, _)| *count)
812        }
813
814        /// Count total outputs (for testing assertions).
815        pub fn output_count(&self) -> usize {
816            self.outputs.lock().unwrap().len()
817        }
818
819        /// Count total transactions (for testing assertions).
820        pub fn transaction_count(&self) -> usize {
821            self.transactions.lock().unwrap().len()
822        }
823    }
824
825    #[async_trait(?Send)]
826    impl Storage for MemoryStorage {
827        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
828            let key = (
829                output.txid.clone(),
830                output.output_index,
831                output.topic.clone(),
832            );
833            let mut outputs = self.outputs.lock().unwrap();
834
835            // No-op if already exists (match KnexStorage behavior)
836            if outputs.contains_key(&key) {
837                // Still upsert BEEF if provided
838                if let Some(ref beef) = output.beef {
839                    self.transactions
840                        .lock()
841                        .unwrap()
842                        .entry(output.txid.clone())
843                        .or_insert_with(|| beef.clone());
844                }
845                return Ok(());
846            }
847
848            // Store output without BEEF (BEEF goes in transactions table)
849            let mut stored = output.clone();
850            stored.beef = None;
851            outputs.insert(key, stored);
852
853            // Upsert BEEF into transactions table
854            if let Some(ref beef) = output.beef {
855                self.transactions
856                    .lock()
857                    .unwrap()
858                    .entry(output.txid.clone())
859                    .or_insert_with(|| beef.clone());
860                self.stamp_created(&output.txid);
861            }
862
863            Ok(())
864        }
865
866        async fn delete_output(
867            &self,
868            txid: &str,
869            output_index: u32,
870            topic: &str,
871        ) -> Result<(), StorageError> {
872            let key = (txid.to_string(), output_index, topic.to_string());
873            let mut outputs = self.outputs.lock().unwrap();
874            outputs.remove(&key);
875
876            // If no more outputs reference this txid, remove the BEEF
877            let has_remaining = outputs.keys().any(|(t, _, _)| t == txid);
878            if !has_remaining {
879                self.transactions.lock().unwrap().remove(txid);
880                self.proven.lock().unwrap().remove(txid);
881            }
882
883            Ok(())
884        }
885
886        async fn mark_utxo_as_spent(
887            &self,
888            txid: &str,
889            output_index: u32,
890            topic: &str,
891        ) -> Result<(), StorageError> {
892            let key = (txid.to_string(), output_index, topic.to_string());
893            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
894                output.spent = true;
895            }
896            Ok(())
897        }
898
899        async fn update_consumed_by(
900            &self,
901            txid: &str,
902            output_index: u32,
903            topic: &str,
904            consumed_by: &[Outpoint],
905        ) -> Result<(), StorageError> {
906            let key = (txid.to_string(), output_index, topic.to_string());
907            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
908                output.consumed_by = consumed_by.to_vec();
909            }
910            Ok(())
911        }
912
913        async fn update_transaction_beef(
914            &self,
915            txid: &str,
916            beef: &[u8],
917        ) -> Result<(), StorageError> {
918            // Model the D1 backend: keep the proof flag accurate on every BEEF
919            // write by inspecting whether the new BEEF proves the target tx.
920            // (The proof-completion stitch calls back here with a proven BEEF,
921            // which is how a row legitimately flips proofless → proven.)
922            // A streaming fold: one element in hand, no `Beef` built.
923            let has_proof = beef_limits::has_proof(beef, txid);
924            if has_proof {
925                self.proven.lock().unwrap().insert(txid.to_string());
926            }
927            self.transactions
928                .lock()
929                .unwrap()
930                .insert(txid.to_string(), beef.to_vec());
931            // Preserve-or-stamp: a rewrite keeps the ORIGINAL first-store
932            // time (models the D1 COALESCE), so the backstop age stays real.
933            self.stamp_created(txid);
934            Ok(())
935        }
936
937        async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
938            // Lightweight flag-flip (no BEEF rewrite), idempotent. Mirrors the
939            // D1 `UPDATE transactions SET has_proof = 1 WHERE txid = ?`.
940            self.proven.lock().unwrap().insert(txid.to_string());
941            Ok(())
942        }
943
944        async fn mark_transaction_proven_at(
945            &self,
946            txid: &str,
947            height: Option<u64>,
948        ) -> Result<(), StorageError> {
949            // Models the D1 `MARK_TX_PROVEN_AT_SQL`: flip has_proof AND record
950            // the anchor (review MED-2/LOW-4). Recorded so the Rc-forward pin
951            // can observe the height reaching the inner store.
952            self.proven.lock().unwrap().insert(txid.to_string());
953            self.proven_at
954                .lock()
955                .unwrap()
956                .insert(txid.to_string(), height);
957            Ok(())
958        }
959
960        async fn update_output_block_height(
961            &self,
962            txid: &str,
963            output_index: u32,
964            topic: &str,
965            block_height: u32,
966        ) -> Result<(), StorageError> {
967            let key = (txid.to_string(), output_index, topic.to_string());
968            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
969                output.block_height = Some(block_height);
970            }
971            Ok(())
972        }
973
974        async fn insert_applied_transaction(
975            &self,
976            tx: &AppliedTransaction,
977        ) -> Result<(), StorageError> {
978            self.applied
979                .lock()
980                .unwrap()
981                .insert((tx.txid.clone(), tx.topic.clone()), true);
982            Ok(())
983        }
984
985        async fn does_applied_transaction_exist(
986            &self,
987            tx: &AppliedTransaction,
988        ) -> Result<bool, StorageError> {
989            Ok(self
990                .applied
991                .lock()
992                .unwrap()
993                .contains_key(&(tx.txid.clone(), tx.topic.clone())))
994        }
995
996        async fn delete_applied_transaction(
997            &self,
998            tx: &AppliedTransaction,
999        ) -> Result<(), StorageError> {
1000            self.applied
1001                .lock()
1002                .unwrap()
1003                .remove(&(tx.txid.clone(), tx.topic.clone()));
1004            Ok(())
1005        }
1006
1007        async fn find_output(
1008            &self,
1009            txid: &str,
1010            output_index: u32,
1011            topic: Option<&str>,
1012            spent: Option<bool>,
1013            include_beef: bool,
1014        ) -> Result<Option<Output>, StorageError> {
1015            let outputs = self.outputs.lock().unwrap();
1016
1017            // If topic is specified, do a direct lookup
1018            if let Some(topic) = topic {
1019                let key = (txid.to_string(), output_index, topic.to_string());
1020                if let Some(output) = outputs.get(&key) {
1021                    if let Some(s) = spent {
1022                        if output.spent != s {
1023                            return Ok(None);
1024                        }
1025                    }
1026                    let mut result = output.clone();
1027                    if include_beef {
1028                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1029                    }
1030                    return Ok(Some(result));
1031                }
1032                return Ok(None);
1033            }
1034
1035            // No topic — find first matching (txid, outputIndex)
1036            for ((t, oi, _), output) in outputs.iter() {
1037                if t == txid && *oi == output_index {
1038                    if let Some(s) = spent {
1039                        if output.spent != s {
1040                            continue;
1041                        }
1042                    }
1043                    let mut result = output.clone();
1044                    if include_beef {
1045                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1046                    }
1047                    return Ok(Some(result));
1048                }
1049            }
1050            Ok(None)
1051        }
1052
1053        async fn find_outputs_for_transaction(
1054            &self,
1055            txid: &str,
1056            include_beef: bool,
1057        ) -> Result<Vec<Output>, StorageError> {
1058            let outputs = self.outputs.lock().unwrap();
1059            let beef = if include_beef {
1060                self.transactions.lock().unwrap().get(txid).cloned()
1061            } else {
1062                None
1063            };
1064
1065            let results: Vec<Output> = outputs
1066                .iter()
1067                .filter(|((t, _, _), _)| t == txid)
1068                .map(|(_, output)| {
1069                    let mut o = output.clone();
1070                    if include_beef {
1071                        o.beef.clone_from(&beef);
1072                    }
1073                    o
1074                })
1075                .collect();
1076
1077            Ok(results)
1078        }
1079
1080        async fn find_utxos_for_topic(
1081            &self,
1082            topic: &str,
1083            since: Option<f64>,
1084            limit: Option<u64>,
1085            include_beef: bool,
1086        ) -> Result<Vec<Output>, StorageError> {
1087            let outputs = self.outputs.lock().unwrap();
1088            let transactions = self.transactions.lock().unwrap();
1089
1090            let mut results: Vec<Output> = outputs
1091                .iter()
1092                .filter(|((_, _, t), o)| {
1093                    t == topic && !o.spent && since.is_none_or(|s| o.score.unwrap_or(0.0) >= s)
1094                })
1095                .map(|(_, output)| {
1096                    let mut o = output.clone();
1097                    if include_beef {
1098                        o.beef = transactions.get(&o.txid).cloned();
1099                    }
1100                    o
1101                })
1102                .collect();
1103
1104            // Sort by score ascending (matching KnexStorage behavior)
1105            results.sort_by(|a, b| {
1106                a.score
1107                    .unwrap_or(0.0)
1108                    .partial_cmp(&b.score.unwrap_or(0.0))
1109                    .unwrap_or(std::cmp::Ordering::Equal)
1110            });
1111
1112            if let Some(limit) = limit {
1113                results.truncate(limit as usize);
1114            }
1115
1116            Ok(results)
1117        }
1118
1119        async fn find_transactions_for_proof_check(
1120            &self,
1121            limit: u64,
1122            min_age_secs: u64,
1123        ) -> Result<Vec<TransactionBeef>, StorageError> {
1124            // Models the real D1 `WHERE has_proof = 0 LIMIT {limit}` query
1125            // (overlay migration 0010): the candidate set is driven by the
1126            // `has_proof` FLAG, not a live re-parse of the BEEF. A row whose
1127            // BEEF already carries a proof but whose flag is still 0 (e.g. a
1128            // GASP-synced proof written before the migration, which defaulted
1129            // every existing row to 0) is therefore STILL returned here — it
1130            // only drops out once `mark_transaction_proven` flips the flag.
1131            // This reproduces the window-clog the engine must clear.
1132            //
1133            // The push-primary backstop age gate (#228) excludes rows younger
1134            // than `min_age_secs` (their proof is expected via /arc-ingest);
1135            // unknown-age rows stay eligible (fail-safe — trait doc).
1136            let candidates: Vec<TransactionBeef> = {
1137                let transactions = self.transactions.lock().unwrap();
1138                let proven = self.proven.lock().unwrap();
1139                transactions
1140                    .iter()
1141                    .filter(|(txid, _)| !proven.contains(*txid))
1142                    .map(|(txid, beef)| TransactionBeef {
1143                        txid: txid.clone(),
1144                        beef: beef.clone(),
1145                    })
1146                    .collect()
1147            };
1148            Ok(candidates
1149                .into_iter()
1150                .filter(|c| self.age_gate_open(&c.txid, min_age_secs))
1151                .take(limit as usize)
1152                .collect())
1153        }
1154
1155        async fn update_last_interaction(
1156            &self,
1157            host: &str,
1158            topic: &str,
1159            since: u64,
1160        ) -> Result<(), StorageError> {
1161            self.sync_state
1162                .lock()
1163                .unwrap()
1164                .insert((host.to_string(), topic.to_string()), since);
1165            Ok(())
1166        }
1167
1168        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
1169            Ok(*self
1170                .sync_state
1171                .lock()
1172                .unwrap()
1173                .get(&(host.to_string(), topic.to_string()))
1174                .unwrap_or(&0))
1175        }
1176
1177        async fn record_peer_sync_outcome(
1178            &self,
1179            host: &str,
1180            topic: &str,
1181            success: bool,
1182        ) -> Result<(), StorageError> {
1183            let now = *self.clock_secs.lock().unwrap();
1184            let mut health = self.peer_health.lock().unwrap();
1185            let entry = health
1186                .entry((host.to_string(), topic.to_string()))
1187                .or_insert((0, now));
1188            entry.0 = if success { 0 } else { entry.0 + 1 };
1189            entry.1 = now;
1190            Ok(())
1191        }
1192
1193        async fn get_peer_sync_health(
1194            &self,
1195            host: &str,
1196            topic: &str,
1197        ) -> Result<PeerSyncHealth, StorageError> {
1198            let now = *self.clock_secs.lock().unwrap();
1199            Ok(self
1200                .peer_health
1201                .lock()
1202                .unwrap()
1203                .get(&(host.to_string(), topic.to_string()))
1204                .map(|(fails, attempt_at)| PeerSyncHealth {
1205                    consecutive_failures: *fails,
1206                    secs_since_last_attempt: Some(now.saturating_sub(*attempt_at)),
1207                })
1208                .unwrap_or_default())
1209        }
1210
1211        async fn record_peer_sync_yield(
1212            &self,
1213            host: &str,
1214            topic: &str,
1215            yielded: bool,
1216        ) -> Result<PeerYieldStreak, StorageError> {
1217            let now = *self.clock_secs.lock().unwrap();
1218            let key = (host.to_string(), topic.to_string());
1219            // The delta-3 fold's D3-M1: the decay only for a peer that is
1220            // not failed (the worker's statement reads the same row).
1221            let not_failed = self
1222                .peer_health
1223                .lock()
1224                .unwrap()
1225                .get(&key)
1226                .is_none_or(|(fails, _)| *fails == 0);
1227            let mut held = self.peer_yieldless.lock().unwrap();
1228            if yielded {
1229                held.remove(&key);
1230                return Ok(PeerYieldStreak::default());
1231            }
1232            let streak = held.entry(key).or_insert((0, now, now));
1233            if streak.0 == 0
1234                || (not_failed
1235                    && now.saturating_sub(streak.2) > crate::gasp::PEER_YIELDLESS_DECAY_SECS)
1236            {
1237                *streak = (0, now, now);
1238            }
1239            streak.0 += 1;
1240            streak.2 = now;
1241            Ok(PeerYieldStreak {
1242                yieldless_syncs: streak.0,
1243                secs_since_first: Some(now.saturating_sub(streak.1)),
1244            })
1245        }
1246
1247        async fn put_deferred_graph(
1248            &self,
1249            record: &crate::gasp::DeferredGraph,
1250        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
1251            let key = (
1252                record.peer.clone(),
1253                record.topic.clone(),
1254                record.outpoint.clone(),
1255            );
1256            *self.deferred_graph_put_attempts.lock().unwrap() += 1;
1257            if *self.deferred_graph_put_fault.lock().unwrap() {
1258                return Err(StorageError::Database("deferred graph save faulted".into()));
1259            }
1260            if let Some(ceiling) = *self.deferred_graph_ceiling.lock().unwrap() {
1261                let held = self.deferred_graphs.lock().unwrap();
1262                if !held.contains_key(&key) && held.len() >= ceiling {
1263                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1264                }
1265            }
1266            if let Some(ceiling) = *self.deferred_graph_byte_ceiling.lock().unwrap() {
1267                let held = self.deferred_graphs.lock().unwrap();
1268                let others: usize = held
1269                    .iter()
1270                    .filter(|(k, _)| **k != key)
1271                    .map(|(_, r)| r.byte_size())
1272                    .sum();
1273                if others + record.byte_size() > ceiling {
1274                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1275                }
1276            }
1277            *self.deferred_graph_writes.lock().unwrap() += 1;
1278            self.deferred_graphs.lock().unwrap().insert(
1279                (
1280                    record.peer.clone(),
1281                    record.topic.clone(),
1282                    record.outpoint.clone(),
1283                ),
1284                record.clone(),
1285            );
1286            Ok(crate::gasp::DeferredGraphSave::Saved)
1287        }
1288
1289        async fn find_deferred_graphs(
1290            &self,
1291            host: &str,
1292            topic: &str,
1293        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
1294            if *self.deferred_graph_keys_fault.lock().unwrap() {
1295                return Err(StorageError::Database("deferred graph keys: fault".into()));
1296            }
1297            Ok(self
1298                .deferred_graphs()
1299                .into_iter()
1300                .filter(|r| r.peer == host && r.topic == topic)
1301                .map(|r| crate::gasp::DeferredGraphKey {
1302                    outpoint: r.outpoint,
1303                    score: r.score,
1304                })
1305                .collect())
1306        }
1307
1308        async fn get_deferred_graph(
1309            &self,
1310            host: &str,
1311            topic: &str,
1312            outpoint: &str,
1313        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
1314            *self.deferred_graph_reads.lock().unwrap() += 1;
1315            Ok(self
1316                .deferred_graphs
1317                .lock()
1318                .unwrap()
1319                .get(&(host.to_string(), topic.to_string(), outpoint.to_string()))
1320                .cloned())
1321        }
1322
1323        async fn delete_deferred_graph(
1324            &self,
1325            host: &str,
1326            topic: &str,
1327            outpoint: &str,
1328        ) -> Result<(), StorageError> {
1329            self.deferred_graphs.lock().unwrap().remove(&(
1330                host.to_string(),
1331                topic.to_string(),
1332                outpoint.to_string(),
1333            ));
1334            Ok(())
1335        }
1336    }
1337}
1338
1339// ============================================================================
1340// Tests
1341// ============================================================================
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::memory::MemoryStorage;
1346    use super::*;
1347    use crate::types::AppliedTransaction;
1348
1349    /// review LOW-4: the `Rc<T: Storage>` blanket must FORWARD
1350    /// `mark_transaction_proven_at`, not fall through to the trait default
1351    /// (which drops the height, re-introducing MED-2). Call it through an
1352    /// `Rc<dyn Storage>` and assert the height reached the inner store.
1353    #[tokio::test]
1354    async fn the_rc_blanket_forwards_the_anchored_proven_latch() {
1355        let store = std::rc::Rc::new(MemoryStorage::new());
1356        let via_dyn: std::rc::Rc<dyn Storage> = store.clone();
1357        via_dyn
1358            .mark_transaction_proven_at("tx", Some(965_772))
1359            .await
1360            .unwrap();
1361        assert_eq!(
1362            store.proven_at_height("tx"),
1363            Some(Some(965_772)),
1364            "the Rc blanket forwarded the height to the inner store"
1365        );
1366        // a height-less call still forwards (records None, not "never called")
1367        via_dyn
1368            .mark_transaction_proven_at("tx2", None)
1369            .await
1370            .unwrap();
1371        assert_eq!(store.proven_at_height("tx2"), Some(None));
1372        assert_eq!(
1373            store.proven_at_height("never"),
1374            None,
1375            "a txid never latched is absent"
1376        );
1377    }
1378
1379    fn make_output(txid: &str, index: u32, topic: &str, score: f64) -> Output {
1380        Output {
1381            txid: txid.to_string(),
1382            output_index: index,
1383            output_script: vec![0x76, 0xa9],
1384            satoshis: 1000,
1385            topic: topic.to_string(),
1386            spent: false,
1387            outputs_consumed: vec![],
1388            consumed_by: vec![],
1389            beef: Some(vec![0xBE, 0xEF]),
1390            block_height: None,
1391            score: Some(score),
1392        }
1393    }
1394
1395    #[tokio::test]
1396    async fn test_insert_and_find_output() {
1397        let store = MemoryStorage::new();
1398        let output = make_output("abc", 0, "tm_test", 1.0);
1399        store.insert_output(&output).await.unwrap();
1400
1401        // Find without BEEF
1402        let found = store
1403            .find_output("abc", 0, Some("tm_test"), None, false)
1404            .await
1405            .unwrap()
1406            .unwrap();
1407        assert_eq!(found.txid, "abc");
1408        assert_eq!(found.output_index, 0);
1409        assert!(found.beef.is_none());
1410
1411        // Find with BEEF
1412        let found = store
1413            .find_output("abc", 0, Some("tm_test"), None, true)
1414            .await
1415            .unwrap()
1416            .unwrap();
1417        assert_eq!(found.beef.unwrap(), vec![0xBE, 0xEF]);
1418    }
1419
1420    #[tokio::test]
1421    async fn test_insert_duplicate_is_noop() {
1422        let store = MemoryStorage::new();
1423        let output = make_output("abc", 0, "tm_test", 1.0);
1424        store.insert_output(&output).await.unwrap();
1425        store.insert_output(&output).await.unwrap();
1426        assert_eq!(store.output_count(), 1);
1427    }
1428
1429    #[tokio::test]
1430    async fn test_delete_output_cleans_up_beef() {
1431        let store = MemoryStorage::new();
1432        let output = make_output("abc", 0, "tm_test", 1.0);
1433        store.insert_output(&output).await.unwrap();
1434        assert_eq!(store.transaction_count(), 1);
1435
1436        store.delete_output("abc", 0, "tm_test").await.unwrap();
1437        assert_eq!(store.output_count(), 0);
1438        assert_eq!(store.transaction_count(), 0); // BEEF cleaned up
1439    }
1440
1441    #[tokio::test]
1442    async fn test_delete_output_keeps_beef_if_other_outputs_exist() {
1443        let store = MemoryStorage::new();
1444        store
1445            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1446            .await
1447            .unwrap();
1448        store
1449            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
1450            .await
1451            .unwrap();
1452        assert_eq!(store.output_count(), 2);
1453        assert_eq!(store.transaction_count(), 1);
1454
1455        store.delete_output("abc", 0, "tm_test").await.unwrap();
1456        assert_eq!(store.output_count(), 1);
1457        assert_eq!(store.transaction_count(), 1); // BEEF kept for output 1
1458    }
1459
1460    #[tokio::test]
1461    async fn test_mark_utxo_as_spent() {
1462        let store = MemoryStorage::new();
1463        store
1464            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1465            .await
1466            .unwrap();
1467
1468        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
1469
1470        let found = store
1471            .find_output("abc", 0, Some("tm_test"), None, false)
1472            .await
1473            .unwrap()
1474            .unwrap();
1475        assert!(found.spent);
1476    }
1477
1478    #[tokio::test]
1479    async fn test_find_output_with_spent_filter() {
1480        let store = MemoryStorage::new();
1481        store
1482            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1483            .await
1484            .unwrap();
1485
1486        // Not spent — should find with spent=false, not with spent=true
1487        assert!(store
1488            .find_output("abc", 0, Some("tm_test"), Some(false), false)
1489            .await
1490            .unwrap()
1491            .is_some());
1492        assert!(store
1493            .find_output("abc", 0, Some("tm_test"), Some(true), false)
1494            .await
1495            .unwrap()
1496            .is_none());
1497
1498        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
1499
1500        // Now spent — reversed
1501        assert!(store
1502            .find_output("abc", 0, Some("tm_test"), Some(true), false)
1503            .await
1504            .unwrap()
1505            .is_some());
1506        assert!(store
1507            .find_output("abc", 0, Some("tm_test"), Some(false), false)
1508            .await
1509            .unwrap()
1510            .is_none());
1511    }
1512
1513    #[tokio::test]
1514    async fn test_update_consumed_by() {
1515        let store = MemoryStorage::new();
1516        store
1517            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1518            .await
1519            .unwrap();
1520
1521        let consumed = vec![Outpoint::new("def", 0), Outpoint::new("ghi", 1)];
1522        store
1523            .update_consumed_by("abc", 0, "tm_test", &consumed)
1524            .await
1525            .unwrap();
1526
1527        let found = store
1528            .find_output("abc", 0, Some("tm_test"), None, false)
1529            .await
1530            .unwrap()
1531            .unwrap();
1532        assert_eq!(found.consumed_by.len(), 2);
1533        assert_eq!(found.consumed_by[0].txid, "def");
1534        assert_eq!(found.consumed_by[1].output_index, 1);
1535    }
1536
1537    #[tokio::test]
1538    async fn test_update_transaction_beef() {
1539        let store = MemoryStorage::new();
1540        store
1541            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1542            .await
1543            .unwrap();
1544
1545        store
1546            .update_transaction_beef("abc", &[0xDE, 0xAD])
1547            .await
1548            .unwrap();
1549
1550        let found = store
1551            .find_output("abc", 0, Some("tm_test"), None, true)
1552            .await
1553            .unwrap()
1554            .unwrap();
1555        assert_eq!(found.beef.unwrap(), vec![0xDE, 0xAD]);
1556    }
1557
1558    #[tokio::test]
1559    async fn test_update_output_block_height() {
1560        let store = MemoryStorage::new();
1561        store
1562            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1563            .await
1564            .unwrap();
1565
1566        store
1567            .update_output_block_height("abc", 0, "tm_test", 850_000)
1568            .await
1569            .unwrap();
1570
1571        let found = store
1572            .find_output("abc", 0, Some("tm_test"), None, false)
1573            .await
1574            .unwrap()
1575            .unwrap();
1576        assert_eq!(found.block_height, Some(850_000));
1577    }
1578
1579    #[tokio::test]
1580    async fn test_find_outputs_for_transaction() {
1581        let store = MemoryStorage::new();
1582        store
1583            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1584            .await
1585            .unwrap();
1586        store
1587            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
1588            .await
1589            .unwrap();
1590        store
1591            .insert_output(&make_output("other", 0, "tm_test", 3.0))
1592            .await
1593            .unwrap();
1594
1595        let results = store
1596            .find_outputs_for_transaction("abc", false)
1597            .await
1598            .unwrap();
1599        assert_eq!(results.len(), 2);
1600    }
1601
1602    #[tokio::test]
1603    async fn test_find_utxos_for_topic() {
1604        let store = MemoryStorage::new();
1605        store
1606            .insert_output(&make_output("a", 0, "tm_test", 1.0))
1607            .await
1608            .unwrap();
1609        store
1610            .insert_output(&make_output("b", 0, "tm_test", 2.0))
1611            .await
1612            .unwrap();
1613        store
1614            .insert_output(&make_output("c", 0, "tm_test", 3.0))
1615            .await
1616            .unwrap();
1617        store
1618            .insert_output(&make_output("d", 0, "tm_other", 4.0))
1619            .await
1620            .unwrap();
1621
1622        // Mark one as spent
1623        store.mark_utxo_as_spent("b", 0, "tm_test").await.unwrap();
1624
1625        // All unspent for tm_test
1626        let results = store
1627            .find_utxos_for_topic("tm_test", None, None, false)
1628            .await
1629            .unwrap();
1630        assert_eq!(results.len(), 2); // a and c (b is spent)
1631
1632        // With since filter
1633        let results = store
1634            .find_utxos_for_topic("tm_test", Some(2.0), None, false)
1635            .await
1636            .unwrap();
1637        assert_eq!(results.len(), 1); // only c (score 3.0 >= 2.0, a is 1.0)
1638
1639        // With limit
1640        let results = store
1641            .find_utxos_for_topic("tm_test", None, Some(1), false)
1642            .await
1643            .unwrap();
1644        assert_eq!(results.len(), 1);
1645        assert_eq!(results[0].txid, "a"); // lowest score first
1646    }
1647
1648    #[tokio::test]
1649    async fn test_find_utxos_for_topic_sorted_by_score() {
1650        let store = MemoryStorage::new();
1651        store
1652            .insert_output(&make_output("c", 0, "tm_test", 30.0))
1653            .await
1654            .unwrap();
1655        store
1656            .insert_output(&make_output("a", 0, "tm_test", 10.0))
1657            .await
1658            .unwrap();
1659        store
1660            .insert_output(&make_output("b", 0, "tm_test", 20.0))
1661            .await
1662            .unwrap();
1663
1664        let results = store
1665            .find_utxos_for_topic("tm_test", None, None, false)
1666            .await
1667            .unwrap();
1668        assert_eq!(results[0].txid, "a");
1669        assert_eq!(results[1].txid, "b");
1670        assert_eq!(results[2].txid, "c");
1671    }
1672
1673    #[tokio::test]
1674    async fn test_applied_transaction() {
1675        let store = MemoryStorage::new();
1676        let tx = AppliedTransaction {
1677            txid: "abc".to_string(),
1678            topic: "tm_test".to_string(),
1679        };
1680
1681        assert!(!store.does_applied_transaction_exist(&tx).await.unwrap());
1682        store.insert_applied_transaction(&tx).await.unwrap();
1683        assert!(store.does_applied_transaction_exist(&tx).await.unwrap());
1684
1685        // Different topic — should not exist
1686        let tx2 = AppliedTransaction {
1687            txid: "abc".to_string(),
1688            topic: "tm_other".to_string(),
1689        };
1690        assert!(!store.does_applied_transaction_exist(&tx2).await.unwrap());
1691    }
1692
1693    #[tokio::test]
1694    async fn test_sync_state() {
1695        let store = MemoryStorage::new();
1696
1697        assert_eq!(
1698            store
1699                .get_last_interaction("host1", "tm_test")
1700                .await
1701                .unwrap(),
1702            0
1703        );
1704
1705        store
1706            .update_last_interaction("host1", "tm_test", 100)
1707            .await
1708            .unwrap();
1709        assert_eq!(
1710            store
1711                .get_last_interaction("host1", "tm_test")
1712                .await
1713                .unwrap(),
1714            100
1715        );
1716
1717        // Update (upsert)
1718        store
1719            .update_last_interaction("host1", "tm_test", 200)
1720            .await
1721            .unwrap();
1722        assert_eq!(
1723            store
1724                .get_last_interaction("host1", "tm_test")
1725                .await
1726                .unwrap(),
1727            200
1728        );
1729
1730        // Different host — independent
1731        assert_eq!(
1732            store
1733                .get_last_interaction("host2", "tm_test")
1734                .await
1735                .unwrap(),
1736            0
1737        );
1738    }
1739
1740    #[tokio::test]
1741    async fn test_find_outputs_by_outpoints_batch() {
1742        let store = MemoryStorage::new();
1743        store
1744            .insert_output(&make_output("a", 0, "tm_test", 1.0))
1745            .await
1746            .unwrap();
1747        store
1748            .insert_output(&make_output("b", 0, "tm_test", 2.0))
1749            .await
1750            .unwrap();
1751        store
1752            .insert_output(&make_output("c", 0, "tm_test", 3.0))
1753            .await
1754            .unwrap();
1755
1756        let outpoints = vec![
1757            Outpoint::new("a", 0),
1758            Outpoint::new("c", 0),
1759            Outpoint::new("missing", 0),
1760        ];
1761        let results = store
1762            .find_outputs_by_outpoints(&outpoints, false)
1763            .await
1764            .unwrap();
1765        assert_eq!(results.len(), 2); // a and c found, missing skipped
1766    }
1767
1768    #[tokio::test]
1769    async fn test_find_output_without_topic() {
1770        let store = MemoryStorage::new();
1771        store
1772            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1773            .await
1774            .unwrap();
1775
1776        // Find without specifying topic
1777        let found = store
1778            .find_output("abc", 0, None, None, false)
1779            .await
1780            .unwrap();
1781        assert!(found.is_some());
1782        assert_eq!(found.unwrap().topic, "tm_test");
1783    }
1784}