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        Ok(ArchiveStatus {
139            api_version: API_VERSION,
140            chain_genesis: self
141                .config
142                .chain
143                .genesis()
144                .map_err(|e| Error::Invalid(e.to_string()))?
145                .0,
146            application_start: self.config.application_start,
147            retention: self.config.retention,
148            generation,
149            selected_tip,
150            indexed_through,
151            scanned_blocks,
152            unresolved_dependencies,
153            stored_transactions,
154            scan_rules_version: SCAN_RULES_VERSION,
155            peer_synced_through: peers::synced_through(
156                &self.db,
157                self.config.application_start.height,
158            )?,
159            peer_omissions: peers::omission_count(&self.db)?,
160            unaudited_records: peers::unaudited_count(&self.db)?,
161        })
162    }
163
164    /// Contiguous fully scanned, dependency-complete coverage from application start.
165    fn indexed_through(&self) -> Result<Option<ChainPoint>, Error> {
166        let start = self.config.application_start.height;
167        let mut next = start;
168        let mut indexed_through = None;
169        let mut rows = self.db.prepare(
170            "SELECT s.height,s.hash FROM selected s JOIN scans c ON c.block_hash=s.hash
171             WHERE s.height>=?1 AND c.rules=?2 AND c.unresolved=0 ORDER BY s.height",
172        )?;
173        for row in rows.query_map(params![sql_integer(start)?, SCAN_RULES_VERSION], point)? {
174            let current = row?;
175            if current.height != next {
176                break;
177            }
178            next += 1;
179            indexed_through = Some(current);
180        }
181        Ok(indexed_through)
182    }
183
184    pub fn ticket(&self, height: u64) -> Result<ScanTicket, Error> {
185        let point = self
186            .db
187            .query_row(
188                "SELECT height,hash FROM selected WHERE height=?1",
189                [sql_integer(height)?],
190                point,
191            )
192            .optional()?
193            .ok_or_else(|| Error::Invalid("block is not in the selected view".into()))?;
194        Ok(ScanTicket {
195            generation: generation(&self.db)?,
196            point,
197        })
198    }
199
200    pub(crate) fn header_at(&self, height: u64) -> Result<Option<Header>, Error> {
201        let raw: Option<Vec<u8>> = self
202            .db
203            .query_row(
204                "SELECT h.bytes FROM selected s JOIN headers h ON h.hash=s.hash WHERE s.height=?1",
205                [sql_integer(height)?],
206                |r| r.get(0),
207            )
208            .optional()?;
209        raw.map(|raw| {
210            bitcoin::consensus::deserialize(&raw)
211                .map_err(|e| Error::Invalid(format!("stored header: {e}")))
212        })
213        .transpose()
214    }
215
216    /// Only the chain coordinator calls this, after the header engine selected
217    /// the branch. This validates archive continuity, not difficulty/chainwork.
218    /// Untrusted peers and API clients cannot supply a branch to this method.
219    pub(crate) fn select(&mut self, start: u64, headers: &[Header]) -> Result<(), Error> {
220        if headers.is_empty() || start < self.config.application_start.height {
221            return Err(Error::Invalid("empty or out-of-domain chain update".into()));
222        }
223        let end = start
224            .checked_add(headers.len() as u64 - 1)
225            .filter(|h| *h <= i64::MAX as u64)
226            .ok_or_else(|| Error::Invalid("chain update height overflow".into()))?;
227        if start == self.config.application_start.height {
228            if headers[0].block_hash() != self.config.application_start.hash {
229                return Err(Error::Invalid(
230                    "selected chain does not contain configured application start".into(),
231                ));
232            }
233        } else {
234            let previous = self
235                .header_at(start - 1)?
236                .ok_or_else(|| Error::Invalid("chain update has a gap".into()))?;
237            if headers[0].prev_blockhash != previous.block_hash() {
238                return Err(Error::Invalid(
239                    "chain update does not join selected parent".into(),
240                ));
241            }
242        }
243        for pair in headers.windows(2) {
244            if pair[1].prev_blockhash != pair[0].block_hash() {
245                return Err(Error::Invalid("noncontiguous selected headers".into()));
246            }
247        }
248        let mut changed_at = None;
249        for (offset, header) in headers.iter().enumerate() {
250            let height = start + offset as u64;
251            if self.header_at(height)?.is_some_and(|old| old != *header) {
252                changed_at = Some(height);
253                break;
254            }
255        }
256        let tx = self.db.transaction()?;
257        if let Some(height) = changed_at {
258            tx.execute(
259                "DELETE FROM selected WHERE height>=?1",
260                [sql_integer(height)?],
261            )?;
262            tx.execute(
263                "UPDATE state SET generation=generation+1 WHERE singleton=1",
264                [],
265            )?;
266            tx.execute("INSERT INTO events(generation,kind,height) SELECT generation,'reorg',?1 FROM state", [sql_integer(height)?])?;
267            peers::reorg(&tx, height)?;
268        }
269        for (offset, header) in headers.iter().enumerate() {
270            let hash = header.block_hash().to_string();
271            tx.execute(
272                "INSERT OR IGNORE INTO headers VALUES(?1,?2)",
273                params![hash, serialize(header)],
274            )?;
275            tx.execute("INSERT INTO selected VALUES(?1,?2) ON CONFLICT(height) DO UPDATE SET hash=excluded.hash",
276                params![sql_integer(start + offset as u64)?, hash])?;
277        }
278        tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'chain',?1,?2 FROM state",
279            params![sql_integer(end)?, headers.last().expect("nonempty checked").block_hash().to_string()])?;
280        tx.commit()?;
281        Ok(())
282    }
283
284    pub(crate) fn check_ticket(&self, ticket: ScanTicket) -> Result<(), Error> {
285        if generation(&self.db)? != ticket.generation
286            || self.ticket(ticket.point.height)?.point != ticket.point
287        {
288            return Err(Error::Stale);
289        }
290        Ok(())
291    }
292
293    pub(crate) fn next_scan(&self) -> Result<Option<ScanTicket>, Error> {
294        let height: Option<u64> = self
295            .db
296            .query_row(
297                "SELECT s.height FROM selected s LEFT JOIN scans c ON c.block_hash=s.hash
298             WHERE c.block_hash IS NULL OR c.rules!=?1 ORDER BY s.height LIMIT 1",
299                [SCAN_RULES_VERSION],
300                |r| unsigned(r, 0),
301            )
302            .optional()?;
303        height.map(|h| self.ticket(h)).transpose()
304    }
305
306    pub(crate) fn transaction(&self, txid: bitcoin::Txid) -> Result<Option<Vec<u8>>, Error> {
307        Ok(self
308            .db
309            .query_row(
310                "SELECT raw FROM transactions WHERE txid=?1 LIMIT 1",
311                [txid.to_string()],
312                |r| r.get(0),
313            )
314            .optional()?)
315    }
316
317    pub(crate) fn location(&self, txid: bitcoin::Txid) -> Result<Option<ChainPoint>, Error> {
318        Ok(self.db.query_row(
319            "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",
320            [txid.to_string()], point,
321        ).optional()?)
322    }
323
324    /// Placements of records this node holds in fully scanned selected blocks
325    /// within [from, to], strictly after `after` as (height, position),
326    /// ascending; `limit` rows at most. Rows never exceed `scanned_through`,
327    /// this node's contiguous indexed prefix: a scanned block still waiting on
328    /// a dependency sits above that ceiling and is not listed until it joins
329    /// the prefix. Pending reveals appear once their parent resolves. Evicted records drop out; only LeafManifest/DataPart
330    /// are ever evicted, and those are exempt from omission detection.
331    pub(crate) fn inventory_page(
332        &self,
333        from: u64,
334        to: u64,
335        after: Option<(u64, u32)>,
336        limit: u32,
337    ) -> Result<InventoryRows, Error> {
338        let Some(ceiling) = self.indexed_through()? else {
339            return Ok(InventoryRows {
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_through: Some(ceiling.height),
393            entries,
394            more,
395        })
396    }
397
398    /// Orphan bytes remain archived but are never returned as selected evidence.
399    pub(crate) fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
400        let row: Option<(ChainPoint, u32, String, Vec<u8>, Vec<u8>)> = self
401            .db
402            .query_row(
403                "SELECT s.height,s.hash,r.position,r.wtxid,r.proof,t.raw FROM records r
404             JOIN selected s ON s.hash=r.block_hash
405             JOIN transactions reveal ON reveal.wtxid=r.wtxid
406             JOIN transactions t ON t.wtxid=r.commit_wtxid
407             WHERE reveal.txid=?1 ORDER BY s.height,r.position LIMIT 1",
408                [txid.to_string()],
409                |r| Ok((point(r)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
410            )
411            .optional()?;
412        let Some((block, position, wtxid, proof, commit)) = row else {
413            return Ok(None);
414        };
415        Ok(Some(ArchivedRecord {
416            receipt: RecordReceipt {
417                generation: generation(&self.db)?,
418                block,
419                position,
420                txid,
421                wtxid: wtxid
422                    .parse()
423                    .map_err(|e| Error::Invalid(format!("stored WTXID: {e}")))?,
424            },
425            package: RecordPackage {
426                version: API_VERSION,
427                chain_genesis: self
428                    .config
429                    .chain
430                    .genesis()
431                    .map_err(|e| Error::Invalid(e.to_string()))?
432                    .0,
433                block,
434                reveal: serde_json::from_slice(&proof)?,
435                commit,
436            },
437        }))
438    }
439
440    /// One distinct selected parent absent from every cached witness variant.
441    /// Cursor comparison uses persisted display hex, not Txid's byte ordering.
442    pub(crate) fn next_missing_dependency(
443        &self,
444        after: Option<bitcoin::Txid>,
445    ) -> Result<Option<(bitcoin::Txid, u64)>, Error> {
446        let id: Option<String> = self
447            .db
448            .query_row(
449                "SELECT DISTINCT d.commit_txid FROM dependencies d
450             JOIN selected s ON s.hash=d.block_hash
451             WHERE NOT EXISTS(SELECT 1 FROM transactions t WHERE t.txid=d.commit_txid)
452             AND (?1 IS NULL OR d.commit_txid>?1)
453             ORDER BY d.commit_txid LIMIT 1",
454                [after.map(|id| id.to_string())],
455                |row| row.get(0),
456            )
457            .optional()?;
458        id.map(|id| {
459            let id = id
460                .parse()
461                .map_err(|error| Error::Invalid(format!("stored parent TXID: {error}")))?;
462            Ok((id, generation(&self.db)?))
463        })
464        .transpose()
465    }
466
467    pub(crate) fn check_generation(&self, expected: u64) -> Result<(), Error> {
468        if generation(&self.db)? != expected {
469            return Err(Error::Stale);
470        }
471        Ok(())
472    }
473
474    pub(crate) fn pending_for(
475        &self,
476        txid: bitcoin::Txid,
477    ) -> Result<Vec<(ScanTicket, u32, urma_chain::inclusion::TransactionProof)>, Error> {
478        let mut stmt = self.db.prepare(
479            "SELECT s.height,s.hash,d.position,d.proof FROM dependencies d
480             JOIN selected s ON s.hash=d.block_hash WHERE d.commit_txid=?1
481             ORDER BY s.height,d.position LIMIT 128",
482        )?;
483        let rows = stmt.query_map([txid.to_string()], |r| {
484            Ok((point(r)?, r.get::<_, u32>(2)?, r.get::<_, Vec<u8>>(3)?))
485        })?;
486        let epoch = generation(&self.db)?;
487        rows.map(|row| {
488            let (point, position, proof) = row?;
489            Ok((
490                ScanTicket {
491                    generation: epoch,
492                    point,
493                },
494                position,
495                serde_json::from_slice(&proof)?,
496            ))
497        })
498        .collect()
499    }
500
501    pub(crate) fn cached_dependency(&self) -> Result<Option<Vec<u8>>, Error> {
502        Ok(self
503            .db
504            .query_row(
505                "SELECT t.raw FROM dependencies d JOIN selected s ON s.hash=d.block_hash
506             JOIN transactions t ON t.txid=d.commit_txid ORDER BY s.height,d.position LIMIT 1",
507                [],
508                |row| row.get(0),
509            )
510            .optional()?)
511    }
512
513    /// Completion changes only evidence from blocks already fully scanned.
514    /// A supplied parent cannot create a scan mark or a transaction placement.
515    pub(crate) fn finish_dependencies(
516        &mut self,
517        completed: Vec<(ScanTicket, u32, Option<crate::scan::Record>)>,
518    ) -> Result<usize, Error> {
519        for (ticket, _, _) in &completed {
520            self.check_ticket(*ticket)?;
521        }
522        let tx = self.db.transaction()?;
523        let mut count = 0;
524        for (ticket, position, record) in completed {
525            let hash = ticket.point.hash.to_string();
526            let removed = tx.execute(
527                "DELETE FROM dependencies WHERE block_hash=?1 AND position=?2",
528                params![hash, position],
529            )?;
530            if removed == 0 {
531                continue;
532            }
533            if let Some(record) = record {
534                store_record(&tx, &hash, &record)?;
535            }
536            tx.execute("UPDATE scans SET unresolved=(SELECT count(*) FROM dependencies WHERE block_hash=?1) WHERE block_hash=?1", [&hash])?;
537            tx.execute("INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'dependency',?2,?3)",
538                params![sql_integer(ticket.generation)?, sql_integer(ticket.point.height)?, hash])?;
539            count += 1;
540        }
541        tx.commit()?;
542        Ok(count)
543    }
544
545    pub(crate) fn admit_record(
546        &mut self,
547        ticket: ScanTicket,
548        record: crate::scan::Record,
549    ) -> Result<RecordReceipt, Error> {
550        self.check_ticket(ticket)?;
551        let receipt = RecordReceipt {
552            generation: ticket.generation,
553            block: ticket.point,
554            position: record.position,
555            txid: record.verified.txid(),
556            wtxid: record.wtxid,
557        };
558        let tx = self.db.transaction()?;
559        store_record(&tx, &ticket.point.hash.to_string(), &record)?;
560        tx.execute(
561            "INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'record',?2,?3)",
562            params![
563                sql_integer(ticket.generation)?,
564                sql_integer(ticket.point.height)?,
565                ticket.point.hash.to_string()
566            ],
567        )?;
568        // No locators, scan marks, dependency resolution, or completeness claims
569        // are inferred from a selective package.
570        tx.commit()?;
571        Ok(receipt)
572    }
573
574    pub(crate) fn commit_scan(&mut self, batch: crate::scan::ScanBatch) -> Result<(), Error> {
575        self.check_ticket(batch.ticket)?;
576        let point = batch.ticket.point;
577        let hash = point.hash.to_string();
578        let tx = self.db.transaction()?;
579        for (position, (txid, wtxid)) in batch.locators.iter().enumerate() {
580            tx.execute("INSERT INTO locators VALUES(?1,?2,?3,?4) ON CONFLICT(block_hash,position) DO UPDATE SET txid=excluded.txid,wtxid=excluded.wtxid",
581                params![hash, sql_integer(position as u64)?, txid.to_string(), wtxid.to_string()])?;
582        }
583        // One transaction includes all records, dependencies, progress and event.
584        // An I/O/constraint failure rolls it all back, including progress.
585        tx.execute("DELETE FROM dependencies WHERE block_hash=?1", [&hash])?;
586        peers::record_omissions(&tx, &batch)?;
587        for record in batch.records {
588            store_record(&tx, &hash, &record)?;
589        }
590        let unresolved = batch.dependencies.len() as u64;
591        for dependency in batch.dependencies {
592            tx.execute(
593                "INSERT INTO dependencies VALUES(?1,?2,?3,?4)",
594                params![
595                    hash,
596                    dependency.position,
597                    dependency.commit.to_string(),
598                    serde_json::to_vec(&dependency.proof)?
599                ],
600            )?;
601        }
602        tx.execute("INSERT INTO scans VALUES(?1,?2,?3) ON CONFLICT(block_hash) DO UPDATE SET rules=excluded.rules,unresolved=excluded.unresolved",
603            params![hash, SCAN_RULES_VERSION, sql_integer(unresolved)?])?;
604        tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'scan',?1,?2 FROM state",
605            params![sql_integer(point.height)?, hash])?;
606        tx.commit()?;
607        Ok(())
608    }
609}
610
611fn migrate(db: &mut Connection, version: u32) -> Result<(), Error> {
612    if version >= 5 {
613        return Ok(());
614    }
615    let tx = db.transaction()?;
616    if version < 2 {
617        tx.execute_batch(objects::SCHEMA)?;
618    } else if version == 2 {
619        objects::migrate_v2(&tx)?;
620    }
621    if version < 4 {
622        tx.execute_batch(names::SCHEMA)?;
623    }
624    tx.execute_batch(peers::SCHEMA)?;
625    tx.commit()?;
626    Ok(())
627}
628
629/// One inventory page as stored: (block, position, txid, wtxid) rows.
630pub(crate) struct InventoryRows {
631    pub(crate) scanned_through: Option<u64>,
632    pub(crate) entries: Vec<(ChainPoint, u32, bitcoin::Txid, bitcoin::Wtxid)>,
633    pub(crate) more: bool,
634}
635
636fn generation(db: &Connection) -> Result<u64, Error> {
637    Ok(
638        db.query_row("SELECT generation FROM state WHERE singleton=1", [], |r| {
639            unsigned(r, 0)
640        })?,
641    )
642}
643
644fn point(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainPoint> {
645    let hash: String = row.get(1)?;
646    Ok(ChainPoint {
647        height: unsigned(row, 0)?,
648        hash: hash.parse::<BlockHash>().map_err(|e| {
649            rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
650        })?,
651    })
652}
653
654fn sql_integer(value: u64) -> Result<i64, Error> {
655    i64::try_from(value).map_err(|_| Error::Invalid("integer exceeds archive range".into()))
656}
657
658fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
659    let value: i64 = row.get(column)?;
660    u64::try_from(value).map_err(|e| {
661        rusqlite::Error::FromSqlConversionFailure(
662            column,
663            rusqlite::types::Type::Integer,
664            Box::new(e),
665        )
666    })
667}
668
669fn store_record(db: &Connection, hash: &str, record: &crate::scan::Record) -> Result<(), Error> {
670    let wtxid = record.wtxid.to_string();
671    let commit_wtxid = record.commit.compute_wtxid().to_string();
672    db.execute(
673        "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
674        params![
675            wtxid,
676            record.verified.txid().to_string(),
677            record.proof.transaction
678        ],
679    )?;
680    db.execute(
681        "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
682        params![
683            commit_wtxid,
684            record.commit.compute_txid().to_string(),
685            record.raw_commit
686        ],
687    )?;
688    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",
689        params![hash, record.position, wtxid, record.verified.author().to_string(), record.verified.record_bytes(), commit_wtxid, serde_json::to_vec(&record.proof)?])?;
690    Ok(())
691}