Skip to main content

urma_node/
archive.rs

1//! Transactional archive owned by one process. Readers use the owning engine or
2//! its API; consumers never open the service database for independent writes.
3
4use crate::{Error, model::*};
5use bitcoin::{BlockHash, block::Header, consensus::serialize};
6use rusqlite::{Connection, OptionalExtension, params};
7use std::{
8    fs::{File, OpenOptions},
9    path::Path,
10    time::Duration,
11};
12
13mod cache;
14mod names;
15mod objects;
16mod peers;
17mod projections;
18pub(crate) use objects::ObjectMember;
19
20pub struct Archive {
21    // Keep the OS lock until after the connection has closed.
22    db: Connection,
23    _lock: File,
24    config: ArchiveConfig,
25    // One restored registry at a time. SQL remains authoritative; errors drop
26    // the working copy so staged in-memory changes cannot outlive rollback.
27    names_cache: Option<urma_names::engine::Registry>,
28}
29
30impl Archive {
31    /// Opens only local state. The directory is a local-filesystem archive, not
32    /// a network share; SQLite WAL relies on local shared-memory coordination.
33    pub fn open(directory: &Path, config: ArchiveConfig) -> Result<Self, Error> {
34        if config.application_start.height > i64::MAX as u64 {
35            return Err(Error::Invalid(
36                "application start exceeds storage range".into(),
37            ));
38        }
39        let mut builder = std::fs::DirBuilder::new();
40        builder.recursive(true);
41        #[cfg(unix)]
42        {
43            use std::os::unix::fs::DirBuilderExt;
44            builder.mode(0o700);
45        }
46        builder.create(directory)?;
47        if std::fs::symlink_metadata(directory)?
48            .file_type()
49            .is_symlink()
50        {
51            return Err(Error::Invalid(
52                "archive directory must not be a symlink".into(),
53            ));
54        }
55        let lock_path = directory.join("owner.lock");
56        for path in [&lock_path, &directory.join("archive.sqlite3")] {
57            match std::fs::symlink_metadata(path) {
58                Ok(meta) if !meta.is_file() => {
59                    return Err(Error::Invalid("archive files must be regular files".into()));
60                }
61                Ok(_) => (),
62                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
63                Err(e) => return Err(e.into()),
64            }
65        }
66        let lock = OpenOptions::new()
67            .create(true)
68            .truncate(false)
69            .read(true)
70            .write(true)
71            .open(lock_path)?;
72        lock.try_lock().map_err(|e| Error::Busy(e.to_string()))?;
73        let mut db = Connection::open(directory.join("archive.sqlite3"))?;
74        db.busy_timeout(Duration::from_secs(5))?;
75        db.execute_batch(
76            "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;",
77        )?;
78        let version: u32 = db.pragma_query_value(None, "user_version", |r| r.get(0))?;
79        let encoded = serde_json::to_string(&config)?;
80        if version == 0 {
81            let tx = db.transaction()?;
82            tx.execute_batch(include_str!("schema.sql"))?;
83            tx.execute("INSERT INTO config VALUES(1,?1)", [&encoded])?;
84            tx.commit()?;
85        } else if !(1..=5).contains(&version) {
86            return Err(Error::Invalid(format!(
87                "unsupported archive schema {version}"
88            )));
89        }
90        let saved: String =
91            db.query_row("SELECT value FROM config WHERE singleton=1", [], |r| {
92                r.get(0)
93            })?;
94        if saved != encoded {
95            return Err(Error::Invalid(
96                "archive configuration differs; explicit migration required".into(),
97            ));
98        }
99        // Configuration identity is checked before any existing archive migration.
100        // Schema 1 remains intact if the additive migration fails.
101        migrate(&mut db, version)?;
102        Ok(Self {
103            db,
104            _lock: lock,
105            config,
106            names_cache: None,
107        })
108    }
109
110    pub fn config(&self) -> &ArchiveConfig {
111        &self.config
112    }
113
114    pub fn status(&self) -> Result<ArchiveStatus, Error> {
115        let generation = generation(&self.db)?;
116        let selected_tip = self
117            .db
118            .query_row(
119                "SELECT height,hash FROM selected ORDER BY height DESC LIMIT 1",
120                [],
121                point,
122            )
123            .optional()?;
124        let indexed_through = self.indexed_through()?;
125        let scanned_blocks = self.db.query_row(
126            "SELECT count(*) FROM scans c JOIN selected s ON s.hash=c.block_hash WHERE c.rules=?1",
127            [SCAN_RULES_VERSION],
128            |r| unsigned(r, 0),
129        )?;
130        let unresolved_dependencies = self.db.query_row(
131            "SELECT count(*) FROM dependencies d JOIN selected s ON s.hash=d.block_hash",
132            [],
133            |r| unsigned(r, 0),
134        )?;
135        let stored_transactions =
136            self.db
137                .query_row("SELECT count(*) FROM transactions", [], |r| unsigned(r, 0))?;
138        let mut status = ArchiveStatus::default();
139        status.api_version = API_VERSION;
140        status.chain_genesis = self
141            .config
142            .chain
143            .genesis()
144            .map_err(|e| Error::Invalid(e.to_string()))?
145            .0;
146        status.application_start = self.config.application_start;
147        status.retention = self.config.retention;
148        status.generation = generation;
149        status.selected_tip = selected_tip;
150        status.indexed_through = indexed_through;
151        status.scanned_blocks = scanned_blocks;
152        status.unresolved_dependencies = unresolved_dependencies;
153        status.stored_transactions = stored_transactions;
154        status.scan_rules_version = SCAN_RULES_VERSION;
155        status.peer_synced_through =
156            peers::synced_through(&self.db, self.config.application_start.height)?;
157        status.peer_omissions = peers::omission_count(&self.db)?;
158        status.unaudited_records = peers::unaudited_count(&self.db)?;
159        Ok(status)
160    }
161
162    /// Contiguous fully scanned, dependency-complete coverage from application start.
163    fn indexed_through(&self) -> Result<Option<ChainPoint>, Error> {
164        let start = self.config.application_start.height;
165        let mut next = start;
166        let mut indexed_through = None;
167        let mut rows = self.db.prepare(
168            "SELECT s.height,s.hash FROM selected s JOIN scans c ON c.block_hash=s.hash
169             WHERE s.height>=?1 AND c.rules=?2 AND c.unresolved=0 ORDER BY s.height",
170        )?;
171        for row in rows.query_map(params![sql_integer(start)?, SCAN_RULES_VERSION], point)? {
172            let current = row?;
173            if current.height != next {
174                break;
175            }
176            next += 1;
177            indexed_through = Some(current);
178        }
179        Ok(indexed_through)
180    }
181
182    pub fn ticket(&self, height: u64) -> Result<ScanTicket, Error> {
183        let point = self
184            .db
185            .query_row(
186                "SELECT height,hash FROM selected WHERE height=?1",
187                [sql_integer(height)?],
188                point,
189            )
190            .optional()?
191            .ok_or_else(|| Error::Invalid("block is not in the selected view".into()))?;
192        Ok(ScanTicket {
193            generation: generation(&self.db)?,
194            point,
195        })
196    }
197
198    pub(crate) fn header_at(&self, height: u64) -> Result<Option<Header>, Error> {
199        let raw: Option<Vec<u8>> = self
200            .db
201            .query_row(
202                "SELECT h.bytes FROM selected s JOIN headers h ON h.hash=s.hash WHERE s.height=?1",
203                [sql_integer(height)?],
204                |r| r.get(0),
205            )
206            .optional()?;
207        raw.map(|raw| {
208            bitcoin::consensus::deserialize(&raw)
209                .map_err(|e| Error::Invalid(format!("stored header: {e}")))
210        })
211        .transpose()
212    }
213
214    /// Only the chain coordinator calls this, after the header engine selected
215    /// the branch. This validates archive continuity, not difficulty/chainwork.
216    /// Untrusted peers and API clients cannot supply a branch to this method.
217    pub(crate) fn select(&mut self, start: u64, headers: &[Header]) -> Result<(), Error> {
218        if headers.is_empty() || start < self.config.application_start.height {
219            return Err(Error::Invalid("empty or out-of-domain chain update".into()));
220        }
221        let end = start
222            .checked_add(headers.len() as u64 - 1)
223            .filter(|h| *h <= i64::MAX as u64)
224            .ok_or_else(|| Error::Invalid("chain update height overflow".into()))?;
225        if start == self.config.application_start.height {
226            if headers[0].block_hash() != self.config.application_start.hash {
227                return Err(Error::Invalid(
228                    "selected chain does not contain configured application start".into(),
229                ));
230            }
231        } else {
232            let previous = self
233                .header_at(start - 1)?
234                .ok_or_else(|| Error::Invalid("chain update has a gap".into()))?;
235            if headers[0].prev_blockhash != previous.block_hash() {
236                return Err(Error::Invalid(
237                    "chain update does not join selected parent".into(),
238                ));
239            }
240        }
241        for pair in headers.windows(2) {
242            if pair[1].prev_blockhash != pair[0].block_hash() {
243                return Err(Error::Invalid("noncontiguous selected headers".into()));
244            }
245        }
246        let mut changed_at = None;
247        for (offset, header) in headers.iter().enumerate() {
248            let height = start + offset as u64;
249            if self.header_at(height)?.is_some_and(|old| old != *header) {
250                changed_at = Some(height);
251                break;
252            }
253        }
254        let tx = self.db.transaction()?;
255        if let Some(height) = changed_at {
256            tx.execute(
257                "DELETE FROM selected WHERE height>=?1",
258                [sql_integer(height)?],
259            )?;
260            tx.execute(
261                "UPDATE state SET generation=generation+1 WHERE singleton=1",
262                [],
263            )?;
264            tx.execute("INSERT INTO events(generation,kind,height) SELECT generation,'reorg',?1 FROM state", [sql_integer(height)?])?;
265            peers::reorg(&tx, height)?;
266        }
267        for (offset, header) in headers.iter().enumerate() {
268            let hash = header.block_hash().to_string();
269            tx.execute(
270                "INSERT OR IGNORE INTO headers VALUES(?1,?2)",
271                params![hash, serialize(header)],
272            )?;
273            tx.execute("INSERT INTO selected VALUES(?1,?2) ON CONFLICT(height) DO UPDATE SET hash=excluded.hash",
274                params![sql_integer(start + offset as u64)?, hash])?;
275        }
276        tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'chain',?1,?2 FROM state",
277            params![sql_integer(end)?, headers.last().expect("nonempty checked").block_hash().to_string()])?;
278        tx.commit()?;
279        Ok(())
280    }
281
282    pub(crate) fn check_ticket(&self, ticket: ScanTicket) -> Result<(), Error> {
283        if generation(&self.db)? != ticket.generation
284            || self.ticket(ticket.point.height)?.point != ticket.point
285        {
286            return Err(Error::Stale);
287        }
288        Ok(())
289    }
290
291    pub(crate) fn next_scan(&self) -> Result<Option<ScanTicket>, Error> {
292        let height: Option<u64> = self
293            .db
294            .query_row(
295                "SELECT s.height FROM selected s LEFT JOIN scans c ON c.block_hash=s.hash
296             WHERE c.block_hash IS NULL OR c.rules!=?1 ORDER BY s.height LIMIT 1",
297                [SCAN_RULES_VERSION],
298                |r| unsigned(r, 0),
299            )
300            .optional()?;
301        height.map(|h| self.ticket(h)).transpose()
302    }
303
304    pub(crate) fn transaction(&self, txid: bitcoin::Txid) -> Result<Option<Vec<u8>>, Error> {
305        Ok(self
306            .db
307            .query_row(
308                "SELECT raw FROM transactions WHERE txid=?1 LIMIT 1",
309                [txid.to_string()],
310                |r| r.get(0),
311            )
312            .optional()?)
313    }
314
315    pub(crate) fn location(&self, txid: bitcoin::Txid) -> Result<Option<ChainPoint>, Error> {
316        Ok(self.db.query_row(
317            "SELECT s.height,s.hash FROM locators l JOIN selected s ON s.hash=l.block_hash WHERE l.txid=?1 ORDER BY s.height LIMIT 1",
318            [txid.to_string()], point,
319        ).optional()?)
320    }
321
322    /// Placements of records this node holds in fully scanned selected blocks
323    /// within [from, to], strictly after `after` as (height, position),
324    /// ascending; `limit` rows at most. Rows never exceed `scanned_through`,
325    /// this node's contiguous indexed prefix: a scanned block still waiting on
326    /// a dependency sits above that ceiling and is not listed until it joins
327    /// the prefix. Pending reveals appear once their parent resolves. Evicted records drop out; only LeafManifest/DataPart
328    /// are ever evicted, and those are exempt from omission detection.
329    pub(crate) fn inventory_page(
330        &self,
331        from: u64,
332        to: u64,
333        after: Option<(u64, u32)>,
334        limit: u32,
335    ) -> Result<InventoryRows, Error> {
336        let scanned_from = self.config.application_start.height;
337        let Some(ceiling) = self.indexed_through()? else {
338            return Ok(InventoryRows {
339                scanned_from,
340                scanned_through: None,
341                entries: Vec::new(),
342                more: false,
343            });
344        };
345        let (after_height, after_position) = match after {
346            Some((height, position)) => (sql_integer(height)?, i64::from(position)),
347            None => (-1, -1),
348        };
349        let mut statement = self.db.prepare(
350            "SELECT s.height,s.hash,l.position,l.txid,l.wtxid FROM records p
351             JOIN locators l ON l.block_hash=p.block_hash AND l.position=p.position
352             JOIN selected s ON s.hash=p.block_hash
353             JOIN scans c ON c.block_hash=p.block_hash AND c.rules=?5
354             WHERE s.height>=?1 AND s.height<=?2 AND s.height<=?7
355               AND (s.height>?3 OR (s.height=?3 AND l.position>?4))
356             ORDER BY s.height,l.position LIMIT ?6",
357        )?;
358        let wanted = usize::try_from(limit).map_err(|e| Error::Invalid(e.to_string()))?;
359        let bound = params![
360            sql_integer(from)?,
361            sql_integer(to)?,
362            after_height,
363            after_position,
364            SCAN_RULES_VERSION,
365            i64::from(limit) + 1,
366            sql_integer(ceiling.height)?
367        ];
368        let mut entries = Vec::new();
369        for row in statement.query_map(bound, |row| {
370            Ok((
371                point(row)?,
372                row.get::<_, i64>(2)?,
373                row.get::<_, String>(3)?,
374                row.get::<_, String>(4)?,
375            ))
376        })? {
377            let (block, position, txid, wtxid) = row?;
378            let invalid = |e: String| Error::Invalid(format!("archived locator: {e}"));
379            entries.push((
380                block,
381                u32::try_from(position).map_err(|e| invalid(e.to_string()))?,
382                txid.parse()
383                    .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
384                wtxid
385                    .parse()
386                    .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
387            ));
388        }
389        let more = entries.len() > wanted;
390        entries.truncate(wanted);
391        Ok(InventoryRows {
392            scanned_from,
393            scanned_through: Some(ceiling.height),
394            entries,
395            more,
396        })
397    }
398
399    /// Orphan bytes remain archived but are never returned as selected evidence.
400    pub(crate) fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
401        let row: Option<(ChainPoint, u32, String, Vec<u8>, Vec<u8>)> = self
402            .db
403            .query_row(
404                "SELECT s.height,s.hash,r.position,r.wtxid,r.proof,t.raw FROM records r
405             JOIN selected s ON s.hash=r.block_hash
406             JOIN transactions reveal ON reveal.wtxid=r.wtxid
407             JOIN transactions t ON t.wtxid=r.commit_wtxid
408             WHERE reveal.txid=?1 ORDER BY s.height,r.position LIMIT 1",
409                [txid.to_string()],
410                |r| Ok((point(r)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
411            )
412            .optional()?;
413        let Some((block, position, wtxid, proof, commit)) = row else {
414            return Ok(None);
415        };
416        Ok(Some(ArchivedRecord {
417            receipt: RecordReceipt {
418                generation: generation(&self.db)?,
419                block,
420                position,
421                txid,
422                wtxid: wtxid
423                    .parse()
424                    .map_err(|e| Error::Invalid(format!("stored WTXID: {e}")))?,
425            },
426            package: RecordPackage {
427                version: API_VERSION,
428                chain_genesis: self
429                    .config
430                    .chain
431                    .genesis()
432                    .map_err(|e| Error::Invalid(e.to_string()))?
433                    .0,
434                block,
435                reveal: serde_json::from_slice(&proof)?,
436                commit,
437            },
438        }))
439    }
440
441    /// One distinct selected parent absent from every cached witness variant.
442    /// Cursor comparison uses persisted display hex, not Txid's byte ordering.
443    pub(crate) fn next_missing_dependency(
444        &self,
445        after: Option<bitcoin::Txid>,
446    ) -> Result<Option<(bitcoin::Txid, u64)>, Error> {
447        let id: Option<String> = self
448            .db
449            .query_row(
450                "SELECT DISTINCT d.commit_txid FROM dependencies d
451             JOIN selected s ON s.hash=d.block_hash
452             WHERE NOT EXISTS(SELECT 1 FROM transactions t WHERE t.txid=d.commit_txid)
453             AND (?1 IS NULL OR d.commit_txid>?1)
454             ORDER BY d.commit_txid LIMIT 1",
455                [after.map(|id| id.to_string())],
456                |row| row.get(0),
457            )
458            .optional()?;
459        id.map(|id| {
460            let id = id
461                .parse()
462                .map_err(|error| Error::Invalid(format!("stored parent TXID: {error}")))?;
463            Ok((id, generation(&self.db)?))
464        })
465        .transpose()
466    }
467
468    pub(crate) fn check_generation(&self, expected: u64) -> Result<(), Error> {
469        if generation(&self.db)? != expected {
470            return Err(Error::Stale);
471        }
472        Ok(())
473    }
474
475    pub(crate) fn pending_for(
476        &self,
477        txid: bitcoin::Txid,
478    ) -> Result<Vec<(ScanTicket, u32, urma_chain::inclusion::TransactionProof)>, Error> {
479        let mut stmt = self.db.prepare(
480            "SELECT s.height,s.hash,d.position,d.proof FROM dependencies d
481             JOIN selected s ON s.hash=d.block_hash WHERE d.commit_txid=?1
482             ORDER BY s.height,d.position LIMIT 128",
483        )?;
484        let rows = stmt.query_map([txid.to_string()], |r| {
485            Ok((point(r)?, r.get::<_, u32>(2)?, r.get::<_, Vec<u8>>(3)?))
486        })?;
487        let epoch = generation(&self.db)?;
488        rows.map(|row| {
489            let (point, position, proof) = row?;
490            Ok((
491                ScanTicket {
492                    generation: epoch,
493                    point,
494                },
495                position,
496                serde_json::from_slice(&proof)?,
497            ))
498        })
499        .collect()
500    }
501
502    pub(crate) fn cached_dependency(&self) -> Result<Option<Vec<u8>>, Error> {
503        Ok(self
504            .db
505            .query_row(
506                "SELECT t.raw FROM dependencies d JOIN selected s ON s.hash=d.block_hash
507             JOIN transactions t ON t.txid=d.commit_txid ORDER BY s.height,d.position LIMIT 1",
508                [],
509                |row| row.get(0),
510            )
511            .optional()?)
512    }
513
514    /// Completion changes only evidence from blocks already fully scanned.
515    /// A supplied parent cannot create a scan mark or a transaction placement.
516    pub(crate) fn finish_dependencies(
517        &mut self,
518        completed: Vec<(ScanTicket, u32, Option<crate::scan::Record>)>,
519    ) -> Result<usize, Error> {
520        for (ticket, _, _) in &completed {
521            self.check_ticket(*ticket)?;
522        }
523        let tx = self.db.transaction()?;
524        let mut count = 0;
525        for (ticket, position, record) in completed {
526            let hash = ticket.point.hash.to_string();
527            let removed = tx.execute(
528                "DELETE FROM dependencies WHERE block_hash=?1 AND position=?2",
529                params![hash, position],
530            )?;
531            if removed == 0 {
532                continue;
533            }
534            if let Some(record) = record {
535                store_record(&tx, &hash, &record)?;
536            }
537            tx.execute("UPDATE scans SET unresolved=(SELECT count(*) FROM dependencies WHERE block_hash=?1) WHERE block_hash=?1", [&hash])?;
538            tx.execute("INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'dependency',?2,?3)",
539                params![sql_integer(ticket.generation)?, sql_integer(ticket.point.height)?, hash])?;
540            count += 1;
541        }
542        tx.commit()?;
543        Ok(count)
544    }
545
546    pub(crate) fn admit_record(
547        &mut self,
548        ticket: ScanTicket,
549        record: crate::scan::Record,
550    ) -> Result<RecordReceipt, Error> {
551        self.check_ticket(ticket)?;
552        let receipt = RecordReceipt {
553            generation: ticket.generation,
554            block: ticket.point,
555            position: record.position,
556            txid: record.verified.txid(),
557            wtxid: record.wtxid,
558        };
559        let tx = self.db.transaction()?;
560        store_record(&tx, &ticket.point.hash.to_string(), &record)?;
561        tx.execute(
562            "INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'record',?2,?3)",
563            params![
564                sql_integer(ticket.generation)?,
565                sql_integer(ticket.point.height)?,
566                ticket.point.hash.to_string()
567            ],
568        )?;
569        // No locators, scan marks, dependency resolution, or completeness claims
570        // are inferred from a selective package.
571        tx.commit()?;
572        Ok(receipt)
573    }
574
575    pub(crate) fn commit_scan(&mut self, batch: crate::scan::ScanBatch) -> Result<(), Error> {
576        self.check_ticket(batch.ticket)?;
577        let point = batch.ticket.point;
578        let hash = point.hash.to_string();
579        let tx = self.db.transaction()?;
580        for (position, (txid, wtxid)) in batch.locators.iter().enumerate() {
581            tx.execute("INSERT INTO locators VALUES(?1,?2,?3,?4) ON CONFLICT(block_hash,position) DO UPDATE SET txid=excluded.txid,wtxid=excluded.wtxid",
582                params![hash, sql_integer(position as u64)?, txid.to_string(), wtxid.to_string()])?;
583        }
584        // One transaction includes all records, dependencies, progress and event.
585        // An I/O/constraint failure rolls it all back, including progress.
586        tx.execute("DELETE FROM dependencies WHERE block_hash=?1", [&hash])?;
587        peers::record_omissions(&tx, &batch)?;
588        for record in batch.records {
589            store_record(&tx, &hash, &record)?;
590        }
591        let unresolved = batch.dependencies.len() as u64;
592        for dependency in batch.dependencies {
593            tx.execute(
594                "INSERT INTO dependencies VALUES(?1,?2,?3,?4)",
595                params![
596                    hash,
597                    dependency.position,
598                    dependency.commit.to_string(),
599                    serde_json::to_vec(&dependency.proof)?
600                ],
601            )?;
602        }
603        tx.execute("INSERT INTO scans VALUES(?1,?2,?3) ON CONFLICT(block_hash) DO UPDATE SET rules=excluded.rules,unresolved=excluded.unresolved",
604            params![hash, SCAN_RULES_VERSION, sql_integer(unresolved)?])?;
605        tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'scan',?1,?2 FROM state",
606            params![sql_integer(point.height)?, hash])?;
607        tx.commit()?;
608        Ok(())
609    }
610}
611
612fn migrate(db: &mut Connection, version: u32) -> Result<(), Error> {
613    if version >= 5 {
614        return Ok(());
615    }
616    let tx = db.transaction()?;
617    if version < 2 {
618        tx.execute_batch(objects::SCHEMA)?;
619    } else if version == 2 {
620        objects::migrate_v2(&tx)?;
621    }
622    if version < 4 {
623        tx.execute_batch(names::SCHEMA)?;
624    }
625    tx.execute_batch(peers::SCHEMA)?;
626    tx.commit()?;
627    Ok(())
628}
629
630/// One inventory page as stored: (block, position, txid, wtxid) rows.
631pub(crate) struct InventoryRows {
632    pub(crate) scanned_from: u64,
633    pub(crate) scanned_through: Option<u64>,
634    pub(crate) entries: Vec<(ChainPoint, u32, bitcoin::Txid, bitcoin::Wtxid)>,
635    pub(crate) more: bool,
636}
637
638fn generation(db: &Connection) -> Result<u64, Error> {
639    Ok(
640        db.query_row("SELECT generation FROM state WHERE singleton=1", [], |r| {
641            unsigned(r, 0)
642        })?,
643    )
644}
645
646fn point(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainPoint> {
647    let hash: String = row.get(1)?;
648    Ok(ChainPoint {
649        height: unsigned(row, 0)?,
650        hash: hash.parse::<BlockHash>().map_err(|e| {
651            rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
652        })?,
653    })
654}
655
656fn sql_integer(value: u64) -> Result<i64, Error> {
657    i64::try_from(value).map_err(|_| Error::Invalid("integer exceeds archive range".into()))
658}
659
660fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
661    let value: i64 = row.get(column)?;
662    u64::try_from(value).map_err(|e| {
663        rusqlite::Error::FromSqlConversionFailure(
664            column,
665            rusqlite::types::Type::Integer,
666            Box::new(e),
667        )
668    })
669}
670
671fn store_record(db: &Connection, hash: &str, record: &crate::scan::Record) -> Result<(), Error> {
672    let wtxid = record.wtxid.to_string();
673    let commit_wtxid = record.commit.compute_wtxid().to_string();
674    db.execute(
675        "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
676        params![
677            wtxid,
678            record.verified.txid().to_string(),
679            record.proof.transaction
680        ],
681    )?;
682    db.execute(
683        "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
684        params![
685            commit_wtxid,
686            record.commit.compute_txid().to_string(),
687            record.raw_commit
688        ],
689    )?;
690    db.execute("INSERT INTO records VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(block_hash,position) DO UPDATE SET wtxid=excluded.wtxid,author=excluded.author,record=excluded.record,commit_wtxid=excluded.commit_wtxid,proof=excluded.proof",
691        params![hash, record.position, wtxid, record.verified.author().to_string(), record.verified.record_bytes(), commit_wtxid, serde_json::to_vec(&record.proof)?])?;
692    Ok(())
693}