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 Some(ceiling) = self.indexed_through()? else {
337            return Ok(InventoryRows {
338                scanned_through: None,
339                entries: Vec::new(),
340                more: false,
341            });
342        };
343        let (after_height, after_position) = match after {
344            Some((height, position)) => (sql_integer(height)?, i64::from(position)),
345            None => (-1, -1),
346        };
347        let mut statement = self.db.prepare(
348            "SELECT s.height,s.hash,l.position,l.txid,l.wtxid FROM records p
349             JOIN locators l ON l.block_hash=p.block_hash AND l.position=p.position
350             JOIN selected s ON s.hash=p.block_hash
351             JOIN scans c ON c.block_hash=p.block_hash AND c.rules=?5
352             WHERE s.height>=?1 AND s.height<=?2 AND s.height<=?7
353               AND (s.height>?3 OR (s.height=?3 AND l.position>?4))
354             ORDER BY s.height,l.position LIMIT ?6",
355        )?;
356        let wanted = usize::try_from(limit).map_err(|e| Error::Invalid(e.to_string()))?;
357        let bound = params![
358            sql_integer(from)?,
359            sql_integer(to)?,
360            after_height,
361            after_position,
362            SCAN_RULES_VERSION,
363            i64::from(limit) + 1,
364            sql_integer(ceiling.height)?
365        ];
366        let mut entries = Vec::new();
367        for row in statement.query_map(bound, |row| {
368            Ok((
369                point(row)?,
370                row.get::<_, i64>(2)?,
371                row.get::<_, String>(3)?,
372                row.get::<_, String>(4)?,
373            ))
374        })? {
375            let (block, position, txid, wtxid) = row?;
376            let invalid = |e: String| Error::Invalid(format!("archived locator: {e}"));
377            entries.push((
378                block,
379                u32::try_from(position).map_err(|e| invalid(e.to_string()))?,
380                txid.parse()
381                    .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
382                wtxid
383                    .parse()
384                    .map_err(|e: bitcoin::hex::HexToArrayError| invalid(e.to_string()))?,
385            ));
386        }
387        let more = entries.len() > wanted;
388        entries.truncate(wanted);
389        Ok(InventoryRows {
390            scanned_through: Some(ceiling.height),
391            entries,
392            more,
393        })
394    }
395
396    /// Orphan bytes remain archived but are never returned as selected evidence.
397    pub(crate) fn record(&self, txid: bitcoin::Txid) -> Result<Option<ArchivedRecord>, Error> {
398        let row: Option<(ChainPoint, u32, String, Vec<u8>, Vec<u8>)> = self
399            .db
400            .query_row(
401                "SELECT s.height,s.hash,r.position,r.wtxid,r.proof,t.raw FROM records r
402             JOIN selected s ON s.hash=r.block_hash
403             JOIN transactions reveal ON reveal.wtxid=r.wtxid
404             JOIN transactions t ON t.wtxid=r.commit_wtxid
405             WHERE reveal.txid=?1 ORDER BY s.height,r.position LIMIT 1",
406                [txid.to_string()],
407                |r| Ok((point(r)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
408            )
409            .optional()?;
410        let Some((block, position, wtxid, proof, commit)) = row else {
411            return Ok(None);
412        };
413        Ok(Some(ArchivedRecord {
414            receipt: RecordReceipt {
415                generation: generation(&self.db)?,
416                block,
417                position,
418                txid,
419                wtxid: wtxid
420                    .parse()
421                    .map_err(|e| Error::Invalid(format!("stored WTXID: {e}")))?,
422            },
423            package: RecordPackage {
424                version: API_VERSION,
425                chain_genesis: self
426                    .config
427                    .chain
428                    .genesis()
429                    .map_err(|e| Error::Invalid(e.to_string()))?
430                    .0,
431                block,
432                reveal: serde_json::from_slice(&proof)?,
433                commit,
434            },
435        }))
436    }
437
438    /// One distinct selected parent absent from every cached witness variant.
439    /// Cursor comparison uses persisted display hex, not Txid's byte ordering.
440    pub(crate) fn next_missing_dependency(
441        &self,
442        after: Option<bitcoin::Txid>,
443    ) -> Result<Option<(bitcoin::Txid, u64)>, Error> {
444        let id: Option<String> = self
445            .db
446            .query_row(
447                "SELECT DISTINCT d.commit_txid FROM dependencies d
448             JOIN selected s ON s.hash=d.block_hash
449             WHERE NOT EXISTS(SELECT 1 FROM transactions t WHERE t.txid=d.commit_txid)
450             AND (?1 IS NULL OR d.commit_txid>?1)
451             ORDER BY d.commit_txid LIMIT 1",
452                [after.map(|id| id.to_string())],
453                |row| row.get(0),
454            )
455            .optional()?;
456        id.map(|id| {
457            let id = id
458                .parse()
459                .map_err(|error| Error::Invalid(format!("stored parent TXID: {error}")))?;
460            Ok((id, generation(&self.db)?))
461        })
462        .transpose()
463    }
464
465    pub(crate) fn check_generation(&self, expected: u64) -> Result<(), Error> {
466        if generation(&self.db)? != expected {
467            return Err(Error::Stale);
468        }
469        Ok(())
470    }
471
472    pub(crate) fn pending_for(
473        &self,
474        txid: bitcoin::Txid,
475    ) -> Result<Vec<(ScanTicket, u32, urma_chain::inclusion::TransactionProof)>, Error> {
476        let mut stmt = self.db.prepare(
477            "SELECT s.height,s.hash,d.position,d.proof FROM dependencies d
478             JOIN selected s ON s.hash=d.block_hash WHERE d.commit_txid=?1
479             ORDER BY s.height,d.position LIMIT 128",
480        )?;
481        let rows = stmt.query_map([txid.to_string()], |r| {
482            Ok((point(r)?, r.get::<_, u32>(2)?, r.get::<_, Vec<u8>>(3)?))
483        })?;
484        let epoch = generation(&self.db)?;
485        rows.map(|row| {
486            let (point, position, proof) = row?;
487            Ok((
488                ScanTicket {
489                    generation: epoch,
490                    point,
491                },
492                position,
493                serde_json::from_slice(&proof)?,
494            ))
495        })
496        .collect()
497    }
498
499    pub(crate) fn cached_dependency(&self) -> Result<Option<Vec<u8>>, Error> {
500        Ok(self
501            .db
502            .query_row(
503                "SELECT t.raw FROM dependencies d JOIN selected s ON s.hash=d.block_hash
504             JOIN transactions t ON t.txid=d.commit_txid ORDER BY s.height,d.position LIMIT 1",
505                [],
506                |row| row.get(0),
507            )
508            .optional()?)
509    }
510
511    /// Completion changes only evidence from blocks already fully scanned.
512    /// A supplied parent cannot create a scan mark or a transaction placement.
513    pub(crate) fn finish_dependencies(
514        &mut self,
515        completed: Vec<(ScanTicket, u32, Option<crate::scan::Record>)>,
516    ) -> Result<usize, Error> {
517        for (ticket, _, _) in &completed {
518            self.check_ticket(*ticket)?;
519        }
520        let tx = self.db.transaction()?;
521        let mut count = 0;
522        for (ticket, position, record) in completed {
523            let hash = ticket.point.hash.to_string();
524            let removed = tx.execute(
525                "DELETE FROM dependencies WHERE block_hash=?1 AND position=?2",
526                params![hash, position],
527            )?;
528            if removed == 0 {
529                continue;
530            }
531            if let Some(record) = record {
532                store_record(&tx, &hash, &record)?;
533            }
534            tx.execute("UPDATE scans SET unresolved=(SELECT count(*) FROM dependencies WHERE block_hash=?1) WHERE block_hash=?1", [&hash])?;
535            tx.execute("INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'dependency',?2,?3)",
536                params![sql_integer(ticket.generation)?, sql_integer(ticket.point.height)?, hash])?;
537            count += 1;
538        }
539        tx.commit()?;
540        Ok(count)
541    }
542
543    pub(crate) fn admit_record(
544        &mut self,
545        ticket: ScanTicket,
546        record: crate::scan::Record,
547    ) -> Result<RecordReceipt, Error> {
548        self.check_ticket(ticket)?;
549        let receipt = RecordReceipt {
550            generation: ticket.generation,
551            block: ticket.point,
552            position: record.position,
553            txid: record.verified.txid(),
554            wtxid: record.wtxid,
555        };
556        let tx = self.db.transaction()?;
557        store_record(&tx, &ticket.point.hash.to_string(), &record)?;
558        tx.execute(
559            "INSERT INTO events(generation,kind,height,block_hash) VALUES(?1,'record',?2,?3)",
560            params![
561                sql_integer(ticket.generation)?,
562                sql_integer(ticket.point.height)?,
563                ticket.point.hash.to_string()
564            ],
565        )?;
566        // No locators, scan marks, dependency resolution, or completeness claims
567        // are inferred from a selective package.
568        tx.commit()?;
569        Ok(receipt)
570    }
571
572    pub(crate) fn commit_scan(&mut self, batch: crate::scan::ScanBatch) -> Result<(), Error> {
573        self.check_ticket(batch.ticket)?;
574        let point = batch.ticket.point;
575        let hash = point.hash.to_string();
576        let tx = self.db.transaction()?;
577        for (position, (txid, wtxid)) in batch.locators.iter().enumerate() {
578            tx.execute("INSERT INTO locators VALUES(?1,?2,?3,?4) ON CONFLICT(block_hash,position) DO UPDATE SET txid=excluded.txid,wtxid=excluded.wtxid",
579                params![hash, sql_integer(position as u64)?, txid.to_string(), wtxid.to_string()])?;
580        }
581        // One transaction includes all records, dependencies, progress and event.
582        // An I/O/constraint failure rolls it all back, including progress.
583        tx.execute("DELETE FROM dependencies WHERE block_hash=?1", [&hash])?;
584        peers::record_omissions(&tx, &batch)?;
585        for record in batch.records {
586            store_record(&tx, &hash, &record)?;
587        }
588        let unresolved = batch.dependencies.len() as u64;
589        for dependency in batch.dependencies {
590            tx.execute(
591                "INSERT INTO dependencies VALUES(?1,?2,?3,?4)",
592                params![
593                    hash,
594                    dependency.position,
595                    dependency.commit.to_string(),
596                    serde_json::to_vec(&dependency.proof)?
597                ],
598            )?;
599        }
600        tx.execute("INSERT INTO scans VALUES(?1,?2,?3) ON CONFLICT(block_hash) DO UPDATE SET rules=excluded.rules,unresolved=excluded.unresolved",
601            params![hash, SCAN_RULES_VERSION, sql_integer(unresolved)?])?;
602        tx.execute("INSERT INTO events(generation,kind,height,block_hash) SELECT generation,'scan',?1,?2 FROM state",
603            params![sql_integer(point.height)?, hash])?;
604        tx.commit()?;
605        Ok(())
606    }
607}
608
609fn migrate(db: &mut Connection, version: u32) -> Result<(), Error> {
610    if version >= 5 {
611        return Ok(());
612    }
613    let tx = db.transaction()?;
614    if version < 2 {
615        tx.execute_batch(objects::SCHEMA)?;
616    } else if version == 2 {
617        objects::migrate_v2(&tx)?;
618    }
619    if version < 4 {
620        tx.execute_batch(names::SCHEMA)?;
621    }
622    tx.execute_batch(peers::SCHEMA)?;
623    tx.commit()?;
624    Ok(())
625}
626
627/// One inventory page as stored: (block, position, txid, wtxid) rows.
628pub(crate) struct InventoryRows {
629    pub(crate) scanned_through: Option<u64>,
630    pub(crate) entries: Vec<(ChainPoint, u32, bitcoin::Txid, bitcoin::Wtxid)>,
631    pub(crate) more: bool,
632}
633
634fn generation(db: &Connection) -> Result<u64, Error> {
635    Ok(
636        db.query_row("SELECT generation FROM state WHERE singleton=1", [], |r| {
637            unsigned(r, 0)
638        })?,
639    )
640}
641
642fn point(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChainPoint> {
643    let hash: String = row.get(1)?;
644    Ok(ChainPoint {
645        height: unsigned(row, 0)?,
646        hash: hash.parse::<BlockHash>().map_err(|e| {
647            rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
648        })?,
649    })
650}
651
652fn sql_integer(value: u64) -> Result<i64, Error> {
653    i64::try_from(value).map_err(|_| Error::Invalid("integer exceeds archive range".into()))
654}
655
656fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
657    let value: i64 = row.get(column)?;
658    u64::try_from(value).map_err(|e| {
659        rusqlite::Error::FromSqlConversionFailure(
660            column,
661            rusqlite::types::Type::Integer,
662            Box::new(e),
663        )
664    })
665}
666
667fn store_record(db: &Connection, hash: &str, record: &crate::scan::Record) -> Result<(), Error> {
668    let wtxid = record.wtxid.to_string();
669    let commit_wtxid = record.commit.compute_wtxid().to_string();
670    db.execute(
671        "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
672        params![
673            wtxid,
674            record.verified.txid().to_string(),
675            record.proof.transaction
676        ],
677    )?;
678    db.execute(
679        "INSERT OR IGNORE INTO transactions VALUES(?1,?2,?3)",
680        params![
681            commit_wtxid,
682            record.commit.compute_txid().to_string(),
683            record.raw_commit
684        ],
685    )?;
686    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",
687        params![hash, record.position, wtxid, record.verified.author().to_string(), record.verified.record_bytes(), commit_wtxid, serde_json::to_vec(&record.proof)?])?;
688    Ok(())
689}