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    // Expired GASP roots (engine 0.1.8)
392    // ========================================================================
393
394    /// Remember that the GASP walk refused `outpoint` of `topic` because its
395    /// own advert had expired at `expiry` (unix seconds, read from the
396    /// output's bytes bound to its txid, never a peer's word). The backend
397    /// keeps the row until `max(expiry, now) +`
398    /// [`crate::gasp::EXPIRED_OUTPOINT_MEMO_SECS`] on its own clock and at
399    /// most [`crate::gasp::EXPIRED_OUTPOINT_MEMO_MAX_ROWS`] rows a topic, so
400    /// a later sync skips it with no node fetch.
401    ///
402    /// Defaulted, unlike the deferred-graph methods: a backend or wrapper
403    /// that does not forward these is fail-safe, the walk fetches and
404    /// refuses the dead again each run as it did before 0.1.8. A wrapper
405    /// forwards both all the same.
406    async fn remember_expired_outpoint(
407        &self,
408        _topic: &str,
409        _outpoint: &str,
410        _expiry: u64,
411    ) -> Result<(), StorageError> {
412        Ok(())
413    }
414
415    /// Of `outpoints`, those of `topic` remembered as expired and not yet
416    /// swept (see [`Self::remember_expired_outpoint`]).
417    async fn find_expired_outpoints(
418        &self,
419        _topic: &str,
420        _outpoints: &[String],
421    ) -> Result<Vec<String>, StorageError> {
422        Ok(Vec::new())
423    }
424}
425
426/// A (host, topic)'s yieldless streak (bsv-low #555, the delta-2 fold's
427/// D2-M1) — the input to [`crate::gasp::yieldless_sync_failed`].
428#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
429pub struct PeerYieldStreak {
430    /// Consecutive yieldless syncs (0 after a yield).
431    pub yieldless_syncs: u64,
432    /// Seconds since the streak's first yieldless sync. `None` = no streak,
433    /// or a backend with no clock: never past the time bound.
434    pub secs_since_first: Option<u64>,
435}
436
437/// Durable per-(host, topic) GASP sync health (bsv-low#302) — the input to
438/// the pure quarantine rule [`crate::gasp::peer_sync_quarantined`].
439#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
440pub struct PeerSyncHealth {
441    /// Consecutive sync attempts that ALL failed (timeout counts as a
442    /// failure). Reset to 0 by any successful sync.
443    pub consecutive_failures: u64,
444    /// Seconds since the last recorded sync ATTEMPT (success or failure).
445    /// `None` = never attempted / no health tracked — never quarantined.
446    pub secs_since_last_attempt: Option<u64>,
447}
448
449// ============================================================================
450// Rc delegation
451// ============================================================================
452
453/// Delegating impl so an `Rc<T: Storage>` is itself a `Storage` — lets a
454/// caller keep a shared handle to the concrete backend (e.g. a test advancing
455/// [`memory::MemoryStorage::advance_clock`]) while the engine owns a
456/// `Box<dyn Storage>` over the same instance. Pure delegation, no semantics.
457#[async_trait(?Send)]
458impl<T: Storage + ?Sized> Storage for std::rc::Rc<T> {
459    async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
460        (**self).insert_output(output).await
461    }
462    async fn delete_output(
463        &self,
464        txid: &str,
465        output_index: u32,
466        topic: &str,
467    ) -> Result<(), StorageError> {
468        (**self).delete_output(txid, output_index, topic).await
469    }
470    async fn mark_utxo_as_spent(
471        &self,
472        txid: &str,
473        output_index: u32,
474        topic: &str,
475    ) -> Result<(), StorageError> {
476        (**self).mark_utxo_as_spent(txid, output_index, topic).await
477    }
478    async fn update_consumed_by(
479        &self,
480        txid: &str,
481        output_index: u32,
482        topic: &str,
483        consumed_by: &[Outpoint],
484    ) -> Result<(), StorageError> {
485        (**self)
486            .update_consumed_by(txid, output_index, topic, consumed_by)
487            .await
488    }
489    async fn update_transaction_beef(&self, txid: &str, beef: &[u8]) -> Result<(), StorageError> {
490        (**self).update_transaction_beef(txid, beef).await
491    }
492    async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
493        (**self).mark_transaction_proven(txid).await
494    }
495    async fn mark_transaction_proven_at(
496        &self,
497        txid: &str,
498        height: Option<u64>,
499    ) -> Result<(), StorageError> {
500        // review LOW-4: forward the anchored latch too — without this the
501        // Rc blanket hits the trait default (drops the height, re-introducing
502        // MED-2) for any caller routing through an `Rc<dyn Storage>`.
503        (**self).mark_transaction_proven_at(txid, height).await
504    }
505    async fn update_output_block_height(
506        &self,
507        txid: &str,
508        output_index: u32,
509        topic: &str,
510        block_height: u32,
511    ) -> Result<(), StorageError> {
512        (**self)
513            .update_output_block_height(txid, output_index, topic, block_height)
514            .await
515    }
516    async fn insert_applied_transaction(
517        &self,
518        tx: &AppliedTransaction,
519    ) -> Result<(), StorageError> {
520        (**self).insert_applied_transaction(tx).await
521    }
522    async fn does_applied_transaction_exist(
523        &self,
524        tx: &AppliedTransaction,
525    ) -> Result<bool, StorageError> {
526        (**self).does_applied_transaction_exist(tx).await
527    }
528    async fn delete_applied_transaction(
529        &self,
530        tx: &AppliedTransaction,
531    ) -> Result<(), StorageError> {
532        (**self).delete_applied_transaction(tx).await
533    }
534    async fn find_output(
535        &self,
536        txid: &str,
537        output_index: u32,
538        topic: Option<&str>,
539        spent: Option<bool>,
540        include_beef: bool,
541    ) -> Result<Option<Output>, StorageError> {
542        (**self)
543            .find_output(txid, output_index, topic, spent, include_beef)
544            .await
545    }
546    async fn find_served_outputs(
547        &self,
548        outpoints: &[Outpoint],
549    ) -> Result<Vec<Output>, StorageError> {
550        (**self).find_served_outputs(outpoints).await
551    }
552
553    async fn find_outputs_for_transaction(
554        &self,
555        txid: &str,
556        include_beef: bool,
557    ) -> Result<Vec<Output>, StorageError> {
558        (**self)
559            .find_outputs_for_transaction(txid, include_beef)
560            .await
561    }
562    async fn find_utxos_for_topic(
563        &self,
564        topic: &str,
565        since: Option<f64>,
566        limit: Option<u64>,
567        include_beef: bool,
568    ) -> Result<Vec<Output>, StorageError> {
569        (**self)
570            .find_utxos_for_topic(topic, since, limit, include_beef)
571            .await
572    }
573    async fn find_transactions_for_proof_check(
574        &self,
575        limit: u64,
576        min_age_secs: u64,
577    ) -> Result<Vec<TransactionBeef>, StorageError> {
578        (**self)
579            .find_transactions_for_proof_check(limit, min_age_secs)
580            .await
581    }
582    async fn update_last_interaction(
583        &self,
584        host: &str,
585        topic: &str,
586        since: u64,
587    ) -> Result<(), StorageError> {
588        (**self).update_last_interaction(host, topic, since).await
589    }
590    async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
591        (**self).get_last_interaction(host, topic).await
592    }
593    async fn record_peer_sync_outcome(
594        &self,
595        host: &str,
596        topic: &str,
597        success: bool,
598    ) -> Result<(), StorageError> {
599        (**self)
600            .record_peer_sync_outcome(host, topic, success)
601            .await
602    }
603    async fn get_peer_sync_health(
604        &self,
605        host: &str,
606        topic: &str,
607    ) -> Result<PeerSyncHealth, StorageError> {
608        (**self).get_peer_sync_health(host, topic).await
609    }
610    async fn record_peer_sync_yield(
611        &self,
612        host: &str,
613        topic: &str,
614        yielded: bool,
615    ) -> Result<PeerYieldStreak, StorageError> {
616        (**self).record_peer_sync_yield(host, topic, yielded).await
617    }
618    async fn put_deferred_graph(
619        &self,
620        record: &crate::gasp::DeferredGraph,
621    ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
622        (**self).put_deferred_graph(record).await
623    }
624    async fn find_deferred_graphs(
625        &self,
626        host: &str,
627        topic: &str,
628    ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
629        (**self).find_deferred_graphs(host, topic).await
630    }
631    async fn get_deferred_graph(
632        &self,
633        host: &str,
634        topic: &str,
635        outpoint: &str,
636    ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
637        (**self).get_deferred_graph(host, topic, outpoint).await
638    }
639    async fn delete_deferred_graph(
640        &self,
641        host: &str,
642        topic: &str,
643        outpoint: &str,
644    ) -> Result<(), StorageError> {
645        (**self).delete_deferred_graph(host, topic, outpoint).await
646    }
647    async fn remember_expired_outpoint(
648        &self,
649        topic: &str,
650        outpoint: &str,
651        expiry: u64,
652    ) -> Result<(), StorageError> {
653        (**self)
654            .remember_expired_outpoint(topic, outpoint, expiry)
655            .await
656    }
657    async fn find_expired_outpoints(
658        &self,
659        topic: &str,
660        outpoints: &[String],
661    ) -> Result<Vec<String>, StorageError> {
662        (**self).find_expired_outpoints(topic, outpoints).await
663    }
664}
665
666// ============================================================================
667// Error type
668// ============================================================================
669
670/// Storage operation errors.
671#[derive(Debug, thiserror::Error)]
672pub enum StorageError {
673    #[error("not found: {0}")]
674    NotFound(String),
675
676    #[error("duplicate: {0}")]
677    Duplicate(String),
678
679    #[error("database error: {0}")]
680    Database(String),
681
682    #[error("serialization error: {0}")]
683    Serialization(String),
684
685    #[error("{0}")]
686    Other(String),
687}
688
689// ============================================================================
690// In-memory storage (for tests and local dev)
691// ============================================================================
692
693#[cfg(any(test, feature = "memory-storage"))]
694pub mod memory {
695    use super::*;
696    use std::collections::HashMap;
697    use std::sync::Mutex;
698
699    /// In-memory Storage implementation for testing and local development.
700    ///
701    /// NOT suitable for production — no persistence, no concurrency beyond Mutex.
702    #[derive(Debug, Default)]
703    pub struct MemoryStorage {
704        /// outputs keyed by (txid, outputIndex, topic)
705        outputs: Mutex<HashMap<(String, u32, String), Output>>,
706        /// BEEF data keyed by txid
707        transactions: Mutex<HashMap<String, Vec<u8>>>,
708        /// txids whose `has_proof` flag is set. Models the real D1
709        /// `transactions.has_proof` column (overlay migration 0010), which is
710        /// the source of truth for `find_transactions_for_proof_check` — NOT a
711        /// live re-parse of the BEEF. A txid is absent here (flag = 0) until
712        /// something explicitly proves it, so a BEEF that already carries a
713        /// proof but is not in this set is still returned as a candidate — the
714        /// exact window-clog the cron must clear via `mark_transaction_proven`.
715        proven: Mutex<std::collections::HashSet<String>>,
716        /// applied transactions keyed by (txid, topic)
717        applied: Mutex<HashMap<(String, String), bool>>,
718        /// GASP sync state keyed by (host, topic)
719        sync_state: Mutex<HashMap<(String, String), u64>>,
720        /// Deterministic logical clock (seconds) for the push-primary backstop
721        /// age gate — models the D1 backend's `unixepoch()`. Tests advance it
722        /// with [`MemoryStorage::advance_clock`]; no wall clock is ever read
723        /// (wasm-safe, deterministic).
724        clock_secs: Mutex<u64>,
725        /// First-store stamp (clock seconds) per txid — models the D1
726        /// `transactions.created_at` column. A txid ABSENT here has UNKNOWN
727        /// age and is treated as OLD (eligible) — the fail-safe direction.
728        created_at: Mutex<HashMap<String, u64>>,
729        /// GASP peer health keyed by (host, topic) —
730        /// `(consecutive_failures, last_attempt_at_clock_secs)`. Models the
731        /// D1 `gasp_peer_health` table (bsv-low#302); the logical clock
732        /// above stands in for `unixepoch()`.
733        peer_health: Mutex<HashMap<(String, String), (u64, u64)>>,
734        /// review LOW-4: records the height an `_at` latch carried, so a pin
735        /// can prove the `Rc` blanket forwards `mark_transaction_proven_at`
736        /// (the trait default would drop the height).
737        proven_at: Mutex<HashMap<String, Option<u64>>>,
738        /// Deferred GASP graphs (bsv-low #555) keyed by (host, topic,
739        /// outpoint): models the D1 `gasp_deferred_graphs` table.
740        deferred_graphs: Mutex<HashMap<(String, String, String), crate::gasp::DeferredGraph>>,
741        /// How many times a deferred graph was WRITTEN (a pin counts the
742        /// storage writes of a deferral).
743        deferred_graph_writes: Mutex<u64>,
744        /// How many single records were READ (bsv-low #555, the lens
745        /// fold's L3).
746        deferred_graph_reads: Mutex<u64>,
747        /// Test knob: the key read of the deferred graphs faults.
748        deferred_graph_keys_fault: Mutex<bool>,
749        /// Test knob: a NEW record past this many held is refused
750        /// `AtCeiling` (the worker's global ceiling).
751        deferred_graph_ceiling: Mutex<Option<usize>>,
752        /// Test knob: a save that would take the bytes of every record held
753        /// past this is refused `AtCeiling`, a held key's too (the worker's
754        /// byte bounds, bsv-low #585).
755        deferred_graph_byte_ceiling: Mutex<Option<usize>>,
756        /// Test knob: every save of a deferred graph faults.
757        deferred_graph_put_fault: Mutex<bool>,
758        /// Expired GASP roots (engine 0.1.8) keyed by (topic, outpoint):
759        /// `(keep_until, written_at)` on the logical clock. Models the D1
760        /// `gasp_expired_outpoints` table.
761        expired_outpoints: Mutex<HashMap<(String, String), (u64, u64)>>,
762        /// How many saves of a deferred graph were ASKED (written, refused
763        /// or faulted).
764        deferred_graph_put_attempts: Mutex<u64>,
765        /// The yieldless streak keyed by (host, topic): `(count,
766        /// first_at, last_at)` on the logical clock. Models the D1
767        /// `gasp_peer_health` columns `yieldless_syncs`, `first_yieldless_at`
768        /// and `last_yieldless_at` (bsv-low #555, the delta fold's D-M2 and
769        /// the delta-2 fold's D2-M1).
770        peer_yieldless: Mutex<HashMap<(String, String), YieldStreakRow>>,
771    }
772
773    /// `(count, first_at, last_at)` of a yieldless streak, on the logical
774    /// clock.
775    type YieldStreakRow = (u64, u64, u64);
776
777    impl MemoryStorage {
778        pub fn new() -> Self {
779            Self::default()
780        }
781
782        /// The anchor height a `mark_transaction_proven_at` recorded for
783        /// `txid` (review LOW-4): `None` = never called `_at`; `Some(h)` =
784        /// called with height `h`.
785        pub fn proven_at_height(&self, txid: &str) -> Option<Option<u64>> {
786            self.proven_at.lock().unwrap().get(txid).copied()
787        }
788
789        /// Advance the deterministic logical clock by `secs` (test hook for
790        /// the push-primary backstop age gate — see
791        /// [`Storage::find_transactions_for_proof_check`]).
792        /// The expired roots remembered for `topic` and not yet swept.
793        pub fn expired_outpoints(&self, topic: &str) -> Vec<String> {
794            let now = *self.clock_secs.lock().unwrap();
795            let mut held: Vec<String> = self
796                .expired_outpoints
797                .lock()
798                .unwrap()
799                .iter()
800                .filter(|((t, _), (keep, _))| t == topic && *keep > now)
801                .map(|((_, o), _)| o.clone())
802                .collect();
803            held.sort();
804            held
805        }
806
807        pub fn advance_clock(&self, secs: u64) {
808            *self.clock_secs.lock().unwrap() += secs;
809        }
810
811        /// Stamp `txid`'s first-store time at the current clock if absent
812        /// (models `COALESCE(existing, unixepoch())` in the D1 backend).
813        fn stamp_created(&self, txid: &str) {
814            let now = *self.clock_secs.lock().unwrap();
815            self.created_at
816                .lock()
817                .unwrap()
818                .entry(txid.to_string())
819                .or_insert(now);
820        }
821
822        /// Whether `txid` clears the backstop age gate: unknown age is OLD
823        /// (eligible, fail-safe); otherwise `clock - created >= min_age_secs`.
824        fn age_gate_open(&self, txid: &str, min_age_secs: u64) -> bool {
825            if min_age_secs == 0 {
826                return true;
827            }
828            let now = *self.clock_secs.lock().unwrap();
829            match self.created_at.lock().unwrap().get(txid) {
830                None => true, // unknown age → treated old → eligible
831                Some(created) => now.saturating_sub(*created) >= min_age_secs,
832            }
833        }
834
835        /// Every deferred GASP graph held, any peer or topic (bsv-low #555).
836        pub fn deferred_graphs(&self) -> Vec<crate::gasp::DeferredGraph> {
837            let mut all: Vec<_> = self
838                .deferred_graphs
839                .lock()
840                .unwrap()
841                .values()
842                .cloned()
843                .collect();
844            all.sort_by(|a, b| (a.score, &a.outpoint).cmp(&(b.score, &b.outpoint)));
845            all
846        }
847
848        /// How many deferred-graph writes were made (bsv-low #555).
849        pub fn deferred_graph_writes(&self) -> u64 {
850            *self.deferred_graph_writes.lock().unwrap()
851        }
852
853        /// How many single records were read (bsv-low #555).
854        pub fn deferred_graph_reads(&self) -> u64 {
855            *self.deferred_graph_reads.lock().unwrap()
856        }
857
858        /// Make the key read of the deferred graphs fault, or not (a test
859        /// knob, bsv-low #555).
860        pub fn set_deferred_graph_keys_fault(&self, fault: bool) {
861            *self.deferred_graph_keys_fault.lock().unwrap() = fault;
862        }
863
864        /// Refuse a NEW record `AtCeiling` once this many are held, as the
865        /// worker's global ceiling does (a test knob, bsv-low #555).
866        pub fn set_deferred_graph_ceiling(&self, ceiling: Option<usize>) {
867            *self.deferred_graph_ceiling.lock().unwrap() = ceiling;
868        }
869
870        /// Refuse `AtCeiling` a save, new or a replacement, that would take
871        /// the bytes of every record held past this, as the worker's byte
872        /// bounds do (a test knob, bsv-low #585). The held record stays.
873        pub fn set_deferred_graph_byte_ceiling(&self, ceiling: Option<usize>) {
874            *self.deferred_graph_byte_ceiling.lock().unwrap() = ceiling;
875        }
876
877        /// Make every save of a deferred graph fault, or not (a test knob,
878        /// bsv-low #555's delta fold, D-L2).
879        pub fn set_deferred_graph_put_fault(&self, fault: bool) {
880            *self.deferred_graph_put_fault.lock().unwrap() = fault;
881        }
882
883        /// How many saves of a deferred graph were asked, whatever their
884        /// answer (bsv-low #555's delta fold, D-L1).
885        pub fn deferred_graph_put_attempts(&self) -> u64 {
886            *self.deferred_graph_put_attempts.lock().unwrap()
887        }
888
889        /// The consecutive yieldless syncs held for (host, topic)
890        /// (bsv-low #555's delta fold, D-M2). `host` may be a peer URL: it
891        /// is read under its [`crate::gasp::peer_origin`], the key the
892        /// engine writes (the delta-2 fold's D2-M2).
893        pub fn peer_yieldless_syncs(&self, host: &str, topic: &str) -> u64 {
894            self.peer_yieldless
895                .lock()
896                .unwrap()
897                .get(&(crate::gasp::peer_origin(host), topic.to_string()))
898                .map_or(0, |(count, _, _)| *count)
899        }
900
901        /// Count total outputs (for testing assertions).
902        pub fn output_count(&self) -> usize {
903            self.outputs.lock().unwrap().len()
904        }
905
906        /// Count total transactions (for testing assertions).
907        pub fn transaction_count(&self) -> usize {
908            self.transactions.lock().unwrap().len()
909        }
910    }
911
912    #[async_trait(?Send)]
913    impl Storage for MemoryStorage {
914        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
915            let key = (
916                output.txid.clone(),
917                output.output_index,
918                output.topic.clone(),
919            );
920            let mut outputs = self.outputs.lock().unwrap();
921
922            // No-op if already exists (match KnexStorage behavior)
923            if outputs.contains_key(&key) {
924                // Still upsert BEEF if provided
925                if let Some(ref beef) = output.beef {
926                    self.transactions
927                        .lock()
928                        .unwrap()
929                        .entry(output.txid.clone())
930                        .or_insert_with(|| beef.clone());
931                }
932                return Ok(());
933            }
934
935            // Store output without BEEF (BEEF goes in transactions table)
936            let mut stored = output.clone();
937            stored.beef = None;
938            outputs.insert(key, stored);
939
940            // Upsert BEEF into transactions table
941            if let Some(ref beef) = output.beef {
942                self.transactions
943                    .lock()
944                    .unwrap()
945                    .entry(output.txid.clone())
946                    .or_insert_with(|| beef.clone());
947                self.stamp_created(&output.txid);
948            }
949
950            Ok(())
951        }
952
953        async fn delete_output(
954            &self,
955            txid: &str,
956            output_index: u32,
957            topic: &str,
958        ) -> Result<(), StorageError> {
959            let key = (txid.to_string(), output_index, topic.to_string());
960            let mut outputs = self.outputs.lock().unwrap();
961            outputs.remove(&key);
962
963            // If no more outputs reference this txid, remove the BEEF
964            let has_remaining = outputs.keys().any(|(t, _, _)| t == txid);
965            if !has_remaining {
966                self.transactions.lock().unwrap().remove(txid);
967                self.proven.lock().unwrap().remove(txid);
968            }
969
970            Ok(())
971        }
972
973        async fn mark_utxo_as_spent(
974            &self,
975            txid: &str,
976            output_index: u32,
977            topic: &str,
978        ) -> Result<(), StorageError> {
979            let key = (txid.to_string(), output_index, topic.to_string());
980            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
981                output.spent = true;
982            }
983            Ok(())
984        }
985
986        async fn update_consumed_by(
987            &self,
988            txid: &str,
989            output_index: u32,
990            topic: &str,
991            consumed_by: &[Outpoint],
992        ) -> Result<(), StorageError> {
993            let key = (txid.to_string(), output_index, topic.to_string());
994            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
995                output.consumed_by = consumed_by.to_vec();
996            }
997            Ok(())
998        }
999
1000        async fn update_transaction_beef(
1001            &self,
1002            txid: &str,
1003            beef: &[u8],
1004        ) -> Result<(), StorageError> {
1005            // Model the D1 backend: keep the proof flag accurate on every BEEF
1006            // write by inspecting whether the new BEEF proves the target tx.
1007            // (The proof-completion stitch calls back here with a proven BEEF,
1008            // which is how a row legitimately flips proofless → proven.)
1009            // A streaming fold: one element in hand, no `Beef` built.
1010            let has_proof = beef_limits::has_proof(beef, txid);
1011            if has_proof {
1012                self.proven.lock().unwrap().insert(txid.to_string());
1013            }
1014            self.transactions
1015                .lock()
1016                .unwrap()
1017                .insert(txid.to_string(), beef.to_vec());
1018            // Preserve-or-stamp: a rewrite keeps the ORIGINAL first-store
1019            // time (models the D1 COALESCE), so the backstop age stays real.
1020            self.stamp_created(txid);
1021            Ok(())
1022        }
1023
1024        async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
1025            // Lightweight flag-flip (no BEEF rewrite), idempotent. Mirrors the
1026            // D1 `UPDATE transactions SET has_proof = 1 WHERE txid = ?`.
1027            self.proven.lock().unwrap().insert(txid.to_string());
1028            Ok(())
1029        }
1030
1031        async fn mark_transaction_proven_at(
1032            &self,
1033            txid: &str,
1034            height: Option<u64>,
1035        ) -> Result<(), StorageError> {
1036            // Models the D1 `MARK_TX_PROVEN_AT_SQL`: flip has_proof AND record
1037            // the anchor (review MED-2/LOW-4). Recorded so the Rc-forward pin
1038            // can observe the height reaching the inner store.
1039            self.proven.lock().unwrap().insert(txid.to_string());
1040            self.proven_at
1041                .lock()
1042                .unwrap()
1043                .insert(txid.to_string(), height);
1044            Ok(())
1045        }
1046
1047        async fn update_output_block_height(
1048            &self,
1049            txid: &str,
1050            output_index: u32,
1051            topic: &str,
1052            block_height: u32,
1053        ) -> Result<(), StorageError> {
1054            let key = (txid.to_string(), output_index, topic.to_string());
1055            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1056                output.block_height = Some(block_height);
1057            }
1058            Ok(())
1059        }
1060
1061        async fn insert_applied_transaction(
1062            &self,
1063            tx: &AppliedTransaction,
1064        ) -> Result<(), StorageError> {
1065            self.applied
1066                .lock()
1067                .unwrap()
1068                .insert((tx.txid.clone(), tx.topic.clone()), true);
1069            Ok(())
1070        }
1071
1072        async fn does_applied_transaction_exist(
1073            &self,
1074            tx: &AppliedTransaction,
1075        ) -> Result<bool, StorageError> {
1076            Ok(self
1077                .applied
1078                .lock()
1079                .unwrap()
1080                .contains_key(&(tx.txid.clone(), tx.topic.clone())))
1081        }
1082
1083        async fn delete_applied_transaction(
1084            &self,
1085            tx: &AppliedTransaction,
1086        ) -> Result<(), StorageError> {
1087            self.applied
1088                .lock()
1089                .unwrap()
1090                .remove(&(tx.txid.clone(), tx.topic.clone()));
1091            Ok(())
1092        }
1093
1094        async fn find_output(
1095            &self,
1096            txid: &str,
1097            output_index: u32,
1098            topic: Option<&str>,
1099            spent: Option<bool>,
1100            include_beef: bool,
1101        ) -> Result<Option<Output>, StorageError> {
1102            let outputs = self.outputs.lock().unwrap();
1103
1104            // If topic is specified, do a direct lookup
1105            if let Some(topic) = topic {
1106                let key = (txid.to_string(), output_index, topic.to_string());
1107                if let Some(output) = outputs.get(&key) {
1108                    if let Some(s) = spent {
1109                        if output.spent != s {
1110                            return Ok(None);
1111                        }
1112                    }
1113                    let mut result = output.clone();
1114                    if include_beef {
1115                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1116                    }
1117                    return Ok(Some(result));
1118                }
1119                return Ok(None);
1120            }
1121
1122            // No topic — find first matching (txid, outputIndex)
1123            for ((t, oi, _), output) in outputs.iter() {
1124                if t == txid && *oi == output_index {
1125                    if let Some(s) = spent {
1126                        if output.spent != s {
1127                            continue;
1128                        }
1129                    }
1130                    let mut result = output.clone();
1131                    if include_beef {
1132                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1133                    }
1134                    return Ok(Some(result));
1135                }
1136            }
1137            Ok(None)
1138        }
1139
1140        async fn find_served_outputs(
1141            &self,
1142            outpoints: &[Outpoint],
1143        ) -> Result<Vec<Output>, StorageError> {
1144            let outputs = self.find_outputs_by_outpoints(outpoints, true).await?;
1145            let now = i64::try_from(*self.clock_secs.lock().unwrap())
1146                .unwrap_or(i64::MAX)
1147                .saturating_mul(1000);
1148            let proven = self.proven.lock().unwrap();
1149            let stamps = self.created_at.lock().unwrap();
1150            Ok(outputs
1151                .into_iter()
1152                .filter(|output| {
1153                    let seen = stamps
1154                        .get(&output.txid)
1155                        .and_then(|s| i64::try_from(*s).ok())
1156                        .map(|s| s.saturating_mul(1000));
1157                    crate::proof_window::verdict(proven.contains(&output.txid), seen, now)
1158                        == crate::proof_window::Verdict::Served
1159                })
1160                .collect())
1161        }
1162
1163        async fn find_outputs_for_transaction(
1164            &self,
1165            txid: &str,
1166            include_beef: bool,
1167        ) -> Result<Vec<Output>, StorageError> {
1168            let outputs = self.outputs.lock().unwrap();
1169            let beef = if include_beef {
1170                self.transactions.lock().unwrap().get(txid).cloned()
1171            } else {
1172                None
1173            };
1174
1175            let results: Vec<Output> = outputs
1176                .iter()
1177                .filter(|((t, _, _), _)| t == txid)
1178                .map(|(_, output)| {
1179                    let mut o = output.clone();
1180                    if include_beef {
1181                        o.beef.clone_from(&beef);
1182                    }
1183                    o
1184                })
1185                .collect();
1186
1187            Ok(results)
1188        }
1189
1190        async fn find_utxos_for_topic(
1191            &self,
1192            topic: &str,
1193            since: Option<f64>,
1194            limit: Option<u64>,
1195            include_beef: bool,
1196        ) -> Result<Vec<Output>, StorageError> {
1197            let outputs = self.outputs.lock().unwrap();
1198            let transactions = self.transactions.lock().unwrap();
1199
1200            let mut results: Vec<Output> = outputs
1201                .iter()
1202                .filter(|((_, _, t), o)| {
1203                    t == topic && !o.spent && since.is_none_or(|s| o.score.unwrap_or(0.0) >= s)
1204                })
1205                .map(|(_, output)| {
1206                    let mut o = output.clone();
1207                    if include_beef {
1208                        o.beef = transactions.get(&o.txid).cloned();
1209                    }
1210                    o
1211                })
1212                .collect();
1213
1214            // Sort by score ascending (matching KnexStorage behavior)
1215            results.sort_by(|a, b| {
1216                a.score
1217                    .unwrap_or(0.0)
1218                    .partial_cmp(&b.score.unwrap_or(0.0))
1219                    .unwrap_or(std::cmp::Ordering::Equal)
1220            });
1221
1222            if let Some(limit) = limit {
1223                results.truncate(limit as usize);
1224            }
1225
1226            Ok(results)
1227        }
1228
1229        async fn find_transactions_for_proof_check(
1230            &self,
1231            limit: u64,
1232            min_age_secs: u64,
1233        ) -> Result<Vec<TransactionBeef>, StorageError> {
1234            // Models the real D1 `WHERE has_proof = 0 LIMIT {limit}` query
1235            // (overlay migration 0010): the candidate set is driven by the
1236            // `has_proof` FLAG, not a live re-parse of the BEEF. A row whose
1237            // BEEF already carries a proof but whose flag is still 0 (e.g. a
1238            // GASP-synced proof written before the migration, which defaulted
1239            // every existing row to 0) is therefore STILL returned here — it
1240            // only drops out once `mark_transaction_proven` flips the flag.
1241            // This reproduces the window-clog the engine must clear.
1242            //
1243            // The push-primary backstop age gate (#228) excludes rows younger
1244            // than `min_age_secs` (their proof is expected via /arc-ingest);
1245            // unknown-age rows stay eligible (fail-safe — trait doc).
1246            let candidates: Vec<TransactionBeef> = {
1247                let transactions = self.transactions.lock().unwrap();
1248                let proven = self.proven.lock().unwrap();
1249                transactions
1250                    .iter()
1251                    .filter(|(txid, _)| !proven.contains(*txid))
1252                    .map(|(txid, beef)| TransactionBeef {
1253                        txid: txid.clone(),
1254                        beef: beef.clone(),
1255                    })
1256                    .collect()
1257            };
1258            Ok(candidates
1259                .into_iter()
1260                .filter(|c| self.age_gate_open(&c.txid, min_age_secs))
1261                .take(limit as usize)
1262                .collect())
1263        }
1264
1265        async fn update_last_interaction(
1266            &self,
1267            host: &str,
1268            topic: &str,
1269            since: u64,
1270        ) -> Result<(), StorageError> {
1271            self.sync_state
1272                .lock()
1273                .unwrap()
1274                .insert((host.to_string(), topic.to_string()), since);
1275            Ok(())
1276        }
1277
1278        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
1279            Ok(*self
1280                .sync_state
1281                .lock()
1282                .unwrap()
1283                .get(&(host.to_string(), topic.to_string()))
1284                .unwrap_or(&0))
1285        }
1286
1287        async fn record_peer_sync_outcome(
1288            &self,
1289            host: &str,
1290            topic: &str,
1291            success: bool,
1292        ) -> Result<(), StorageError> {
1293            let now = *self.clock_secs.lock().unwrap();
1294            let mut health = self.peer_health.lock().unwrap();
1295            let entry = health
1296                .entry((host.to_string(), topic.to_string()))
1297                .or_insert((0, now));
1298            entry.0 = if success { 0 } else { entry.0 + 1 };
1299            entry.1 = now;
1300            Ok(())
1301        }
1302
1303        async fn get_peer_sync_health(
1304            &self,
1305            host: &str,
1306            topic: &str,
1307        ) -> Result<PeerSyncHealth, StorageError> {
1308            let now = *self.clock_secs.lock().unwrap();
1309            Ok(self
1310                .peer_health
1311                .lock()
1312                .unwrap()
1313                .get(&(host.to_string(), topic.to_string()))
1314                .map(|(fails, attempt_at)| PeerSyncHealth {
1315                    consecutive_failures: *fails,
1316                    secs_since_last_attempt: Some(now.saturating_sub(*attempt_at)),
1317                })
1318                .unwrap_or_default())
1319        }
1320
1321        async fn record_peer_sync_yield(
1322            &self,
1323            host: &str,
1324            topic: &str,
1325            yielded: bool,
1326        ) -> Result<PeerYieldStreak, StorageError> {
1327            let now = *self.clock_secs.lock().unwrap();
1328            let key = (host.to_string(), topic.to_string());
1329            // The delta-3 fold's D3-M1: the decay only for a peer that is
1330            // not failed (the worker's statement reads the same row).
1331            let not_failed = self
1332                .peer_health
1333                .lock()
1334                .unwrap()
1335                .get(&key)
1336                .is_none_or(|(fails, _)| *fails == 0);
1337            let mut held = self.peer_yieldless.lock().unwrap();
1338            if yielded {
1339                held.remove(&key);
1340                return Ok(PeerYieldStreak::default());
1341            }
1342            let streak = held.entry(key).or_insert((0, now, now));
1343            if streak.0 == 0
1344                || (not_failed
1345                    && now.saturating_sub(streak.2) > crate::gasp::PEER_YIELDLESS_DECAY_SECS)
1346            {
1347                *streak = (0, now, now);
1348            }
1349            streak.0 += 1;
1350            streak.2 = now;
1351            Ok(PeerYieldStreak {
1352                yieldless_syncs: streak.0,
1353                secs_since_first: Some(now.saturating_sub(streak.1)),
1354            })
1355        }
1356
1357        async fn put_deferred_graph(
1358            &self,
1359            record: &crate::gasp::DeferredGraph,
1360        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
1361            let key = (
1362                record.peer.clone(),
1363                record.topic.clone(),
1364                record.outpoint.clone(),
1365            );
1366            *self.deferred_graph_put_attempts.lock().unwrap() += 1;
1367            if *self.deferred_graph_put_fault.lock().unwrap() {
1368                return Err(StorageError::Database("deferred graph save faulted".into()));
1369            }
1370            if let Some(ceiling) = *self.deferred_graph_ceiling.lock().unwrap() {
1371                let held = self.deferred_graphs.lock().unwrap();
1372                if !held.contains_key(&key) && held.len() >= ceiling {
1373                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1374                }
1375            }
1376            if let Some(ceiling) = *self.deferred_graph_byte_ceiling.lock().unwrap() {
1377                let held = self.deferred_graphs.lock().unwrap();
1378                let others: usize = held
1379                    .iter()
1380                    .filter(|(k, _)| **k != key)
1381                    .map(|(_, r)| r.byte_size())
1382                    .sum();
1383                if others + record.byte_size() > ceiling {
1384                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1385                }
1386            }
1387            *self.deferred_graph_writes.lock().unwrap() += 1;
1388            self.deferred_graphs.lock().unwrap().insert(
1389                (
1390                    record.peer.clone(),
1391                    record.topic.clone(),
1392                    record.outpoint.clone(),
1393                ),
1394                record.clone(),
1395            );
1396            Ok(crate::gasp::DeferredGraphSave::Saved)
1397        }
1398
1399        async fn find_deferred_graphs(
1400            &self,
1401            host: &str,
1402            topic: &str,
1403        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
1404            if *self.deferred_graph_keys_fault.lock().unwrap() {
1405                return Err(StorageError::Database("deferred graph keys: fault".into()));
1406            }
1407            Ok(self
1408                .deferred_graphs()
1409                .into_iter()
1410                .filter(|r| r.peer == host && r.topic == topic)
1411                .map(|r| crate::gasp::DeferredGraphKey {
1412                    outpoint: r.outpoint,
1413                    score: r.score,
1414                })
1415                .collect())
1416        }
1417
1418        async fn get_deferred_graph(
1419            &self,
1420            host: &str,
1421            topic: &str,
1422            outpoint: &str,
1423        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
1424            *self.deferred_graph_reads.lock().unwrap() += 1;
1425            Ok(self
1426                .deferred_graphs
1427                .lock()
1428                .unwrap()
1429                .get(&(host.to_string(), topic.to_string(), outpoint.to_string()))
1430                .cloned())
1431        }
1432
1433        async fn delete_deferred_graph(
1434            &self,
1435            host: &str,
1436            topic: &str,
1437            outpoint: &str,
1438        ) -> Result<(), StorageError> {
1439            self.deferred_graphs.lock().unwrap().remove(&(
1440                host.to_string(),
1441                topic.to_string(),
1442                outpoint.to_string(),
1443            ));
1444            Ok(())
1445        }
1446
1447        async fn remember_expired_outpoint(
1448            &self,
1449            topic: &str,
1450            outpoint: &str,
1451            expiry: u64,
1452        ) -> Result<(), StorageError> {
1453            let now = *self.clock_secs.lock().unwrap();
1454            let mut held = self.expired_outpoints.lock().unwrap();
1455            held.retain(|_, (keep, _)| *keep > now);
1456            held.insert(
1457                (topic.to_string(), outpoint.to_string()),
1458                (
1459                    expiry.max(now) + crate::gasp::EXPIRED_OUTPOINT_MEMO_SECS,
1460                    now,
1461                ),
1462            );
1463            // The bound: the most recently written rows of the topic stay.
1464            let mut of_topic: Vec<(u64, String)> = held
1465                .iter()
1466                .filter(|((t, _), _)| t == topic)
1467                .map(|((_, o), (_, at))| (*at, o.clone()))
1468                .collect();
1469            let cap = crate::gasp::EXPIRED_OUTPOINT_MEMO_MAX_ROWS;
1470            if of_topic.len() > cap {
1471                of_topic.sort();
1472                for (_, o) in &of_topic[..of_topic.len() - cap] {
1473                    held.remove(&(topic.to_string(), o.clone()));
1474                }
1475            }
1476            Ok(())
1477        }
1478
1479        async fn find_expired_outpoints(
1480            &self,
1481            topic: &str,
1482            outpoints: &[String],
1483        ) -> Result<Vec<String>, StorageError> {
1484            let now = *self.clock_secs.lock().unwrap();
1485            let held = self.expired_outpoints.lock().unwrap();
1486            Ok(outpoints
1487                .iter()
1488                .filter(|o| {
1489                    held.get(&(topic.to_string(), (*o).clone()))
1490                        .is_some_and(|(keep, _)| *keep > now)
1491                })
1492                .cloned()
1493                .collect())
1494        }
1495    }
1496}
1497
1498// ============================================================================
1499// Tests
1500// ============================================================================
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::memory::MemoryStorage;
1505    use super::*;
1506    use crate::types::AppliedTransaction;
1507
1508    /// review LOW-4: the `Rc<T: Storage>` blanket must FORWARD
1509    /// `mark_transaction_proven_at`, not fall through to the trait default
1510    /// (which drops the height, re-introducing MED-2). Call it through an
1511    /// `Rc<dyn Storage>` and assert the height reached the inner store.
1512    #[tokio::test]
1513    async fn the_rc_blanket_forwards_the_anchored_proven_latch() {
1514        let store = std::rc::Rc::new(MemoryStorage::new());
1515        let via_dyn: std::rc::Rc<dyn Storage> = store.clone();
1516        via_dyn
1517            .mark_transaction_proven_at("tx", Some(965_772))
1518            .await
1519            .unwrap();
1520        assert_eq!(
1521            store.proven_at_height("tx"),
1522            Some(Some(965_772)),
1523            "the Rc blanket forwarded the height to the inner store"
1524        );
1525        // a height-less call still forwards (records None, not "never called")
1526        via_dyn
1527            .mark_transaction_proven_at("tx2", None)
1528            .await
1529            .unwrap();
1530        assert_eq!(store.proven_at_height("tx2"), Some(None));
1531        assert_eq!(
1532            store.proven_at_height("never"),
1533            None,
1534            "a txid never latched is absent"
1535        );
1536    }
1537
1538    fn make_output(txid: &str, index: u32, topic: &str, score: f64) -> Output {
1539        Output {
1540            txid: txid.to_string(),
1541            output_index: index,
1542            output_script: vec![0x76, 0xa9],
1543            satoshis: 1000,
1544            topic: topic.to_string(),
1545            spent: false,
1546            outputs_consumed: vec![],
1547            consumed_by: vec![],
1548            beef: Some(vec![0xBE, 0xEF]),
1549            block_height: None,
1550            score: Some(score),
1551        }
1552    }
1553
1554    #[tokio::test]
1555    async fn test_insert_and_find_output() {
1556        let store = MemoryStorage::new();
1557        let output = make_output("abc", 0, "tm_test", 1.0);
1558        store.insert_output(&output).await.unwrap();
1559
1560        // Find without BEEF
1561        let found = store
1562            .find_output("abc", 0, Some("tm_test"), None, false)
1563            .await
1564            .unwrap()
1565            .unwrap();
1566        assert_eq!(found.txid, "abc");
1567        assert_eq!(found.output_index, 0);
1568        assert!(found.beef.is_none());
1569
1570        // Find with BEEF
1571        let found = store
1572            .find_output("abc", 0, Some("tm_test"), None, true)
1573            .await
1574            .unwrap()
1575            .unwrap();
1576        assert_eq!(found.beef.unwrap(), vec![0xBE, 0xEF]);
1577    }
1578
1579    #[tokio::test]
1580    async fn test_insert_duplicate_is_noop() {
1581        let store = MemoryStorage::new();
1582        let output = make_output("abc", 0, "tm_test", 1.0);
1583        store.insert_output(&output).await.unwrap();
1584        store.insert_output(&output).await.unwrap();
1585        assert_eq!(store.output_count(), 1);
1586    }
1587
1588    #[tokio::test]
1589    async fn test_delete_output_cleans_up_beef() {
1590        let store = MemoryStorage::new();
1591        let output = make_output("abc", 0, "tm_test", 1.0);
1592        store.insert_output(&output).await.unwrap();
1593        assert_eq!(store.transaction_count(), 1);
1594
1595        store.delete_output("abc", 0, "tm_test").await.unwrap();
1596        assert_eq!(store.output_count(), 0);
1597        assert_eq!(store.transaction_count(), 0); // BEEF cleaned up
1598    }
1599
1600    #[tokio::test]
1601    async fn test_delete_output_keeps_beef_if_other_outputs_exist() {
1602        let store = MemoryStorage::new();
1603        store
1604            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1605            .await
1606            .unwrap();
1607        store
1608            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
1609            .await
1610            .unwrap();
1611        assert_eq!(store.output_count(), 2);
1612        assert_eq!(store.transaction_count(), 1);
1613
1614        store.delete_output("abc", 0, "tm_test").await.unwrap();
1615        assert_eq!(store.output_count(), 1);
1616        assert_eq!(store.transaction_count(), 1); // BEEF kept for output 1
1617    }
1618
1619    #[tokio::test]
1620    async fn test_mark_utxo_as_spent() {
1621        let store = MemoryStorage::new();
1622        store
1623            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1624            .await
1625            .unwrap();
1626
1627        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
1628
1629        let found = store
1630            .find_output("abc", 0, Some("tm_test"), None, false)
1631            .await
1632            .unwrap()
1633            .unwrap();
1634        assert!(found.spent);
1635    }
1636
1637    #[tokio::test]
1638    async fn test_find_output_with_spent_filter() {
1639        let store = MemoryStorage::new();
1640        store
1641            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1642            .await
1643            .unwrap();
1644
1645        // Not spent — should find with spent=false, not with spent=true
1646        assert!(store
1647            .find_output("abc", 0, Some("tm_test"), Some(false), false)
1648            .await
1649            .unwrap()
1650            .is_some());
1651        assert!(store
1652            .find_output("abc", 0, Some("tm_test"), Some(true), false)
1653            .await
1654            .unwrap()
1655            .is_none());
1656
1657        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
1658
1659        // Now spent — reversed
1660        assert!(store
1661            .find_output("abc", 0, Some("tm_test"), Some(true), false)
1662            .await
1663            .unwrap()
1664            .is_some());
1665        assert!(store
1666            .find_output("abc", 0, Some("tm_test"), Some(false), false)
1667            .await
1668            .unwrap()
1669            .is_none());
1670    }
1671
1672    #[tokio::test]
1673    async fn test_update_consumed_by() {
1674        let store = MemoryStorage::new();
1675        store
1676            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1677            .await
1678            .unwrap();
1679
1680        let consumed = vec![Outpoint::new("def", 0), Outpoint::new("ghi", 1)];
1681        store
1682            .update_consumed_by("abc", 0, "tm_test", &consumed)
1683            .await
1684            .unwrap();
1685
1686        let found = store
1687            .find_output("abc", 0, Some("tm_test"), None, false)
1688            .await
1689            .unwrap()
1690            .unwrap();
1691        assert_eq!(found.consumed_by.len(), 2);
1692        assert_eq!(found.consumed_by[0].txid, "def");
1693        assert_eq!(found.consumed_by[1].output_index, 1);
1694    }
1695
1696    #[tokio::test]
1697    async fn test_update_transaction_beef() {
1698        let store = MemoryStorage::new();
1699        store
1700            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1701            .await
1702            .unwrap();
1703
1704        store
1705            .update_transaction_beef("abc", &[0xDE, 0xAD])
1706            .await
1707            .unwrap();
1708
1709        let found = store
1710            .find_output("abc", 0, Some("tm_test"), None, true)
1711            .await
1712            .unwrap()
1713            .unwrap();
1714        assert_eq!(found.beef.unwrap(), vec![0xDE, 0xAD]);
1715    }
1716
1717    #[tokio::test]
1718    async fn test_update_output_block_height() {
1719        let store = MemoryStorage::new();
1720        store
1721            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1722            .await
1723            .unwrap();
1724
1725        store
1726            .update_output_block_height("abc", 0, "tm_test", 850_000)
1727            .await
1728            .unwrap();
1729
1730        let found = store
1731            .find_output("abc", 0, Some("tm_test"), None, false)
1732            .await
1733            .unwrap()
1734            .unwrap();
1735        assert_eq!(found.block_height, Some(850_000));
1736    }
1737
1738    #[tokio::test]
1739    async fn test_find_outputs_for_transaction() {
1740        let store = MemoryStorage::new();
1741        store
1742            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1743            .await
1744            .unwrap();
1745        store
1746            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
1747            .await
1748            .unwrap();
1749        store
1750            .insert_output(&make_output("other", 0, "tm_test", 3.0))
1751            .await
1752            .unwrap();
1753
1754        let results = store
1755            .find_outputs_for_transaction("abc", false)
1756            .await
1757            .unwrap();
1758        assert_eq!(results.len(), 2);
1759    }
1760
1761    #[tokio::test]
1762    async fn test_find_utxos_for_topic() {
1763        let store = MemoryStorage::new();
1764        store
1765            .insert_output(&make_output("a", 0, "tm_test", 1.0))
1766            .await
1767            .unwrap();
1768        store
1769            .insert_output(&make_output("b", 0, "tm_test", 2.0))
1770            .await
1771            .unwrap();
1772        store
1773            .insert_output(&make_output("c", 0, "tm_test", 3.0))
1774            .await
1775            .unwrap();
1776        store
1777            .insert_output(&make_output("d", 0, "tm_other", 4.0))
1778            .await
1779            .unwrap();
1780
1781        // Mark one as spent
1782        store.mark_utxo_as_spent("b", 0, "tm_test").await.unwrap();
1783
1784        // All unspent for tm_test
1785        let results = store
1786            .find_utxos_for_topic("tm_test", None, None, false)
1787            .await
1788            .unwrap();
1789        assert_eq!(results.len(), 2); // a and c (b is spent)
1790
1791        // With since filter
1792        let results = store
1793            .find_utxos_for_topic("tm_test", Some(2.0), None, false)
1794            .await
1795            .unwrap();
1796        assert_eq!(results.len(), 1); // only c (score 3.0 >= 2.0, a is 1.0)
1797
1798        // With limit
1799        let results = store
1800            .find_utxos_for_topic("tm_test", None, Some(1), false)
1801            .await
1802            .unwrap();
1803        assert_eq!(results.len(), 1);
1804        assert_eq!(results[0].txid, "a"); // lowest score first
1805    }
1806
1807    #[tokio::test]
1808    async fn test_find_utxos_for_topic_sorted_by_score() {
1809        let store = MemoryStorage::new();
1810        store
1811            .insert_output(&make_output("c", 0, "tm_test", 30.0))
1812            .await
1813            .unwrap();
1814        store
1815            .insert_output(&make_output("a", 0, "tm_test", 10.0))
1816            .await
1817            .unwrap();
1818        store
1819            .insert_output(&make_output("b", 0, "tm_test", 20.0))
1820            .await
1821            .unwrap();
1822
1823        let results = store
1824            .find_utxos_for_topic("tm_test", None, None, false)
1825            .await
1826            .unwrap();
1827        assert_eq!(results[0].txid, "a");
1828        assert_eq!(results[1].txid, "b");
1829        assert_eq!(results[2].txid, "c");
1830    }
1831
1832    #[tokio::test]
1833    async fn test_applied_transaction() {
1834        let store = MemoryStorage::new();
1835        let tx = AppliedTransaction {
1836            txid: "abc".to_string(),
1837            topic: "tm_test".to_string(),
1838        };
1839
1840        assert!(!store.does_applied_transaction_exist(&tx).await.unwrap());
1841        store.insert_applied_transaction(&tx).await.unwrap();
1842        assert!(store.does_applied_transaction_exist(&tx).await.unwrap());
1843
1844        // Different topic — should not exist
1845        let tx2 = AppliedTransaction {
1846            txid: "abc".to_string(),
1847            topic: "tm_other".to_string(),
1848        };
1849        assert!(!store.does_applied_transaction_exist(&tx2).await.unwrap());
1850    }
1851
1852    #[tokio::test]
1853    async fn test_sync_state() {
1854        let store = MemoryStorage::new();
1855
1856        assert_eq!(
1857            store
1858                .get_last_interaction("host1", "tm_test")
1859                .await
1860                .unwrap(),
1861            0
1862        );
1863
1864        store
1865            .update_last_interaction("host1", "tm_test", 100)
1866            .await
1867            .unwrap();
1868        assert_eq!(
1869            store
1870                .get_last_interaction("host1", "tm_test")
1871                .await
1872                .unwrap(),
1873            100
1874        );
1875
1876        // Update (upsert)
1877        store
1878            .update_last_interaction("host1", "tm_test", 200)
1879            .await
1880            .unwrap();
1881        assert_eq!(
1882            store
1883                .get_last_interaction("host1", "tm_test")
1884                .await
1885                .unwrap(),
1886            200
1887        );
1888
1889        // Different host — independent
1890        assert_eq!(
1891            store
1892                .get_last_interaction("host2", "tm_test")
1893                .await
1894                .unwrap(),
1895            0
1896        );
1897    }
1898
1899    #[tokio::test]
1900    async fn test_find_outputs_by_outpoints_batch() {
1901        let store = MemoryStorage::new();
1902        store
1903            .insert_output(&make_output("a", 0, "tm_test", 1.0))
1904            .await
1905            .unwrap();
1906        store
1907            .insert_output(&make_output("b", 0, "tm_test", 2.0))
1908            .await
1909            .unwrap();
1910        store
1911            .insert_output(&make_output("c", 0, "tm_test", 3.0))
1912            .await
1913            .unwrap();
1914
1915        let outpoints = vec![
1916            Outpoint::new("a", 0),
1917            Outpoint::new("c", 0),
1918            Outpoint::new("missing", 0),
1919        ];
1920        let results = store
1921            .find_outputs_by_outpoints(&outpoints, false)
1922            .await
1923            .unwrap();
1924        assert_eq!(results.len(), 2); // a and c found, missing skipped
1925    }
1926
1927    #[tokio::test]
1928    async fn test_find_output_without_topic() {
1929        let store = MemoryStorage::new();
1930        store
1931            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1932            .await
1933            .unwrap();
1934
1935        // Find without specifying topic
1936        let found = store
1937            .find_output("abc", 0, None, None, false)
1938            .await
1939            .unwrap();
1940        assert!(found.is_some());
1941        assert_eq!(found.unwrap().topic, "tm_test");
1942    }
1943}