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