Skip to main content

code_system_graph_store_sqlite/
lib.rs

1//! `SQLite` persistence adapter for `Code System Graph` snapshots.
2//!
3//! Durable database files are supported on Unix and Windows because the store
4//! requires enforceable owner-only file permissions.
5
6mod access_lock;
7mod backup_restore;
8mod file_permissions;
9mod schema_contract;
10
11use std::collections::BTreeSet;
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use access_lock::StoreAccessLock;
18use backup_restore::{backup_connection, restore_database};
19use code_system_graph_model::{
20    ArtifactFingerprint, CheckoutId, Community, CommunityAlgorithm, CommunityConfig, CommunityId, CommunityMetrics, CommunitySnapshot, Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, ExtractorRun, LinkDecision, LinkStatus, NativePath, Node, NodeId, NodeKind, RepoFreshness, RepoFreshnessState, RepoId, RepositoryRecord, StoredExtractorBatch, WorkspaceId, WorkspaceRecord, contains_unsafe_metadata_characters, stable_id
21};
22pub use file_permissions::set_owner_only_file;
23use file_permissions::{
24    SQLITE_ARTIFACT_SUFFIXES, artifact_path, prepare_database_file, restrict_store_permissions
25};
26use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
27use schema_contract::validate_exact_schema;
28use sysinfo::{Pid, ProcessesToUpdate, System};
29use thiserror::Error;
30
31const INITIAL_SCHEMA: &str = include_str!("../migrations/0001_initial.sql");
32const LATEST_SCHEMA_VERSION: i64 = 1;
33
34/// Returns the newest on-disk schema version supported by this binary.
35#[must_use]
36pub const fn latest_schema_version() -> i64 {
37    LATEST_SCHEMA_VERSION
38}
39
40/// Source-free runtime capabilities observed from one open store connection.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct StoreDiagnostics {
43    /// Active `SQLite` journal mode.
44    pub journal_mode: String,
45    /// Whether foreign-key enforcement is active.
46    pub foreign_keys_enabled: bool,
47    /// Whether the managed FTS5 node index exists.
48    pub fts5_index_available: bool,
49}
50const COMMUNITY_ALGORITHM_JSON_MAX_BYTES: usize = 256;
51const COMMUNITY_CONFIG_JSON_MAX_BYTES: usize = 65_536;
52const COMMUNITY_METRICS_JSON_MAX_BYTES: usize = 65_536;
53const COMMUNITY_EXPLANATION_JSON_MAX_BYTES: usize = 1_048_576;
54const NODE_SEARCH_QUERY_MAX_BYTES: usize = 1_024;
55const MANUAL_LINK_ID_MAX_BYTES: usize = 2_048;
56const MANUAL_LINK_KIND_MAX_BYTES: usize = 256;
57const MANUAL_LINK_REASON_MAX_BYTES: usize = 4_096;
58const MANUAL_LINK_DECISION_JSON_MAX_BYTES: usize = 262_144;
59const PROVIDER_COMPONENT_MAX_BYTES: usize = 256;
60const PROVIDER_CAPABILITIES_JSON_MAX_BYTES: usize = 65_536;
61const PROVIDER_CAPABILITY_COUNT_MAX: usize = 256;
62const QUERY_CACHE_FINGERPRINT_MAX_BYTES: usize = 256;
63const QUERY_CACHE_RESULT_MAX_BYTES: usize = 1_048_576;
64
65/// Embedded `SQLite` store with transactional snapshot publication.
66pub struct SqliteStore {
67    connection: Connection,
68    _access_lock: Option<StoreAccessLock>,
69}
70
71/// Exclusive writer lock represented by a restrictive sidecar file.
72#[derive(Debug)]
73pub struct StoreLock {
74    path: PathBuf,
75    content: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79struct LockMetadata {
80    pid: u32,
81    process_started_at: u64,
82    token: String,
83}
84
85struct StoredWorkspaceIdentity {
86    id: String,
87    manifest_hash: String,
88    config_path: Option<NativePath>,
89}
90
91struct StoredRepositoryRow {
92    alias: String,
93    repo_id: String,
94    checkout_id: String,
95    path_encoding: String,
96    path_bytes: Vec<u8>,
97    path_display: String,
98    common_encoding: Option<String>,
99    common_bytes: Option<Vec<u8>>,
100    common_display: Option<String>,
101    normalized_remote: Option<String>,
102    head_commit: Option<String>,
103    is_linked_worktree: bool,
104    working_tree_dirty: bool,
105}
106
107/// Counts and identity of a published snapshot.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct StoredSnapshotSummary {
110    /// Stable snapshot identifier.
111    pub snapshot_id: String,
112    /// Number of graph nodes.
113    pub node_count: usize,
114    /// Number of graph edges.
115    pub edge_count: usize,
116    /// Number of evidence records.
117    pub evidence_count: usize,
118}
119
120/// One ranked full-text match from the current graph snapshot.
121#[derive(Debug, Clone, PartialEq)]
122pub struct StoredNodeSearchHit {
123    /// Matched graph node.
124    pub node: Node,
125    /// Native FTS5 `bm25` score; lower values are more relevant.
126    pub fts_rank: f64,
127}
128
129/// Whether a manual record creates a link or suppresses an automatically inferred link.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum ManualLinkDisposition {
132    /// Include the declared link in the snapshot.
133    Active,
134    /// Suppress an automatically inferred link with the same endpoints and kind.
135    Suppression,
136}
137
138impl ManualLinkDisposition {
139    fn as_str(self) -> &'static str {
140        match self {
141            Self::Active => "active",
142            Self::Suppression => "suppression",
143        }
144    }
145
146    fn from_stored(value: &str) -> Option<Self> {
147        match value {
148            "active" => Some(Self::Active),
149            "suppression" => Some(Self::Suppression),
150            _ => None,
151        }
152    }
153}
154
155/// Source-free manual link declaration retained with one immutable graph snapshot.
156#[derive(Debug, Clone, PartialEq)]
157pub struct ManualLinkRecord {
158    /// Stable identifier for this configured declaration.
159    pub id: String,
160    /// Snapshot that owns this historical record.
161    pub snapshot_id: String,
162    /// Existing source node in the same snapshot.
163    pub source_node_id: NodeId,
164    /// Existing target node in the same snapshot.
165    pub target_node_id: NodeId,
166    /// Normalized link kind used to match active or inferred links.
167    pub kind: String,
168    /// Whether the declaration creates or suppresses the link.
169    pub disposition: ManualLinkDisposition,
170    /// Required operator rationale without source excerpts or provider payloads.
171    pub reason: String,
172    /// Complete versioned linker decision retained for explainability.
173    pub decision: LinkDecision,
174    /// Version of the configuration format that produced this record.
175    pub config_version: u32,
176}
177
178/// Source-free capability discovery result for one provider version and repository.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ProviderCapabilityRecord {
181    /// Workspace containing the registered repository.
182    pub workspace_name: String,
183    /// Stable repository identifier.
184    pub repo_id: RepoId,
185    /// Provider implementation name.
186    pub provider: String,
187    /// Probed provider version.
188    pub provider_version: String,
189    /// Normalized capability names, without provider responses or source.
190    pub capabilities: Vec<String>,
191    /// Observation timestamp in Unix milliseconds.
192    pub observed_at_unix_ms: u64,
193}
194
195/// Bounded source-free cached result for one immutable snapshot and exact request fingerprint.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct QueryCacheRecord {
198    /// Workspace owning the immutable snapshot.
199    pub workspace_name: String,
200    /// Snapshot used to compute the result.
201    pub snapshot_id: String,
202    /// Stable fingerprint of the complete public query input.
203    pub input_fingerprint: String,
204    /// Versioned JSON result without source bodies.
205    pub result_summary_json: Vec<u8>,
206    /// Observation timestamp in Unix milliseconds.
207    pub stored_at_unix_ms: u64,
208    /// Optional expiry timestamp in Unix milliseconds.
209    pub expires_at_unix_ms: Option<u64>,
210}
211
212/// Immutable inputs published as one atomic snapshot transaction.
213#[derive(Debug, Clone, Copy)]
214pub struct SnapshotBatch<'a> {
215    /// Validated workspace registry.
216    pub workspace: &'a WorkspaceRecord,
217    /// Stable snapshot identifier.
218    pub snapshot_id: &'a str,
219    /// Complete graph node set.
220    pub nodes: &'a [Node],
221    /// Complete graph edge set.
222    pub edges: &'a [Edge],
223    /// Complete evidence set.
224    pub evidence: &'a [Evidence],
225    /// Current extractor-relevant artifact fingerprints.
226    pub fingerprints: &'a [ArtifactFingerprint],
227    /// Reusable source-owned extractor outputs.
228    pub extractor_batches: &'a [StoredExtractorBatch],
229    /// Extractor execution metrics.
230    pub extractor_runs: &'a [ExtractorRun],
231    /// Source-free manual link declarations for this exact snapshot.
232    pub manual_links: &'a [ManualLinkRecord],
233    /// Optional community analysis for this exact graph snapshot.
234    pub community_snapshot: Option<&'a CommunitySnapshot>,
235}
236
237/// Result of restoring a database backup.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct RestoreReport {
240    /// Backup used as the restore source.
241    pub source_path: PathBuf,
242    /// Safety backup of the replaced database, when it existed.
243    pub safety_backup_path: Option<PathBuf>,
244    /// Exact schema version restored.
245    pub schema_version: i64,
246}
247
248/// Compact persisted workspace registry entry.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct WorkspaceRegistrySummary {
251    /// Stable workspace identifier.
252    pub id: WorkspaceId,
253    /// User-facing workspace name.
254    pub name: String,
255    /// Current merged manifest fingerprint.
256    pub manifest_hash: String,
257    /// Canonical workspace manifest path.
258    pub config_path: Option<NativePath>,
259    /// Number of registered repository aliases.
260    pub repository_count: usize,
261}
262
263/// Error returned by the `SQLite` adapter.
264#[derive(Debug, Error)]
265pub enum StoreError {
266    /// `SQLite` operation failed.
267    #[error("SQLite operation failed: {0}")]
268    Sqlite(#[from] rusqlite::Error),
269    /// A domain tag could not be encoded or decoded.
270    #[error("stored domain value is invalid: {0}")]
271    Serialization(#[from] serde_json::Error),
272    /// No current snapshot exists for a workspace.
273    #[error("workspace `{0}` has no current snapshot")]
274    CurrentSnapshotMissing(String),
275    /// Persisted workspace registry is absent or incomplete.
276    #[error("workspace registry `{0}` is missing required identity data")]
277    RegistryIncomplete(String),
278    /// Filesystem operation failed.
279    #[error("filesystem operation failed for `{path}`: {source}")]
280    Io {
281        /// Path involved in the failed operation.
282        path: PathBuf,
283        /// Underlying operating-system error.
284        source: std::io::Error,
285    },
286    /// A database does not match the only schema supported by this binary.
287    #[error("database does not match the current schema")]
288    InvalidSchema,
289    /// Another writer owns a non-stale lock.
290    #[error("store writer lock is already held at `{0}`")]
291    LockHeld(PathBuf),
292    /// Integer cannot be represented by the target domain type.
293    #[error("stored integer `{field}` is outside the supported range: {value}")]
294    IntegerOutOfRange {
295        /// Field being converted.
296        field: &'static str,
297        /// Invalid signed value.
298        value: i128,
299    },
300    /// Backup failed validation or aliases the destination.
301    #[error("invalid backup `{path}`: {reason}")]
302    InvalidBackup {
303        /// Backup path.
304        path: PathBuf,
305        /// Actionable validation reason.
306        reason: String,
307    },
308    /// An operation failed and its partial database artifacts could not be removed.
309    #[error("operation failed ({operation}); cleanup also failed ({cleanup})")]
310    OperationCleanupFailed {
311        /// Error that interrupted the operation.
312        operation: Box<StoreError>,
313        /// Error that prevented cleanup of partial artifacts.
314        cleanup: Box<StoreError>,
315    },
316    /// Bounded FTS query is empty or has an invalid limit.
317    #[error("invalid node search query: {0}")]
318    InvalidSearchQuery(String),
319    /// A graph snapshot violates identity, reference, or safe-metadata invariants.
320    #[error("graph snapshot is invalid: {0}")]
321    InvalidGraphSnapshot(String),
322    /// A requested immutable graph snapshot does not exist.
323    #[error("graph snapshot `{0}` does not exist")]
324    SnapshotMissing(String),
325    /// A requested graph snapshot has no persisted community analysis.
326    #[error("graph snapshot `{0}` has no community analysis")]
327    CommunitySnapshotMissing(String),
328    /// Community analysis targets a different graph snapshot.
329    #[error(
330        "community snapshot `{community_snapshot_id}` does not match graph snapshot \
331         `{batch_snapshot_id}`"
332    )]
333    CommunitySnapshotMismatch {
334        /// Graph snapshot being published.
335        batch_snapshot_id: String,
336        /// Graph snapshot named by the community analysis.
337        community_snapshot_id: String,
338    },
339    /// A community identifier occurs more than once in one analysis.
340    #[error("community `{community_id}` is duplicated in snapshot `{snapshot_id}`")]
341    DuplicateCommunityId {
342        /// Graph snapshot being validated.
343        snapshot_id: String,
344        /// Repeated community identifier.
345        community_id: String,
346    },
347    /// A node occurs more than once in one community membership list.
348    #[error(
349        "node `{node_id}` is duplicated in community `{community_id}` for snapshot `{snapshot_id}`"
350    )]
351    DuplicateCommunityMembership {
352        /// Graph snapshot being validated.
353        snapshot_id: String,
354        /// Community containing the repeated member.
355        community_id: String,
356        /// Repeated member node.
357        node_id: String,
358    },
359    /// A community membership references a node absent from its graph snapshot.
360    #[error(
361        "community `{community_id}` references missing node `{node_id}` in snapshot `{snapshot_id}`"
362    )]
363    CommunityMembershipNodeMissing {
364        /// Graph snapshot being validated.
365        snapshot_id: String,
366        /// Community containing the invalid member.
367        community_id: String,
368        /// Missing graph node.
369        node_id: String,
370    },
371    /// A variable JSON payload exceeds its schema bound.
372    #[error("community JSON field `{field}` is {actual_bytes} bytes; maximum is {max_bytes}")]
373    CommunityJsonTooLarge {
374        /// Logical JSON field.
375        field: &'static str,
376        /// Encoded UTF-8 byte length.
377        actual_bytes: usize,
378        /// Maximum accepted byte length.
379        max_bytes: usize,
380    },
381    /// Persisted rows cannot be decoded into a valid domain value.
382    #[error("stored {entity} is malformed: {reason}")]
383    MalformedStoredData {
384        /// Stored entity being decoded.
385        entity: String,
386        /// Specific invariant or decoding failure.
387        reason: String,
388    },
389    /// A store-local persistence record violates safety, size, or reference invariants.
390    #[error("invalid persistence record: {0}")]
391    InvalidPersistenceRecord(String),
392}
393
394impl StoreLock {
395    /// Acquires an exclusive writer lock and recovers an expired lock once.
396    ///
397    /// The sidecar path is the database path with `.lock` appended to its extension. On Unix it
398    /// is created with mode `0600`. Dropping the returned guard removes only a lock whose token
399    /// still matches this owner.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`StoreError::LockHeld`] for an active lock or [`StoreError::Io`] when lock
404    /// metadata cannot be created, inspected, or replaced.
405    pub fn acquire(database_path: &Path, stale_after: Duration) -> Result<Self, StoreError> {
406        let path = lock_path(database_path);
407        let now = SystemTime::now()
408            .duration_since(UNIX_EPOCH)
409            .map_err(|source| StoreError::Io {
410                path: path.clone(),
411                source: std::io::Error::other(source),
412            })?;
413        let token = stable_id(
414            "lock",
415            &format!(
416                "{}:{}:{}",
417                std::process::id(),
418                now.as_secs(),
419                now.subsec_nanos()
420            ),
421        );
422        let content = LockMetadata {
423            pid: std::process::id(),
424            process_started_at: process_start_time(std::process::id()).unwrap_or_default(),
425            token,
426        }
427        .encode();
428        for attempt in 0..2 {
429            match create_lock_file(&path) {
430                Ok(mut file) => {
431                    file.write_all(content.as_bytes())
432                        .and_then(|()| file.sync_all())
433                        .map_err(|source| StoreError::Io {
434                            path: path.clone(),
435                            source,
436                        })?;
437                    return Ok(Self { path, content });
438                }
439                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
440                    if attempt == 0 && lock_is_stale(&path, stale_after)? {
441                        fs::remove_file(&path).map_err(|source| StoreError::Io {
442                            path: path.clone(),
443                            source,
444                        })?;
445                        continue;
446                    }
447                    return Err(StoreError::LockHeld(path));
448                }
449                Err(source) => {
450                    return Err(StoreError::Io {
451                        path: path.clone(),
452                        source,
453                    });
454                }
455            }
456        }
457        Err(StoreError::LockHeld(path))
458    }
459}
460
461impl Drop for StoreLock {
462    fn drop(&mut self) {
463        let owned = fs::read_to_string(&self.path).is_ok_and(|content| content == self.content);
464        if owned {
465            let _result = fs::remove_file(&self.path);
466        }
467    }
468}
469
470impl SqliteStore {
471    /// Returns whether [`Self::restore_from`] can replace an existing on-disk database.
472    #[must_use]
473    pub const fn supports_inplace_restore() -> bool {
474        cfg!(any(target_os = "linux", target_os = "macos", windows))
475    }
476
477    /// Opens an exact 1.0.0 store or initializes a new empty database.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`StoreError`] if `SQLite` cannot open, initialize, or validate the database.
482    pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
483        let path = path.as_ref();
484        let access_lock = StoreAccessLock::shared(path)?;
485        let connection = open_exact(access_lock.database_path())?;
486        Ok(Self {
487            connection,
488            _access_lock: Some(access_lock),
489        })
490    }
491
492    /// Creates a validated online backup of the exact 1.0.0 schema.
493    ///
494    /// A source on read-only media is treated as immutable only when no `SQLite` sidecars exist.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`StoreError`] when the source is invalid or the destination already exists.
499    pub fn backup_file(database_path: &Path, destination: &Path) -> Result<(), StoreError> {
500        let access_lock = StoreAccessLock::shared(database_path)?;
501        backup_restore::backup_file(access_lock.database_path(), destination)
502    }
503
504    /// Restores a validated backup with the exact 1.0.0 schema.
505    ///
506    /// The existing destination is first preserved as a non-overwriting safety backup.
507    /// Restore is refused while another [`SqliteStore`] has the destination open.
508    /// In-place replacement is supported on Linux, macOS, and Windows.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`StoreError`] if validation, locking, safety backup, or restore fails.
513    pub fn restore_from(
514        database_path: &Path,
515        backup_path: &Path,
516    ) -> Result<RestoreReport, StoreError> {
517        if database_path.exists() && !Self::supports_inplace_restore() {
518            return Err(StoreError::Io {
519                path: database_path.to_path_buf(),
520                source: std::io::Error::new(
521                    std::io::ErrorKind::Unsupported,
522                    "safe in-place restore is unsupported on this platform; \
523                     back up the database, remove it, then restore or reinitialize",
524                ),
525            });
526        }
527        let access_lock = StoreAccessLock::exclusive(database_path)?;
528        restore_database(
529            access_lock.database_path(),
530            backup_path,
531            || {},
532            |_| {},
533            |_| Ok(()),
534            || Ok(()),
535        )
536    }
537
538    /// Opens an existing store without writes or implicit schema changes.
539    ///
540    /// A database on read-only media is treated as immutable only when no `SQLite` sidecars exist.
541    ///
542    /// # Errors
543    ///
544    /// Returns [`StoreError`] if the database is absent, corrupt, or not the exact 1.0.0 schema.
545    pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, StoreError> {
546        let path = path.as_ref();
547        let access_lock = StoreAccessLock::shared(path)?;
548        let connection = open_read_only_connection(access_lock.database_path(), |connection| {
549            connection.pragma_update(None, "foreign_keys", true)?;
550            connection.busy_timeout(Duration::from_secs(5))?;
551            validate_exact_schema(connection)
552        })?;
553        Ok(Self {
554            connection,
555            _access_lock: Some(access_lock),
556        })
557    }
558
559    /// Creates an in-memory store for isolated tests and ephemeral operations.
560    ///
561    /// # Errors
562    ///
563    /// Returns [`StoreError`] if `SQLite` cannot configure or initialize the database.
564    pub fn in_memory() -> Result<Self, StoreError> {
565        Self::from_connection(Connection::open_in_memory()?)
566    }
567
568    fn from_connection(mut connection: Connection) -> Result<Self, StoreError> {
569        configure_connection(&connection)?;
570        if database_is_empty(&connection)? {
571            initialize_empty_schema(&mut connection)?;
572        }
573        validate_exact_schema(&connection)?;
574        Ok(Self {
575            connection,
576            _access_lock: None,
577        })
578    }
579
580    /// Returns the exact initial schema version recorded by this store.
581    ///
582    /// # Errors
583    ///
584    /// Returns [`StoreError`] when schema metadata cannot be read.
585    pub fn schema_version(&self) -> Result<i64, StoreError> {
586        schema_version(&self.connection)
587    }
588
589    /// Returns the opaque identity that binds disposable operational state to this database.
590    ///
591    /// # Errors
592    ///
593    /// Returns [`StoreError`] when exact schema metadata cannot be read.
594    pub fn database_instance_id(&self) -> Result<String, StoreError> {
595        database_instance_id(&self.connection)
596    }
597
598    /// Atomically replaces repository registrations for one workspace.
599    ///
600    /// # Errors
601    ///
602    /// Returns [`StoreError`] on serialization, constraint, or transaction failure.
603    pub fn save_workspace_registry(
604        &mut self,
605        workspace: &WorkspaceRecord,
606    ) -> Result<(), StoreError> {
607        let transaction = self.connection.transaction()?;
608        upsert_registry(&transaction, workspace)?;
609        transaction.commit()?;
610        Ok(())
611    }
612
613    /// Returns whether a workspace name is already persisted.
614    ///
615    /// # Errors
616    ///
617    /// Returns [`StoreError`] when the registry cannot be queried.
618    pub fn workspace_exists(&self, workspace: &str) -> Result<bool, StoreError> {
619        Ok(self.connection.query_row(
620            "SELECT EXISTS(SELECT 1 FROM workspaces WHERE name = ?1)",
621            [workspace],
622            |row| row.get(0),
623        )?)
624    }
625
626    /// Removes one workspace and garbage-collects unreferenced repository records.
627    ///
628    /// # Errors
629    ///
630    /// Returns [`StoreError`] when the transactional removal fails.
631    pub fn remove_workspace(&mut self, workspace: &str) -> Result<bool, StoreError> {
632        let transaction = self.connection.transaction()?;
633        let removed = transaction.execute("DELETE FROM workspaces WHERE name = ?1", [workspace])?;
634        transaction.execute(
635            "DELETE FROM repository_checkouts
636             WHERE NOT EXISTS (
637                SELECT 1 FROM workspace_repositories wr
638                WHERE wr.checkout_id = repository_checkouts.id
639             )",
640            [],
641        )?;
642        transaction.execute(
643            "DELETE FROM repositories
644             WHERE NOT EXISTS (
645                SELECT 1 FROM workspace_repositories wr
646                WHERE wr.repo_id = repositories.id
647             )",
648            [],
649        )?;
650        transaction.commit()?;
651        Ok(removed > 0)
652    }
653
654    /// Lists persisted workspaces in deterministic name order.
655    ///
656    /// # Errors
657    ///
658    /// Returns [`StoreError`] when registry rows or counts cannot be read.
659    pub fn list_workspaces(&self) -> Result<Vec<WorkspaceRegistrySummary>, StoreError> {
660        let mut statement = self.connection.prepare(
661            "SELECT
662                w.id, w.name, w.manifest_hash, w.config_path_encoding, w.config_path,
663                w.config_path_display, COUNT(wr.alias)
664             FROM workspaces w
665             LEFT JOIN workspace_repositories wr ON wr.workspace_name = w.name
666             WHERE w.id IS NOT NULL
667             GROUP BY
668                w.id, w.name, w.manifest_hash, w.config_path_encoding, w.config_path,
669                w.config_path_display
670             ORDER BY w.name",
671        )?;
672        let rows = statement
673            .query_map([], |row| {
674                Ok((
675                    row.get::<_, String>(0)?,
676                    row.get::<_, String>(1)?,
677                    row.get::<_, String>(2)?,
678                    row.get::<_, Option<String>>(3)?,
679                    row.get::<_, Option<Vec<u8>>>(4)?,
680                    row.get::<_, Option<String>>(5)?,
681                    row.get::<_, i64>(6)?,
682                ))
683            })?
684            .collect::<Result<Vec<_>, _>>()?;
685        rows.into_iter()
686            .map(
687                |(id, name, manifest_hash, encoding, bytes, display, repository_count)| {
688                    let config_path = match (encoding, bytes, display) {
689                        (Some(encoding), Some(bytes), Some(display)) => Some(NativePath {
690                            encoding: serde_json::from_str(&encoding)?,
691                            bytes,
692                            display,
693                        }),
694                        _ => None,
695                    };
696                    Ok(WorkspaceRegistrySummary {
697                        id: WorkspaceId::new(id),
698                        name,
699                        manifest_hash,
700                        config_path,
701                        repository_count: count_to_usize(
702                            "workspace_repositories",
703                            repository_count,
704                        )?,
705                    })
706                },
707            )
708            .collect()
709    }
710
711    /// Loads a workspace and its repository registrations in alias order.
712    ///
713    /// # Errors
714    ///
715    /// Returns [`StoreError::RegistryIncomplete`] when required identity data is missing, or
716    /// another [`StoreError`] when stored values are invalid.
717    pub fn load_workspace_registry(&self, workspace: &str) -> Result<WorkspaceRecord, StoreError> {
718        let identity = load_workspace_identity(&self.connection, workspace)?;
719        let repositories = load_workspace_repositories(&self.connection, workspace)?;
720        Ok(WorkspaceRecord {
721            id: WorkspaceId::new(identity.id),
722            name: workspace.to_owned(),
723            manifest_hash: identity.manifest_hash,
724            config_path: identity.config_path,
725            repositories,
726        })
727    }
728
729    /// Creates a consistent online backup at a new destination path.
730    ///
731    /// # Errors
732    ///
733    /// Returns [`StoreError`] when the destination already exists or the backup cannot complete.
734    pub fn backup_to(&self, destination: &Path) -> Result<(), StoreError> {
735        backup_connection(&self.connection, destination)
736    }
737
738    /// Loads per-repository freshness from the current workspace snapshot.
739    ///
740    /// # Errors
741    ///
742    /// Returns [`StoreError`] when no current snapshot exists or stored freshness is invalid.
743    pub fn load_current_freshness(
744        &self,
745        workspace: &str,
746    ) -> Result<Vec<RepoFreshness>, StoreError> {
747        let snapshot_id = self.current_snapshot_id(workspace)?;
748        let mut statement = self.connection.prepare(
749            "SELECT repo_id, checkout_id, head_commit, manifest_hash, state, reason
750             FROM repository_snapshot_freshness
751             WHERE snapshot_id = ?1
752             ORDER BY repo_id, checkout_id",
753        )?;
754        let rows = statement
755            .query_map([snapshot_id], |row| {
756                Ok((
757                    row.get::<_, String>(0)?,
758                    row.get::<_, String>(1)?,
759                    row.get::<_, Option<String>>(2)?,
760                    row.get::<_, String>(3)?,
761                    row.get::<_, String>(4)?,
762                    row.get::<_, Option<String>>(5)?,
763                ))
764            })?
765            .collect::<Result<Vec<_>, _>>()?;
766        rows.into_iter()
767            .map(
768                |(repo_id, checkout_id, head_commit, manifest_hash, state, reason)| {
769                    Ok(RepoFreshness {
770                        repo_id: RepoId::new(repo_id),
771                        checkout_id: CheckoutId::new(checkout_id),
772                        head_commit,
773                        manifest_hash,
774                        state: serde_json::from_str::<RepoFreshnessState>(&state)?,
775                        reason,
776                    })
777                },
778            )
779            .collect()
780    }
781
782    /// Loads extractor-relevant fingerprints from the current snapshot.
783    ///
784    /// # Errors
785    ///
786    /// Returns [`StoreError`] when no current snapshot exists or stored paths are invalid.
787    pub fn load_current_artifact_fingerprints(
788        &self,
789        workspace: &str,
790    ) -> Result<Vec<ArtifactFingerprint>, StoreError> {
791        let snapshot_id = self.current_snapshot_id(workspace)?;
792        let mut statement = self.connection.prepare(
793            "SELECT
794                repo_id, checkout_id, path_encoding, relative_path, path_display,
795                extractor, content_hash, size_bytes
796             FROM artifact_fingerprints
797             WHERE snapshot_id = ?1
798             ORDER BY repo_id, checkout_id, path_encoding, relative_path, extractor",
799        )?;
800        let rows = statement
801            .query_map([snapshot_id], |row| {
802                Ok((
803                    row.get::<_, String>(0)?,
804                    row.get::<_, String>(1)?,
805                    row.get::<_, String>(2)?,
806                    row.get::<_, Vec<u8>>(3)?,
807                    row.get::<_, String>(4)?,
808                    row.get::<_, String>(5)?,
809                    row.get::<_, String>(6)?,
810                    row.get::<_, i64>(7)?,
811                ))
812            })?
813            .collect::<Result<Vec<_>, _>>()?;
814        rows.into_iter()
815            .map(
816                |(
817                    repo_id,
818                    checkout_id,
819                    encoding,
820                    bytes,
821                    display,
822                    extractor,
823                    content_hash,
824                    stored_size_bytes,
825                )| {
826                    Ok(ArtifactFingerprint {
827                        repo_id: RepoId::new(repo_id),
828                        checkout_id: CheckoutId::new(checkout_id),
829                        path: NativePath {
830                            encoding: serde_json::from_str(&encoding)?,
831                            bytes,
832                            display,
833                        },
834                        extractor,
835                        content_hash,
836                        size_bytes: u64::try_from(stored_size_bytes).map_err(|_| {
837                            StoreError::IntegerOutOfRange {
838                                field: "artifact_fingerprints.size_bytes",
839                                value: i128::from(stored_size_bytes),
840                            }
841                        })?,
842                    })
843                },
844            )
845            .collect()
846    }
847
848    /// Loads reusable source-owned extractor outputs from the current snapshot.
849    ///
850    /// # Errors
851    ///
852    /// Returns [`StoreError`] when no current snapshot exists or stored values are invalid.
853    pub fn load_current_extractor_batches(
854        &self,
855        workspace: &str,
856    ) -> Result<Vec<StoredExtractorBatch>, StoreError> {
857        self.load_current_extractor_batches_with_limit(workspace, i64::MAX as u64)
858    }
859
860    /// Loads current extractor batches whose payload fits within `maximum_payload_bytes`.
861    ///
862    /// Oversized rows are omitted before `SQLite` copies their BLOB into the process. Callers can
863    /// consequently treat them as cache misses and recompute them under the active policy.
864    ///
865    /// # Errors
866    ///
867    /// Returns [`StoreError`] when no current snapshot exists or stored values are invalid.
868    pub fn load_current_extractor_batches_with_limit(
869        &self,
870        workspace: &str,
871        maximum_payload_bytes: u64,
872    ) -> Result<Vec<StoredExtractorBatch>, StoreError> {
873        let snapshot_id = self.current_snapshot_id(workspace)?;
874        let maximum_payload_bytes = i64::try_from(maximum_payload_bytes).unwrap_or(i64::MAX);
875        let mut statement = self.connection.prepare(
876            "SELECT
877                repo_id, checkout_id, path_encoding, relative_path, path_display,
878                extractor, content_hash, size_bytes, extractor_version, budget_fingerprint,
879                source_was_lossy, output_count, payload
880             FROM extractor_batches
881             WHERE snapshot_id = ?1 AND length(payload) <= ?2
882             ORDER BY repo_id, checkout_id, path_encoding, relative_path, extractor",
883        )?;
884        let rows = statement
885            .query_map(params![snapshot_id, maximum_payload_bytes], |row| {
886                Ok((
887                    row.get::<_, String>(0)?,
888                    row.get::<_, String>(1)?,
889                    row.get::<_, String>(2)?,
890                    row.get::<_, Vec<u8>>(3)?,
891                    row.get::<_, String>(4)?,
892                    row.get::<_, String>(5)?,
893                    row.get::<_, String>(6)?,
894                    row.get::<_, i64>(7)?,
895                    row.get::<_, String>(8)?,
896                    row.get::<_, String>(9)?,
897                    row.get::<_, bool>(10)?,
898                    row.get::<_, i64>(11)?,
899                    row.get::<_, Vec<u8>>(12)?,
900                ))
901            })?
902            .collect::<Result<Vec<_>, _>>()?;
903        rows.into_iter()
904            .map(
905                |(
906                    repo_id,
907                    checkout_id,
908                    encoding,
909                    bytes,
910                    display,
911                    extractor,
912                    content_hash,
913                    stored_size_bytes,
914                    extractor_version,
915                    budget_fingerprint,
916                    source_was_lossy,
917                    stored_output_count,
918                    payload,
919                )| {
920                    Ok(StoredExtractorBatch {
921                        source: ArtifactFingerprint {
922                            repo_id: RepoId::new(repo_id),
923                            checkout_id: CheckoutId::new(checkout_id),
924                            path: NativePath {
925                                encoding: serde_json::from_str(&encoding)?,
926                                bytes,
927                                display,
928                            },
929                            extractor,
930                            content_hash,
931                            size_bytes: stored_metric_to_u64(
932                                "extractor_batches.size_bytes",
933                                stored_size_bytes,
934                            )?,
935                        },
936                        extractor_version,
937                        budget_fingerprint,
938                        source_was_lossy,
939                        output_count: stored_metric_to_u64(
940                            "extractor_batches.output_count",
941                            stored_output_count,
942                        )?,
943                        payload,
944                    })
945                },
946            )
947            .collect()
948    }
949
950    /// Loads extractor run metrics from the current snapshot.
951    ///
952    /// # Errors
953    ///
954    /// Returns [`StoreError`] when no current snapshot exists or run metrics are invalid.
955    pub fn load_current_extractor_runs(
956        &self,
957        workspace: &str,
958    ) -> Result<Vec<ExtractorRun>, StoreError> {
959        let snapshot_id = self.current_snapshot_id(workspace)?;
960        let mut statement = self.connection.prepare(
961            "SELECT
962                id, repo_id, checkout_id, extractor, extractor_version, status,
963                discovered_files, parsed_files, skipped_files, elapsed_ms
964             FROM extractor_runs
965             WHERE snapshot_id = ?1
966             ORDER BY repo_id, checkout_id, extractor, id",
967        )?;
968        let rows = statement
969            .query_map([&snapshot_id], |row| {
970                Ok((
971                    row.get::<_, String>(0)?,
972                    row.get::<_, String>(1)?,
973                    row.get::<_, String>(2)?,
974                    row.get::<_, String>(3)?,
975                    row.get::<_, String>(4)?,
976                    row.get::<_, String>(5)?,
977                    row.get::<_, i64>(6)?,
978                    row.get::<_, i64>(7)?,
979                    row.get::<_, i64>(8)?,
980                    row.get::<_, i64>(9)?,
981                ))
982            })?
983            .collect::<Result<Vec<_>, _>>()?;
984        rows.into_iter()
985            .map(
986                |(
987                    id,
988                    repo_id,
989                    checkout_id,
990                    extractor,
991                    extractor_version,
992                    status,
993                    discovered_files,
994                    parsed_files,
995                    skipped_files,
996                    elapsed_ms,
997                )| {
998                    Ok(ExtractorRun {
999                        id,
1000                        snapshot_id: snapshot_id.clone(),
1001                        repo_id: RepoId::new(repo_id),
1002                        checkout_id: CheckoutId::new(checkout_id),
1003                        extractor,
1004                        extractor_version,
1005                        status: serde_json::from_str(&status)?,
1006                        discovered_files: stored_metric_to_u64(
1007                            "extractor_runs.discovered_files",
1008                            discovered_files,
1009                        )?,
1010                        parsed_files: stored_metric_to_u64(
1011                            "extractor_runs.parsed_files",
1012                            parsed_files,
1013                        )?,
1014                        skipped_files: stored_metric_to_u64(
1015                            "extractor_runs.skipped_files",
1016                            skipped_files,
1017                        )?,
1018                        elapsed_ms: stored_metric_to_u64("extractor_runs.elapsed_ms", elapsed_ms)?,
1019                    })
1020                },
1021            )
1022            .collect()
1023    }
1024
1025    /// Replaces manual link declarations for one existing snapshot in a transaction.
1026    ///
1027    /// Records for other snapshots are retained, preserving historical declarations. Passing an
1028    /// empty slice clears only the selected snapshot's declarations.
1029    ///
1030    /// # Errors
1031    ///
1032    /// Returns [`StoreError`] when the snapshot or endpoint nodes are absent, metadata is unsafe
1033    /// or oversized, records conflict, or the transaction fails.
1034    pub fn persist_manual_links(
1035        &mut self,
1036        snapshot_id: &str,
1037        records: &[ManualLinkRecord],
1038    ) -> Result<(), StoreError> {
1039        validate_manual_links(snapshot_id, records, None)?;
1040        self.require_snapshot(snapshot_id)?;
1041        let transaction = self.connection.transaction()?;
1042        for record in records {
1043            for (field, node_id) in [
1044                ("source", record.source_node_id.as_str()),
1045                ("target", record.target_node_id.as_str()),
1046            ] {
1047                let exists = transaction.query_row(
1048                    "SELECT EXISTS(
1049                        SELECT 1 FROM nodes WHERE snapshot_id = ?1 AND id = ?2
1050                     )",
1051                    params![snapshot_id, node_id],
1052                    |row| row.get::<_, bool>(0),
1053                )?;
1054                if !exists {
1055                    return Err(StoreError::InvalidPersistenceRecord(format!(
1056                        "manual link `{}` {field} node `{node_id}` is absent from snapshot \
1057                         `{snapshot_id}`",
1058                        record.id
1059                    )));
1060                }
1061            }
1062        }
1063        transaction.execute(
1064            "DELETE FROM manual_links WHERE snapshot_id = ?1",
1065            [snapshot_id],
1066        )?;
1067        insert_manual_links(&transaction, records)?;
1068        transaction.commit()?;
1069        Ok(())
1070    }
1071
1072    /// Loads manual link declarations for one immutable snapshot in stable identifier order.
1073    ///
1074    /// # Errors
1075    ///
1076    /// Returns [`StoreError::SnapshotMissing`] when the snapshot is absent or another
1077    /// [`StoreError`] when persisted rows are malformed.
1078    pub fn load_manual_links(
1079        &self,
1080        snapshot_id: &str,
1081    ) -> Result<Vec<ManualLinkRecord>, StoreError> {
1082        self.require_snapshot(snapshot_id)?;
1083        let mut statement = self.connection.prepare(
1084            "SELECT id, source_node_id, target_node_id, kind, disposition, reason, decision_json,
1085                    config_version
1086             FROM manual_links
1087             WHERE snapshot_id = ?1
1088             ORDER BY id",
1089        )?;
1090        let rows = statement
1091            .query_map([snapshot_id], |row| {
1092                Ok((
1093                    row.get::<_, String>(0)?,
1094                    row.get::<_, String>(1)?,
1095                    row.get::<_, String>(2)?,
1096                    row.get::<_, String>(3)?,
1097                    row.get::<_, String>(4)?,
1098                    row.get::<_, String>(5)?,
1099                    row.get::<_, Vec<u8>>(6)?,
1100                    row.get::<_, i64>(7)?,
1101                ))
1102            })?
1103            .collect::<Result<Vec<_>, _>>()?;
1104        let records = rows
1105            .into_iter()
1106            .map(
1107                |(id, source, target, kind, disposition, reason, decision_json, config_version)| {
1108                    let disposition =
1109                        ManualLinkDisposition::from_stored(&disposition).ok_or_else(|| {
1110                            malformed_stored_data(
1111                                format!("manual link `{id}` in snapshot `{snapshot_id}`"),
1112                                format!("unknown disposition `{disposition}`"),
1113                            )
1114                        })?;
1115                    let config_version = u32::try_from(config_version).map_err(|_| {
1116                        StoreError::IntegerOutOfRange {
1117                            field: "manual_links.config_version",
1118                            value: i128::from(config_version),
1119                        }
1120                    })?;
1121                    let decision = serde_json::from_slice(&decision_json).map_err(|error| {
1122                        malformed_stored_data(
1123                            format!("manual link `{id}` decision in snapshot `{snapshot_id}`"),
1124                            error.to_string(),
1125                        )
1126                    })?;
1127                    Ok(ManualLinkRecord {
1128                        id,
1129                        snapshot_id: snapshot_id.to_owned(),
1130                        source_node_id: NodeId::new(source),
1131                        target_node_id: NodeId::new(target),
1132                        kind,
1133                        disposition,
1134                        reason,
1135                        decision,
1136                        config_version,
1137                    })
1138                },
1139            )
1140            .collect::<Result<Vec<_>, StoreError>>()?;
1141        validate_manual_links(snapshot_id, &records, None).map_err(|error| {
1142            malformed_stored_data(
1143                format!("manual links in snapshot `{snapshot_id}`"),
1144                error.to_string(),
1145            )
1146        })?;
1147        Ok(records)
1148    }
1149
1150    /// Inserts or replaces one source-free provider capability report.
1151    ///
1152    /// The repository must currently be registered in the selected workspace. Replacement is
1153    /// scoped by workspace, repository, provider, and provider version.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns [`StoreError`] for invalid metadata, an unregistered repository, serialization,
1158    /// integer conversion, or constraint failures.
1159    pub fn upsert_provider_capabilities(
1160        &mut self,
1161        record: &ProviderCapabilityRecord,
1162    ) -> Result<(), StoreError> {
1163        validate_provider_capability_record(record)?;
1164        let registered = self.connection.query_row(
1165            "SELECT EXISTS(
1166                SELECT 1 FROM workspace_repositories
1167                WHERE workspace_name = ?1 AND repo_id = ?2
1168             )",
1169            params![record.workspace_name, record.repo_id.as_str()],
1170            |row| row.get::<_, bool>(0),
1171        )?;
1172        if !registered {
1173            return Err(StoreError::InvalidPersistenceRecord(format!(
1174                "repository `{}` is not registered in workspace `{}`",
1175                record.repo_id.as_str(),
1176                record.workspace_name
1177            )));
1178        }
1179        let capabilities_json = serde_json::to_string(&record.capabilities)?;
1180        self.connection.execute(
1181            "INSERT INTO provider_capabilities(
1182                workspace_name, repo_id, provider, provider_version, capabilities_json,
1183                observed_at_unix_ms
1184             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1185             ON CONFLICT(workspace_name, repo_id, provider, provider_version) DO UPDATE SET
1186                capabilities_json = excluded.capabilities_json,
1187                observed_at_unix_ms = excluded.observed_at_unix_ms",
1188            params![
1189                record.workspace_name,
1190                record.repo_id.as_str(),
1191                record.provider,
1192                record.provider_version,
1193                capabilities_json,
1194                metric_to_i64(
1195                    "provider_capabilities.observed_at_unix_ms",
1196                    record.observed_at_unix_ms
1197                )?,
1198            ],
1199        )?;
1200        Ok(())
1201    }
1202
1203    /// Loads one source-free provider capability report.
1204    ///
1205    /// # Errors
1206    ///
1207    /// Returns [`StoreError`] when lookup metadata is unsafe or oversized, or persisted data is
1208    /// malformed. An unknown scope returns `Ok(None)`.
1209    pub fn load_provider_capabilities(
1210        &self,
1211        workspace: &str,
1212        repo_id: &RepoId,
1213        provider: &str,
1214        provider_version: &str,
1215    ) -> Result<Option<ProviderCapabilityRecord>, StoreError> {
1216        validate_safe_metadata("workspace name", workspace, 1, MANUAL_LINK_ID_MAX_BYTES)?;
1217        validate_safe_metadata(
1218            "repository identifier",
1219            repo_id.as_str(),
1220            1,
1221            MANUAL_LINK_ID_MAX_BYTES,
1222        )?;
1223        validate_safe_metadata("provider name", provider, 1, PROVIDER_COMPONENT_MAX_BYTES)?;
1224        validate_safe_metadata(
1225            "provider version",
1226            provider_version,
1227            1,
1228            PROVIDER_COMPONENT_MAX_BYTES,
1229        )?;
1230        let stored = self
1231            .connection
1232            .query_row(
1233                "SELECT capabilities_json, observed_at_unix_ms
1234                 FROM provider_capabilities
1235                 WHERE workspace_name = ?1
1236                   AND repo_id = ?2
1237                   AND provider = ?3
1238                   AND provider_version = ?4",
1239                params![workspace, repo_id.as_str(), provider, provider_version],
1240                |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1241            )
1242            .optional()?;
1243        let Some((capabilities_json, observed_at_unix_ms)) = stored else {
1244            return Ok(None);
1245        };
1246        let capabilities = serde_json::from_str(&capabilities_json).map_err(|error| {
1247            malformed_stored_data(
1248                format!(
1249                    "provider capabilities `{workspace}/{}/{provider}/{provider_version}`",
1250                    repo_id.as_str()
1251                ),
1252                error.to_string(),
1253            )
1254        })?;
1255        let record = ProviderCapabilityRecord {
1256            workspace_name: workspace.to_owned(),
1257            repo_id: repo_id.clone(),
1258            provider: provider.to_owned(),
1259            provider_version: provider_version.to_owned(),
1260            capabilities,
1261            observed_at_unix_ms: stored_metric_to_u64(
1262                "provider_capabilities.observed_at_unix_ms",
1263                observed_at_unix_ms,
1264            )?,
1265        };
1266        validate_provider_capability_record(&record).map_err(|error| {
1267            malformed_stored_data("provider capability record", error.to_string())
1268        })?;
1269        Ok(Some(record))
1270    }
1271
1272    /// Inserts or replaces one bounded query result for an immutable snapshot.
1273    ///
1274    /// # Errors
1275    ///
1276    /// Returns [`StoreError`] when metadata, JSON, timestamps, snapshot ownership, or size bounds
1277    /// are invalid.
1278    pub fn put_query_cache(&mut self, record: &QueryCacheRecord) -> Result<(), StoreError> {
1279        validate_query_cache_record(record)?;
1280        let snapshot_exists = self.connection.query_row(
1281            "SELECT EXISTS(
1282                SELECT 1 FROM repo_snapshots
1283                WHERE id = ?1 AND workspace_name = ?2
1284             )",
1285            params![record.snapshot_id, record.workspace_name],
1286            |row| row.get::<_, bool>(0),
1287        )?;
1288        if !snapshot_exists {
1289            return Err(StoreError::InvalidPersistenceRecord(format!(
1290                "query cache snapshot `{}` is absent from workspace `{}`",
1291                record.snapshot_id, record.workspace_name
1292            )));
1293        }
1294        self.connection.execute(
1295            "INSERT INTO query_cache(
1296                workspace_name, snapshot_id, input_fingerprint, result_summary_json,
1297                stored_at_unix_ms, expires_at_unix_ms
1298             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1299             ON CONFLICT(workspace_name, snapshot_id, input_fingerprint) DO UPDATE SET
1300                result_summary_json = excluded.result_summary_json,
1301                stored_at_unix_ms = excluded.stored_at_unix_ms,
1302                expires_at_unix_ms = excluded.expires_at_unix_ms",
1303            params![
1304                record.workspace_name,
1305                record.snapshot_id,
1306                record.input_fingerprint,
1307                record.result_summary_json,
1308                metric_to_i64("query_cache.stored_at_unix_ms", record.stored_at_unix_ms)?,
1309                record
1310                    .expires_at_unix_ms
1311                    .map(|value| metric_to_i64("query_cache.expires_at_unix_ms", value))
1312                    .transpose()?,
1313            ],
1314        )?;
1315        Ok(())
1316    }
1317
1318    /// Loads one unexpired cached query result for an exact immutable-snapshot fingerprint.
1319    ///
1320    /// # Errors
1321    ///
1322    /// Returns [`StoreError`] when lookup metadata or persisted cache data is malformed.
1323    pub fn load_query_cache(
1324        &self,
1325        workspace: &str,
1326        snapshot_id: &str,
1327        input_fingerprint: &str,
1328        now_unix_ms: u64,
1329    ) -> Result<Option<QueryCacheRecord>, StoreError> {
1330        validate_safe_metadata("workspace name", workspace, 1, MANUAL_LINK_ID_MAX_BYTES)?;
1331        validate_safe_metadata(
1332            "snapshot identifier",
1333            snapshot_id,
1334            1,
1335            MANUAL_LINK_ID_MAX_BYTES,
1336        )?;
1337        validate_safe_metadata(
1338            "query cache input fingerprint",
1339            input_fingerprint,
1340            1,
1341            QUERY_CACHE_FINGERPRINT_MAX_BYTES,
1342        )?;
1343        let stored = self
1344            .connection
1345            .query_row(
1346                "SELECT result_summary_json, stored_at_unix_ms, expires_at_unix_ms
1347                 FROM query_cache
1348                 WHERE workspace_name = ?1
1349                   AND snapshot_id = ?2
1350                   AND input_fingerprint = ?3
1351                   AND (expires_at_unix_ms IS NULL OR expires_at_unix_ms >= ?4)",
1352                params![
1353                    workspace,
1354                    snapshot_id,
1355                    input_fingerprint,
1356                    metric_to_i64("query_cache.now_unix_ms", now_unix_ms)?
1357                ],
1358                |row| {
1359                    Ok((
1360                        row.get::<_, Vec<u8>>(0)?,
1361                        row.get::<_, i64>(1)?,
1362                        row.get::<_, Option<i64>>(2)?,
1363                    ))
1364                },
1365            )
1366            .optional()?;
1367        let Some((result_summary_json, stored_at_unix_ms, expires_at_unix_ms)) = stored else {
1368            return Ok(None);
1369        };
1370        let record = QueryCacheRecord {
1371            workspace_name: workspace.to_owned(),
1372            snapshot_id: snapshot_id.to_owned(),
1373            input_fingerprint: input_fingerprint.to_owned(),
1374            result_summary_json,
1375            stored_at_unix_ms: stored_metric_to_u64(
1376                "query_cache.stored_at_unix_ms",
1377                stored_at_unix_ms,
1378            )?,
1379            expires_at_unix_ms: expires_at_unix_ms
1380                .map(|value| stored_metric_to_u64("query_cache.expires_at_unix_ms", value))
1381                .transpose()?,
1382        };
1383        validate_query_cache_record(&record)
1384            .map_err(|error| malformed_stored_data("query cache record", error.to_string()))?;
1385        Ok(Some(record))
1386    }
1387
1388    /// Deletes every cached query result for one workspace.
1389    ///
1390    /// # Errors
1391    ///
1392    /// Returns [`StoreError`] when the delete cannot be completed.
1393    pub fn clear_query_cache(&mut self, workspace: &str) -> Result<usize, StoreError> {
1394        Ok(self.connection.execute(
1395            "DELETE FROM query_cache WHERE workspace_name = ?1",
1396            [workspace],
1397        )?)
1398    }
1399
1400    /// Loads counts and identity for the current snapshot.
1401    ///
1402    /// # Errors
1403    ///
1404    /// Returns [`StoreError`] when no current snapshot exists or counts cannot be read.
1405    pub fn current_snapshot_summary(
1406        &self,
1407        workspace: &str,
1408    ) -> Result<StoredSnapshotSummary, StoreError> {
1409        let snapshot_id = self.current_snapshot_id(workspace)?;
1410        let (node_count, edge_count, evidence_count) = self.connection.query_row(
1411            "SELECT
1412                (SELECT COUNT(*) FROM nodes WHERE snapshot_id = ?1),
1413                (SELECT COUNT(*) FROM edges WHERE snapshot_id = ?1),
1414                (SELECT COUNT(*) FROM evidence WHERE snapshot_id = ?1)",
1415            [&snapshot_id],
1416            |row| {
1417                Ok((
1418                    row.get::<_, i64>(0)?,
1419                    row.get::<_, i64>(1)?,
1420                    row.get::<_, i64>(2)?,
1421                ))
1422            },
1423        )?;
1424        Ok(StoredSnapshotSummary {
1425            snapshot_id,
1426            node_count: count_to_usize("nodes", node_count)?,
1427            edge_count: count_to_usize("edges", edge_count)?,
1428            evidence_count: count_to_usize("evidence", evidence_count)?,
1429        })
1430    }
1431
1432    /// Atomically replaces the current workspace snapshot.
1433    ///
1434    /// The candidate is invisible as current until all nodes, evidence, edges, and evidence
1435    /// references are committed successfully.
1436    ///
1437    /// # Errors
1438    ///
1439    /// Returns [`StoreError`] on community validation, constraint, serialization, or transaction
1440    /// failure.
1441    pub fn publish_snapshot(&mut self, batch: SnapshotBatch<'_>) -> Result<(), StoreError> {
1442        self.publish_snapshot_with_progress(batch, |_| {})
1443    }
1444
1445    /// Publishes one atomic snapshot while reporting each completed durable row insertion.
1446    ///
1447    /// # Errors
1448    ///
1449    /// Returns [`StoreError`] under the same conditions as [`Self::publish_snapshot`].
1450    pub fn publish_snapshot_with_progress<F>(
1451        &mut self,
1452        batch: SnapshotBatch<'_>,
1453        mut progress: F,
1454    ) -> Result<(), StoreError>
1455    where
1456        F: FnMut(u64),
1457    {
1458        let SnapshotBatch {
1459            workspace,
1460            snapshot_id,
1461            nodes,
1462            edges,
1463            evidence,
1464            fingerprints,
1465            extractor_batches,
1466            extractor_runs,
1467            manual_links,
1468            community_snapshot,
1469        } = batch;
1470        validate_graph_snapshot(nodes, edges, evidence)?;
1471        validate_artifact_fingerprints(fingerprints)?;
1472        validate_manual_links(snapshot_id, manual_links, Some(nodes))?;
1473        validate_community_snapshot(snapshot_id, nodes, community_snapshot)?;
1474        let transaction = self.connection.transaction()?;
1475        upsert_registry(&transaction, workspace)?;
1476        transaction.execute(
1477            "DELETE FROM query_cache WHERE workspace_name = ?1",
1478            [&workspace.name],
1479        )?;
1480        transaction.execute(
1481            "UPDATE repo_snapshots SET is_current = 0 WHERE workspace_name = ?1",
1482            [&workspace.name],
1483        )?;
1484        transaction.execute("DELETE FROM repo_snapshots WHERE id = ?1", [snapshot_id])?;
1485        transaction.execute(
1486            "INSERT INTO repo_snapshots(id, workspace_name, is_current) VALUES (?1, ?2, 0)",
1487            params![snapshot_id, workspace.name],
1488        )?;
1489
1490        insert_graph(
1491            &transaction,
1492            snapshot_id,
1493            nodes,
1494            edges,
1495            evidence,
1496            &mut progress,
1497        )?;
1498        insert_manual_links(&transaction, manual_links)?;
1499        progress(u64::try_from(manual_links.len()).unwrap_or(u64::MAX));
1500        insert_incremental_state(
1501            &transaction,
1502            snapshot_id,
1503            fingerprints,
1504            extractor_runs,
1505            &mut progress,
1506        )?;
1507        insert_extractor_batches(&transaction, snapshot_id, extractor_batches, &mut progress)?;
1508        insert_freshness(&transaction, snapshot_id, workspace)?;
1509        if let Some(community_snapshot) = community_snapshot {
1510            insert_community_snapshot(&transaction, community_snapshot, &mut progress)?;
1511        }
1512        transaction.execute(
1513            "UPDATE repo_snapshots SET is_current = 1 WHERE id = ?1",
1514            [snapshot_id],
1515        )?;
1516        transaction.commit()?;
1517        Ok(())
1518    }
1519
1520    /// Loads nodes and edges from the current workspace snapshot.
1521    ///
1522    /// # Errors
1523    ///
1524    /// Returns [`StoreError`] when no current snapshot exists or stored data is invalid.
1525    pub fn load_current_graph(
1526        &self,
1527        workspace: &str,
1528    ) -> Result<(Vec<Node>, Vec<Edge>), StoreError> {
1529        let snapshot_id = self.current_snapshot_id(workspace)?;
1530        self.load_graph_snapshot(&snapshot_id)
1531    }
1532
1533    /// Loads nodes and edges from one immutable graph snapshot.
1534    ///
1535    /// Rows are returned in stable identifier order.
1536    ///
1537    /// # Errors
1538    ///
1539    /// Returns [`StoreError::SnapshotMissing`] when the snapshot does not exist, or another
1540    /// [`StoreError`] when stored rows cannot be decoded.
1541    pub fn load_graph_snapshot(
1542        &self,
1543        snapshot_id: &str,
1544    ) -> Result<(Vec<Node>, Vec<Edge>), StoreError> {
1545        self.require_snapshot(snapshot_id)?;
1546        let mut node_statement = self.connection.prepare(
1547            "SELECT id, kind, repo_id, stable_key, label
1548             FROM nodes WHERE snapshot_id = ?1 ORDER BY id",
1549        )?;
1550        let node_rows = node_statement
1551            .query_map([&snapshot_id], |row| {
1552                Ok((
1553                    row.get::<_, String>(0)?,
1554                    row.get::<_, String>(1)?,
1555                    row.get::<_, Option<String>>(2)?,
1556                    row.get::<_, String>(3)?,
1557                    row.get::<_, String>(4)?,
1558                ))
1559            })?
1560            .collect::<Result<Vec<_>, _>>()?;
1561        let nodes = node_rows
1562            .into_iter()
1563            .map(|(id, kind, repo_id, stable_key, label)| {
1564                Ok(Node {
1565                    id: NodeId::new(id),
1566                    kind: serde_json::from_str::<NodeKind>(&kind)?,
1567                    repo_id: repo_id.map(RepoId::new),
1568                    stable_key,
1569                    label,
1570                })
1571            })
1572            .collect::<Result<Vec<_>, StoreError>>()?;
1573
1574        let mut edge_statement = self.connection.prepare(
1575            "SELECT id, source_node_id, target_node_id, kind, confidence, epistemic_status
1576             FROM edges WHERE snapshot_id = ?1 ORDER BY id",
1577        )?;
1578        let edge_rows = edge_statement
1579            .query_map([&snapshot_id], |row| {
1580                Ok((
1581                    row.get::<_, String>(0)?,
1582                    row.get::<_, String>(1)?,
1583                    row.get::<_, String>(2)?,
1584                    row.get::<_, String>(3)?,
1585                    row.get::<_, f32>(4)?,
1586                    row.get::<_, String>(5)?,
1587                ))
1588            })?
1589            .collect::<Result<Vec<_>, _>>()?;
1590        let mut edges = Vec::with_capacity(edge_rows.len());
1591        for (id, source, target, kind, confidence, status) in edge_rows {
1592            let mut evidence_statement = self.connection.prepare(
1593                "SELECT evidence_id FROM edge_evidence
1594                 WHERE snapshot_id = ?1 AND edge_id = ?2 ORDER BY evidence_id",
1595            )?;
1596            let evidence = evidence_statement
1597                .query_map(params![snapshot_id, id], |row| row.get::<_, String>(0))?
1598                .map(|result| result.map(code_system_graph_model::EvidenceId::new))
1599                .collect::<Result<Vec<_>, _>>()?;
1600            edges.push(Edge {
1601                id: EdgeId::new(id),
1602                source: NodeId::new(source),
1603                target: NodeId::new(target),
1604                kind: serde_json::from_str::<EdgeKind>(&kind)?,
1605                confidence,
1606                status: serde_json::from_str::<EpistemicStatus>(&status)?,
1607                evidence,
1608            });
1609        }
1610        Ok((nodes, edges))
1611    }
1612
1613    /// Loads evidence metadata for the current workspace snapshot.
1614    ///
1615    /// # Errors
1616    ///
1617    /// Returns [`StoreError`] when the current snapshot is absent or stored tags are malformed.
1618    pub fn load_current_evidence(&self, workspace: &str) -> Result<Vec<Evidence>, StoreError> {
1619        let snapshot_id = self.current_snapshot_id(workspace)?;
1620        self.load_evidence_snapshot(&snapshot_id)
1621    }
1622
1623    /// Loads evidence metadata for one immutable graph snapshot.
1624    ///
1625    /// Line ranges, extractor versions, commits, and notes predate their normalized storage
1626    /// columns and therefore remain absent in this projection.
1627    ///
1628    /// # Errors
1629    ///
1630    /// Returns [`StoreError::SnapshotMissing`] when the snapshot is absent or
1631    /// [`StoreError::MalformedStoredData`] when a stored provenance tag is invalid.
1632    pub fn load_evidence_snapshot(&self, snapshot_id: &str) -> Result<Vec<Evidence>, StoreError> {
1633        self.require_snapshot(snapshot_id)?;
1634        let mut statement = self.connection.prepare(
1635            "SELECT id, repo_id, file_path, start_line, end_line, extractor, extractor_version,
1636                    provenance, confidence, observed_at_commit, content_hash
1637             FROM evidence WHERE snapshot_id = ?1 ORDER BY id",
1638        )?;
1639        let rows = statement
1640            .query_map([snapshot_id], |row| {
1641                Ok((
1642                    row.get::<_, String>(0)?,
1643                    row.get::<_, Option<String>>(1)?,
1644                    row.get::<_, Option<String>>(2)?,
1645                    row.get::<_, Option<u32>>(3)?,
1646                    row.get::<_, Option<u32>>(4)?,
1647                    row.get::<_, String>(5)?,
1648                    row.get::<_, String>(6)?,
1649                    row.get::<_, String>(7)?,
1650                    row.get::<_, f32>(8)?,
1651                    row.get::<_, Option<String>>(9)?,
1652                    row.get::<_, Option<String>>(10)?,
1653                ))
1654            })?
1655            .collect::<Result<Vec<_>, _>>()?;
1656        rows.into_iter()
1657            .map(
1658                |(
1659                    id,
1660                    repo_id,
1661                    file_path,
1662                    start_line,
1663                    end_line,
1664                    extractor,
1665                    extractor_version,
1666                    provenance,
1667                    confidence,
1668                    observed_at_commit,
1669                    content_hash,
1670                )| {
1671                    let provenance = serde_json::from_str(&provenance).map_err(|error| {
1672                        malformed_stored_data(format!("evidence `{id}`"), error.to_string())
1673                    })?;
1674                    Ok(Evidence {
1675                        id: EvidenceId::new(id),
1676                        repo_id: repo_id.map(RepoId::new),
1677                        file_path,
1678                        start_line,
1679                        end_line,
1680                        extractor,
1681                        extractor_version,
1682                        provenance,
1683                        confidence,
1684                        observed_at_commit,
1685                        content_hash,
1686                        note: None,
1687                    })
1688                },
1689            )
1690            .collect()
1691    }
1692
1693    /// Searches current snapshot node labels and stable keys using bounded FTS5.
1694    ///
1695    /// The input is always treated as a quoted phrase rather than raw FTS syntax.
1696    ///
1697    /// # Errors
1698    ///
1699    /// Returns [`StoreError`] for empty input, limits outside `1..=100`, invalid stored tags, or
1700    /// `SQLite` failures.
1701    pub fn search_current_nodes(
1702        &self,
1703        workspace: &str,
1704        query: &str,
1705        limit: usize,
1706    ) -> Result<Vec<Node>, StoreError> {
1707        if !(1..=100).contains(&limit) {
1708            return Err(StoreError::InvalidSearchQuery(
1709                "limit must be between 1 and 100".to_owned(),
1710            ));
1711        }
1712        self.search_current_nodes_ranked(workspace, query, limit)
1713            .map(|hits| hits.into_iter().map(|hit| hit.node).collect())
1714    }
1715
1716    /// Searches current nodes and includes the native FTS5 relevance score.
1717    ///
1718    /// The non-empty query is limited to 1,024 UTF-8 bytes, treated as a quoted phrase, and bound
1719    /// as a parameter. The result limit is `1..=500`. Hits are ordered by ascending `bm25` score
1720    /// and then stable node identifier.
1721    ///
1722    /// # Errors
1723    ///
1724    /// Returns [`StoreError::InvalidSearchQuery`] for empty input or limits outside `1..=500`.
1725    /// Invalid stored node tags or non-finite ranks are rejected as malformed data.
1726    pub fn search_current_nodes_ranked(
1727        &self,
1728        workspace: &str,
1729        query: &str,
1730        limit: usize,
1731    ) -> Result<Vec<StoredNodeSearchHit>, StoreError> {
1732        let query = query.trim();
1733        if query.is_empty() {
1734            return Err(StoreError::InvalidSearchQuery(
1735                "query must not be empty".to_owned(),
1736            ));
1737        }
1738        if query.len() > NODE_SEARCH_QUERY_MAX_BYTES {
1739            return Err(StoreError::InvalidSearchQuery(format!(
1740                "query must not exceed {NODE_SEARCH_QUERY_MAX_BYTES} UTF-8 bytes"
1741            )));
1742        }
1743        if !(1..=500).contains(&limit) {
1744            return Err(StoreError::InvalidSearchQuery(
1745                "limit must be between 1 and 500".to_owned(),
1746            ));
1747        }
1748        let phrase = format!("\"{}\"", query.replace('"', "\"\""));
1749        let limit = i64::try_from(limit).map_err(|_| StoreError::IntegerOutOfRange {
1750            field: "search.limit",
1751            value: i128::try_from(limit).unwrap_or(i128::MAX),
1752        })?;
1753        let mut statement = self.connection.prepare(
1754            "SELECT n.id, n.kind, n.repo_id, n.stable_key, n.label, bm25(nodes_fts)
1755             FROM nodes_fts
1756             JOIN repo_snapshots snapshot ON snapshot.id = nodes_fts.snapshot_id
1757             JOIN nodes n
1758               ON n.snapshot_id = nodes_fts.snapshot_id AND n.id = nodes_fts.node_id
1759             WHERE snapshot.workspace_name = ?1
1760               AND snapshot.is_current = 1
1761               AND nodes_fts MATCH ?2
1762             ORDER BY bm25(nodes_fts), n.id
1763             LIMIT ?3",
1764        )?;
1765        let rows = statement
1766            .query_map(params![workspace, phrase, limit], |row| {
1767                Ok((
1768                    row.get::<_, String>(0)?,
1769                    row.get::<_, String>(1)?,
1770                    row.get::<_, Option<String>>(2)?,
1771                    row.get::<_, String>(3)?,
1772                    row.get::<_, String>(4)?,
1773                    row.get::<_, f64>(5)?,
1774                ))
1775            })?
1776            .collect::<Result<Vec<_>, _>>()?;
1777        rows.into_iter()
1778            .map(|(id, kind, repo_id, stable_key, label, fts_rank)| {
1779                if !fts_rank.is_finite() {
1780                    return Err(malformed_stored_data(
1781                        format!("FTS hit `{id}`"),
1782                        "rank is not finite",
1783                    ));
1784                }
1785                let kind = serde_json::from_str(&kind).map_err(|error| {
1786                    malformed_stored_data(format!("node `{id}`"), error.to_string())
1787                })?;
1788                Ok(StoredNodeSearchHit {
1789                    node: Node {
1790                        id: NodeId::new(id),
1791                        kind,
1792                        repo_id: repo_id.map(RepoId::new),
1793                        stable_key,
1794                        label,
1795                    },
1796                    fts_rank,
1797                })
1798            })
1799            .collect()
1800    }
1801
1802    /// Loads the community analysis associated with the current workspace snapshot.
1803    ///
1804    /// # Errors
1805    ///
1806    /// Returns [`StoreError`] when no current graph snapshot or community analysis exists, or when
1807    /// persisted rows are malformed.
1808    pub fn load_current_community_snapshot(
1809        &self,
1810        workspace: &str,
1811    ) -> Result<CommunitySnapshot, StoreError> {
1812        let snapshot_id = self.current_snapshot_id(workspace)?;
1813        self.load_community_snapshot(&snapshot_id)
1814    }
1815
1816    /// Loads one immutable community analysis in deterministic identifier order.
1817    ///
1818    /// # Errors
1819    ///
1820    /// Returns [`StoreError::SnapshotMissing`] when the graph snapshot does not exist,
1821    /// [`StoreError::CommunitySnapshotMissing`] when it has no analysis, or
1822    /// [`StoreError::MalformedStoredData`] when persisted rows violate domain invariants.
1823    pub fn load_community_snapshot(
1824        &self,
1825        snapshot_id: &str,
1826    ) -> Result<CommunitySnapshot, StoreError> {
1827        self.require_snapshot(snapshot_id)?;
1828        load_community_snapshot(&self.connection, snapshot_id)
1829    }
1830
1831    /// Loads an immutable community analysis only when it belongs to the requested workspace.
1832    ///
1833    /// # Errors
1834    ///
1835    /// Returns [`StoreError::SnapshotMissing`] when the snapshot is absent or belongs to another
1836    /// workspace, preserving workspace isolation at read-only delivery boundaries.
1837    pub fn load_workspace_community_snapshot(
1838        &self,
1839        workspace: &str,
1840        snapshot_id: &str,
1841    ) -> Result<CommunitySnapshot, StoreError> {
1842        let belongs = self
1843            .connection
1844            .query_row(
1845                "SELECT 1 FROM repo_snapshots WHERE id = ?1 AND workspace_name = ?2",
1846                params![snapshot_id, workspace],
1847                |_| Ok(()),
1848            )
1849            .optional()?;
1850        belongs.ok_or_else(|| StoreError::SnapshotMissing(snapshot_id.to_owned()))?;
1851        load_community_snapshot(&self.connection, snapshot_id)
1852    }
1853
1854    fn current_snapshot_id(&self, workspace: &str) -> Result<String, StoreError> {
1855        self.connection
1856            .query_row(
1857                "SELECT id FROM repo_snapshots
1858                 WHERE workspace_name = ?1 AND is_current = 1",
1859                [workspace],
1860                |row| row.get::<_, String>(0),
1861            )
1862            .optional()?
1863            .ok_or_else(|| StoreError::CurrentSnapshotMissing(workspace.to_owned()))
1864    }
1865
1866    fn require_snapshot(&self, snapshot_id: &str) -> Result<(), StoreError> {
1867        let exists = self
1868            .connection
1869            .query_row(
1870                "SELECT 1 FROM repo_snapshots WHERE id = ?1",
1871                [snapshot_id],
1872                |_| Ok(()),
1873            )
1874            .optional()?;
1875        exists.ok_or_else(|| StoreError::SnapshotMissing(snapshot_id.to_owned()))
1876    }
1877
1878    /// Runs `SQLite`'s quick integrity check.
1879    ///
1880    /// # Errors
1881    ///
1882    /// Returns [`StoreError`] if `SQLite` cannot perform the check.
1883    pub fn integrity_check(&self) -> Result<bool, StoreError> {
1884        let result = self
1885            .connection
1886            .query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?;
1887        if result != "ok" || self.schema_version()? != LATEST_SCHEMA_VERSION {
1888            return Ok(false);
1889        }
1890        let foreign_key_violation = self
1891            .connection
1892            .query_row(
1893                "SELECT 1 FROM pragma_foreign_key_check LIMIT 1",
1894                [],
1895                |row| row.get::<_, i64>(0),
1896            )
1897            .optional()?;
1898        Ok(foreign_key_violation.is_none())
1899    }
1900
1901    /// Observes `SQLite` safety and indexing capabilities without modifying the store.
1902    ///
1903    /// # Errors
1904    ///
1905    /// Returns [`StoreError`] when `SQLite` cannot provide the requested diagnostics.
1906    pub fn diagnostics(&self) -> Result<StoreDiagnostics, StoreError> {
1907        let journal_mode = self
1908            .connection
1909            .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))?;
1910        let foreign_keys_enabled = self
1911            .connection
1912            .query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?
1913            == 1;
1914        let fts5_index_available = self
1915            .connection
1916            .query_row(
1917                "SELECT 1 FROM sqlite_schema
1918                 WHERE type = 'table' AND name = 'nodes_fts'",
1919                [],
1920                |row| row.get::<_, i64>(0),
1921            )
1922            .optional()?
1923            .is_some();
1924        Ok(StoreDiagnostics {
1925            journal_mode,
1926            foreign_keys_enabled,
1927            fts5_index_available,
1928        })
1929    }
1930}
1931
1932fn load_workspace_identity(
1933    connection: &Connection,
1934    workspace: &str,
1935) -> Result<StoredWorkspaceIdentity, StoreError> {
1936    let (id, manifest_hash, encoding, bytes, display) = connection
1937        .query_row(
1938            "SELECT
1939                id, manifest_hash, config_path_encoding, config_path, config_path_display
1940             FROM workspaces WHERE name = ?1",
1941            [workspace],
1942            |row| {
1943                Ok((
1944                    row.get::<_, Option<String>>(0)?,
1945                    row.get::<_, String>(1)?,
1946                    row.get::<_, Option<String>>(2)?,
1947                    row.get::<_, Option<Vec<u8>>>(3)?,
1948                    row.get::<_, Option<String>>(4)?,
1949                ))
1950            },
1951        )
1952        .optional()?
1953        .ok_or_else(|| StoreError::RegistryIncomplete(workspace.to_owned()))?;
1954    Ok(StoredWorkspaceIdentity {
1955        id: id.ok_or_else(|| StoreError::RegistryIncomplete(workspace.to_owned()))?,
1956        manifest_hash,
1957        config_path: decode_optional_path(encoding, bytes, display)?,
1958    })
1959}
1960
1961fn load_workspace_repositories(
1962    connection: &Connection,
1963    workspace: &str,
1964) -> Result<Vec<RepositoryRecord>, StoreError> {
1965    let mut statement = connection.prepare(
1966        "SELECT
1967            wr.alias, r.id, c.id, c.path_encoding, c.canonical_path, c.path_display,
1968            r.git_common_path_encoding, r.git_common_path, r.git_common_path_display,
1969            r.normalized_remote, c.head_commit, c.is_linked_worktree, c.working_tree_dirty
1970         FROM workspace_repositories wr
1971         JOIN repositories r ON r.id = wr.repo_id
1972         JOIN repository_checkouts c ON c.id = wr.checkout_id
1973         WHERE wr.workspace_name = ?1
1974         ORDER BY wr.alias",
1975    )?;
1976    let rows = statement
1977        .query_map([workspace], |row| {
1978            Ok(StoredRepositoryRow {
1979                alias: row.get(0)?,
1980                repo_id: row.get(1)?,
1981                checkout_id: row.get(2)?,
1982                path_encoding: row.get(3)?,
1983                path_bytes: row.get(4)?,
1984                path_display: row.get(5)?,
1985                common_encoding: row.get(6)?,
1986                common_bytes: row.get(7)?,
1987                common_display: row.get(8)?,
1988                normalized_remote: row.get(9)?,
1989                head_commit: row.get(10)?,
1990                is_linked_worktree: row.get::<_, i64>(11)? != 0,
1991                working_tree_dirty: row.get::<_, i64>(12)? != 0,
1992            })
1993        })?
1994        .collect::<Result<Vec<_>, _>>()?;
1995    rows.into_iter().map(repository_from_row).collect()
1996}
1997
1998fn repository_from_row(row: StoredRepositoryRow) -> Result<RepositoryRecord, StoreError> {
1999    Ok(RepositoryRecord {
2000        id: RepoId::new(row.repo_id),
2001        checkout_id: CheckoutId::new(row.checkout_id),
2002        alias: row.alias,
2003        canonical_path: NativePath {
2004            encoding: serde_json::from_str(&row.path_encoding)?,
2005            bytes: row.path_bytes,
2006            display: row.path_display,
2007        },
2008        git_common_dir: decode_optional_path(
2009            row.common_encoding,
2010            row.common_bytes,
2011            row.common_display,
2012        )?,
2013        normalized_remote: row.normalized_remote,
2014        head_commit: row.head_commit,
2015        is_linked_worktree: row.is_linked_worktree,
2016        working_tree_dirty: row.working_tree_dirty,
2017    })
2018}
2019
2020fn decode_optional_path(
2021    encoding: Option<String>,
2022    bytes: Option<Vec<u8>>,
2023    display: Option<String>,
2024) -> Result<Option<NativePath>, StoreError> {
2025    match (encoding, bytes, display) {
2026        (Some(encoding), Some(bytes), Some(display)) => Ok(Some(NativePath {
2027            encoding: serde_json::from_str(&encoding)?,
2028            bytes,
2029            display,
2030        })),
2031        _ => Ok(None),
2032    }
2033}
2034
2035fn open_exact(path: &Path) -> Result<Connection, StoreError> {
2036    prepare_database_file(path)?;
2037    let mut connection = Connection::open_with_flags(
2038        path,
2039        OpenFlags::SQLITE_OPEN_READ_WRITE
2040            | OpenFlags::SQLITE_OPEN_CREATE
2041            | OpenFlags::SQLITE_OPEN_NO_MUTEX
2042            | OpenFlags::SQLITE_OPEN_NOFOLLOW,
2043    )?;
2044    configure_connection(&connection)?;
2045    if database_is_empty(&connection)? {
2046        initialize_empty_schema(&mut connection)?;
2047    }
2048    restrict_store_permissions(path)?;
2049    validate_exact_schema(&connection)?;
2050    Ok(connection)
2051}
2052
2053fn open_read_only_connection(
2054    path: &Path,
2055    configure: impl Fn(&Connection) -> Result<(), StoreError>,
2056) -> Result<Connection, StoreError> {
2057    let path = fs::canonicalize(path).map_err(|source| StoreError::Io {
2058        path: path.to_path_buf(),
2059        source,
2060    })?;
2061    let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
2062        | OpenFlags::SQLITE_OPEN_NO_MUTEX
2063        | OpenFlags::SQLITE_OPEN_NOFOLLOW;
2064    let connection = Connection::open_with_flags(&path, flags)?;
2065    match configure(&connection) {
2066        Ok(()) => Ok(connection),
2067        Err(error) if is_read_only_directory_error(&error) => {
2068            drop(connection);
2069            if immutable_fallback_has_sidecars(&path)? {
2070                return Err(error);
2071            }
2072            let uri = immutable_database_uri(&path)?;
2073            let connection = Connection::open_with_flags(uri, flags | OpenFlags::SQLITE_OPEN_URI)?;
2074            configure(&connection)?;
2075            Ok(connection)
2076        }
2077        Err(error) => Err(error),
2078    }
2079}
2080
2081fn is_read_only_directory_error(error: &StoreError) -> bool {
2082    matches!(
2083        error,
2084        StoreError::Sqlite(rusqlite::Error::SqliteFailure(details, _))
2085            if details.extended_code == rusqlite::ffi::SQLITE_READONLY_DIRECTORY
2086    )
2087}
2088
2089fn immutable_fallback_has_sidecars(path: &Path) -> Result<bool, StoreError> {
2090    for suffix in &SQLITE_ARTIFACT_SUFFIXES[1..] {
2091        let sidecar = artifact_path(path, suffix);
2092        match fs::symlink_metadata(&sidecar) {
2093            Ok(_) => return Ok(true),
2094            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
2095            Err(source) => {
2096                return Err(StoreError::Io {
2097                    path: sidecar,
2098                    source,
2099                });
2100            }
2101        }
2102    }
2103    Ok(false)
2104}
2105
2106#[expect(
2107    clippy::unnecessary_wraps,
2108    reason = "non-Unix database paths can be non-Unicode"
2109)]
2110fn immutable_database_uri(path: &Path) -> Result<String, StoreError> {
2111    const HEX: &[u8; 16] = b"0123456789ABCDEF";
2112
2113    #[cfg(unix)]
2114    let bytes = {
2115        use std::os::unix::ffi::OsStrExt;
2116
2117        path.as_os_str().as_bytes()
2118    };
2119    #[cfg(not(unix))]
2120    let bytes = path
2121        .to_str()
2122        .ok_or_else(|| StoreError::Io {
2123            path: path.to_path_buf(),
2124            source: std::io::Error::new(
2125                std::io::ErrorKind::InvalidInput,
2126                "database path is not valid Unicode",
2127            ),
2128        })?
2129        .as_bytes();
2130
2131    let mut uri = String::with_capacity(bytes.len() + 24);
2132    uri.push_str("file:");
2133    for &byte in bytes {
2134        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
2135            uri.push(char::from(byte));
2136        } else {
2137            uri.push('%');
2138            uri.push(char::from(HEX[usize::from(byte >> 4)]));
2139            uri.push(char::from(HEX[usize::from(byte & 0x0f)]));
2140        }
2141    }
2142    uri.push_str("?immutable=1");
2143    Ok(uri)
2144}
2145
2146fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
2147    connection.pragma_update(None, "foreign_keys", true)?;
2148    connection.pragma_update(None, "journal_mode", "WAL")?;
2149    connection.busy_timeout(Duration::from_secs(5))?;
2150    Ok(())
2151}
2152
2153fn database_is_empty(connection: &Connection) -> Result<bool, StoreError> {
2154    let tables = connection.query_row(
2155        "SELECT COUNT(*) FROM sqlite_master
2156         WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
2157        [],
2158        |row| row.get::<_, i64>(0),
2159    )?;
2160    Ok(tables == 0)
2161}
2162
2163fn validate_artifact_fingerprints(fingerprints: &[ArtifactFingerprint]) -> Result<(), StoreError> {
2164    const MAX_PATH_DISPLAY_BYTES: usize = 4 * 1024;
2165
2166    for fingerprint in fingerprints {
2167        validate_safe_metadata(
2168            "artifact path display",
2169            &fingerprint.path.display,
2170            1,
2171            MAX_PATH_DISPLAY_BYTES,
2172        )?;
2173        validate_safe_metadata("extractor", &fingerprint.extractor, 1, 256)?;
2174        validate_safe_metadata("artifact content hash", &fingerprint.content_hash, 1, 128)?;
2175    }
2176    Ok(())
2177}
2178
2179fn validate_graph_snapshot(
2180    nodes: &[Node],
2181    edges: &[Edge],
2182    evidence: &[Evidence],
2183) -> Result<(), StoreError> {
2184    const MAX_METADATA_BYTES: usize = 64 * 1024;
2185
2186    let validate_metadata = |field: &str, value: &str| {
2187        if value.len() > MAX_METADATA_BYTES {
2188            return Err(StoreError::InvalidGraphSnapshot(format!(
2189                "{field} exceeds {MAX_METADATA_BYTES} bytes"
2190            )));
2191        }
2192        if contains_unsafe_metadata_characters(value) {
2193            return Err(StoreError::InvalidGraphSnapshot(format!(
2194                "{field} contains unsafe control or bidirectional characters"
2195            )));
2196        }
2197        Ok(())
2198    };
2199
2200    let mut node_ids = BTreeSet::new();
2201    for node in nodes {
2202        if !node_ids.insert(node.id.as_str()) {
2203            return Err(StoreError::InvalidGraphSnapshot(format!(
2204                "duplicate node identifier `{}`",
2205                node.id.as_str()
2206            )));
2207        }
2208        validate_metadata("node identifier", node.id.as_str())?;
2209        validate_metadata("node stable key", &node.stable_key)?;
2210        validate_metadata("node label", &node.label)?;
2211    }
2212
2213    let mut evidence_ids = BTreeSet::new();
2214    for item in evidence {
2215        if !evidence_ids.insert(item.id.as_str()) {
2216            return Err(StoreError::InvalidGraphSnapshot(format!(
2217                "duplicate evidence identifier `{}`",
2218                item.id.as_str()
2219            )));
2220        }
2221        validate_metadata("evidence identifier", item.id.as_str())?;
2222        validate_metadata("evidence extractor", &item.extractor)?;
2223        validate_metadata("evidence extractor version", &item.extractor_version)?;
2224        for (field, value) in [
2225            ("evidence file path", item.file_path.as_deref()),
2226            (
2227                "evidence observed commit",
2228                item.observed_at_commit.as_deref(),
2229            ),
2230            ("evidence content hash", item.content_hash.as_deref()),
2231            ("evidence note", item.note.as_deref()),
2232        ] {
2233            if let Some(value) = value {
2234                validate_metadata(field, value)?;
2235            }
2236        }
2237        if !item.confidence.is_finite() || !(0.0..=1.0).contains(&item.confidence) {
2238            return Err(StoreError::InvalidGraphSnapshot(
2239                "evidence confidence must be finite and between zero and one".to_owned(),
2240            ));
2241        }
2242        if matches!((item.start_line, item.end_line), (Some(start), Some(end)) if start > end) {
2243            return Err(StoreError::InvalidGraphSnapshot(
2244                "evidence line range is reversed".to_owned(),
2245            ));
2246        }
2247    }
2248
2249    let mut edge_ids = BTreeSet::new();
2250    for edge in edges {
2251        if !edge_ids.insert(edge.id.as_str()) {
2252            return Err(StoreError::InvalidGraphSnapshot(format!(
2253                "duplicate edge identifier `{}`",
2254                edge.id.as_str()
2255            )));
2256        }
2257        validate_metadata("edge identifier", edge.id.as_str())?;
2258        if !node_ids.contains(edge.source.as_str()) || !node_ids.contains(edge.target.as_str()) {
2259            return Err(StoreError::InvalidGraphSnapshot(format!(
2260                "edge `{}` references a missing node",
2261                edge.id.as_str()
2262            )));
2263        }
2264        if !edge.confidence.is_finite() || !(0.0..=1.0).contains(&edge.confidence) {
2265            return Err(StoreError::InvalidGraphSnapshot(format!(
2266                "edge `{}` has invalid confidence",
2267                edge.id.as_str()
2268            )));
2269        }
2270        let mut edge_evidence = BTreeSet::new();
2271        for evidence_id in &edge.evidence {
2272            if !edge_evidence.insert(evidence_id.as_str()) {
2273                return Err(StoreError::InvalidGraphSnapshot(format!(
2274                    "edge `{}` repeats evidence `{}`",
2275                    edge.id.as_str(),
2276                    evidence_id.as_str()
2277                )));
2278            }
2279            if !evidence_ids.contains(evidence_id.as_str()) {
2280                return Err(StoreError::InvalidGraphSnapshot(format!(
2281                    "edge `{}` references missing evidence",
2282                    edge.id.as_str()
2283                )));
2284            }
2285        }
2286    }
2287
2288    Ok(())
2289}
2290
2291fn validate_community_snapshot(
2292    snapshot_id: &str,
2293    nodes: &[Node],
2294    community_snapshot: Option<&CommunitySnapshot>,
2295) -> Result<(), StoreError> {
2296    let Some(community_snapshot) = community_snapshot else {
2297        return Ok(());
2298    };
2299    if community_snapshot.snapshot_id != snapshot_id {
2300        return Err(StoreError::CommunitySnapshotMismatch {
2301            batch_snapshot_id: snapshot_id.to_owned(),
2302            community_snapshot_id: community_snapshot.snapshot_id.clone(),
2303        });
2304    }
2305    let node_ids = nodes
2306        .iter()
2307        .map(|node| node.id.as_str())
2308        .collect::<BTreeSet<_>>();
2309    let mut community_ids = BTreeSet::new();
2310    for community in &community_snapshot.communities {
2311        if !community_ids.insert(community.id.as_str()) {
2312            return Err(StoreError::DuplicateCommunityId {
2313                snapshot_id: snapshot_id.to_owned(),
2314                community_id: community.id.as_str().to_owned(),
2315            });
2316        }
2317        let mut member_ids = BTreeSet::new();
2318        for member in &community.members {
2319            if !member_ids.insert(member.as_str()) {
2320                return Err(StoreError::DuplicateCommunityMembership {
2321                    snapshot_id: snapshot_id.to_owned(),
2322                    community_id: community.id.as_str().to_owned(),
2323                    node_id: member.as_str().to_owned(),
2324                });
2325            }
2326            if !node_ids.contains(member.as_str()) {
2327                return Err(StoreError::CommunityMembershipNodeMissing {
2328                    snapshot_id: snapshot_id.to_owned(),
2329                    community_id: community.id.as_str().to_owned(),
2330                    node_id: member.as_str().to_owned(),
2331                });
2332            }
2333        }
2334    }
2335    Ok(())
2336}
2337
2338fn insert_community_snapshot<F>(
2339    transaction: &rusqlite::Transaction<'_>,
2340    snapshot: &CommunitySnapshot,
2341    progress: &mut F,
2342) -> Result<(), StoreError>
2343where
2344    F: FnMut(u64),
2345{
2346    let algorithm = serde_json::to_string(&snapshot.config.algorithm)?;
2347    ensure_community_json_bound(
2348        "community_snapshots.algorithm",
2349        &algorithm,
2350        COMMUNITY_ALGORITHM_JSON_MAX_BYTES,
2351    )?;
2352    let config_json = serde_json::to_string(&snapshot.config)?;
2353    ensure_community_json_bound(
2354        "community_snapshots.config_json",
2355        &config_json,
2356        COMMUNITY_CONFIG_JSON_MAX_BYTES,
2357    )?;
2358    transaction.execute(
2359        "INSERT INTO community_snapshots(snapshot_id, engine_version, algorithm, config_json)
2360         VALUES (?1, ?2, ?3, ?4)",
2361        params![
2362            snapshot.snapshot_id,
2363            snapshot.engine_version,
2364            algorithm,
2365            config_json
2366        ],
2367    )?;
2368    progress(1);
2369    for community in &snapshot.communities {
2370        insert_community(transaction, &snapshot.snapshot_id, community)?;
2371        progress(1);
2372    }
2373    Ok(())
2374}
2375
2376fn insert_community(
2377    transaction: &rusqlite::Transaction<'_>,
2378    snapshot_id: &str,
2379    community: &Community,
2380) -> Result<(), StoreError> {
2381    let metrics_json = serde_json::to_string(&community.metrics)?;
2382    ensure_community_json_bound(
2383        "communities.metrics_json",
2384        &metrics_json,
2385        COMMUNITY_METRICS_JSON_MAX_BYTES,
2386    )?;
2387    let explanation_json = serde_json::to_string(&serde_json::json!({
2388        "central_nodes": &community.central_nodes,
2389        "repositories": &community.repositories,
2390        "services": &community.services,
2391        "inbound_contracts": &community.inbound_contracts,
2392        "outbound_contracts": &community.outbound_contracts,
2393        "label_evidence": &community.label_evidence,
2394        "limitations": &community.limitations,
2395    }))?;
2396    ensure_community_json_bound(
2397        "communities.explanation_json",
2398        &explanation_json,
2399        COMMUNITY_EXPLANATION_JSON_MAX_BYTES,
2400    )?;
2401    transaction.execute(
2402        "INSERT INTO communities(snapshot_id, id, label, metrics_json, explanation_json)
2403         VALUES (?1, ?2, ?3, ?4, ?5)",
2404        params![
2405            snapshot_id,
2406            community.id.as_str(),
2407            community.label,
2408            metrics_json,
2409            explanation_json
2410        ],
2411    )?;
2412    for (member_order, member) in community.members.iter().enumerate() {
2413        let member_order =
2414            i64::try_from(member_order).map_err(|_| StoreError::IntegerOutOfRange {
2415                field: "community_memberships.member_order",
2416                value: i128::try_from(member_order).unwrap_or(i128::MAX),
2417            })?;
2418        transaction.execute(
2419            "INSERT INTO community_memberships(
2420                snapshot_id, community_id, node_id, member_order
2421             ) VALUES (?1, ?2, ?3, ?4)",
2422            params![
2423                snapshot_id,
2424                community.id.as_str(),
2425                member.as_str(),
2426                member_order
2427            ],
2428        )?;
2429    }
2430    Ok(())
2431}
2432
2433fn ensure_community_json_bound(
2434    field: &'static str,
2435    json: &str,
2436    max_bytes: usize,
2437) -> Result<(), StoreError> {
2438    let actual_bytes = json.len();
2439    if actual_bytes > max_bytes {
2440        return Err(StoreError::CommunityJsonTooLarge {
2441            field,
2442            actual_bytes,
2443            max_bytes,
2444        });
2445    }
2446    Ok(())
2447}
2448
2449fn load_community_snapshot(
2450    connection: &Connection,
2451    snapshot_id: &str,
2452) -> Result<CommunitySnapshot, StoreError> {
2453    let header = connection
2454        .query_row(
2455            "SELECT engine_version, algorithm, config_json
2456             FROM community_snapshots WHERE snapshot_id = ?1",
2457            [snapshot_id],
2458            |row| {
2459                Ok((
2460                    row.get::<_, String>(0)?,
2461                    row.get::<_, String>(1)?,
2462                    row.get::<_, String>(2)?,
2463                ))
2464            },
2465        )
2466        .optional()?
2467        .ok_or_else(|| StoreError::CommunitySnapshotMissing(snapshot_id.to_owned()))?;
2468    let (engine_version, algorithm_json, config_json) = header;
2469    ensure_community_json_bound(
2470        "community_snapshots.algorithm",
2471        &algorithm_json,
2472        COMMUNITY_ALGORITHM_JSON_MAX_BYTES,
2473    )?;
2474    ensure_community_json_bound(
2475        "community_snapshots.config_json",
2476        &config_json,
2477        COMMUNITY_CONFIG_JSON_MAX_BYTES,
2478    )?;
2479    let algorithm =
2480        serde_json::from_str::<CommunityAlgorithm>(&algorithm_json).map_err(|error| {
2481            malformed_stored_data(
2482                format!("community snapshot `{snapshot_id}` algorithm"),
2483                error.to_string(),
2484            )
2485        })?;
2486    let config = serde_json::from_str::<CommunityConfig>(&config_json).map_err(|error| {
2487        malformed_stored_data(
2488            format!("community snapshot `{snapshot_id}` config"),
2489            error.to_string(),
2490        )
2491    })?;
2492    if config.algorithm != algorithm {
2493        return Err(malformed_stored_data(
2494            format!("community snapshot `{snapshot_id}`"),
2495            "algorithm does not match config",
2496        ));
2497    }
2498
2499    let mut statement = connection.prepare(
2500        "SELECT id, label, metrics_json, explanation_json
2501         FROM communities WHERE snapshot_id = ?1 ORDER BY id",
2502    )?;
2503    let rows = statement
2504        .query_map([snapshot_id], |row| {
2505            Ok((
2506                row.get::<_, String>(0)?,
2507                row.get::<_, String>(1)?,
2508                row.get::<_, String>(2)?,
2509                row.get::<_, String>(3)?,
2510            ))
2511        })?
2512        .collect::<Result<Vec<_>, _>>()?;
2513    let mut communities = Vec::with_capacity(rows.len());
2514    for (id, label, metrics_json, explanation_json) in rows {
2515        communities.push(load_community(
2516            connection,
2517            snapshot_id,
2518            id,
2519            label,
2520            &metrics_json,
2521            &explanation_json,
2522        )?);
2523    }
2524    Ok(CommunitySnapshot {
2525        snapshot_id: snapshot_id.to_owned(),
2526        engine_version,
2527        config,
2528        communities,
2529    })
2530}
2531
2532fn load_community(
2533    connection: &Connection,
2534    snapshot_id: &str,
2535    id: String,
2536    label: String,
2537    metrics_json: &str,
2538    explanation_json: &str,
2539) -> Result<Community, StoreError> {
2540    ensure_community_json_bound(
2541        "communities.metrics_json",
2542        metrics_json,
2543        COMMUNITY_METRICS_JSON_MAX_BYTES,
2544    )?;
2545    ensure_community_json_bound(
2546        "communities.explanation_json",
2547        explanation_json,
2548        COMMUNITY_EXPLANATION_JSON_MAX_BYTES,
2549    )?;
2550    let entity = format!("community `{id}` in snapshot `{snapshot_id}`");
2551    let metrics = serde_json::from_str::<CommunityMetrics>(metrics_json)
2552        .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2553    let mut details = serde_json::from_str::<serde_json::Value>(explanation_json)
2554        .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2555    let details = details
2556        .as_object_mut()
2557        .ok_or_else(|| malformed_stored_data(&entity, "explanation JSON must be an object"))?;
2558    let central_nodes =
2559        serde_json::from_value(remove_json_field(details, "central_nodes", &entity)?)
2560            .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2561    let repositories = serde_json::from_value(remove_json_field(details, "repositories", &entity)?)
2562        .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2563    let services = serde_json::from_value(remove_json_field(details, "services", &entity)?)
2564        .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2565    let inbound_contracts =
2566        serde_json::from_value(remove_json_field(details, "inbound_contracts", &entity)?)
2567            .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2568    let outbound_contracts =
2569        serde_json::from_value(remove_json_field(details, "outbound_contracts", &entity)?)
2570            .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2571    let label_evidence =
2572        serde_json::from_value(remove_json_field(details, "label_evidence", &entity)?)
2573            .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2574    let limitations = serde_json::from_value(remove_json_field(details, "limitations", &entity)?)
2575        .map_err(|error| malformed_stored_data(&entity, error.to_string()))?;
2576    if !details.is_empty() {
2577        return Err(malformed_stored_data(
2578            &entity,
2579            "explanation JSON contains unknown fields",
2580        ));
2581    }
2582
2583    let members = load_community_members(connection, snapshot_id, &id, &entity)?;
2584    if metrics.size != members.len() {
2585        return Err(malformed_stored_data(
2586            &entity,
2587            "metrics size does not match normalized membership count",
2588        ));
2589    }
2590    Ok(Community {
2591        id: CommunityId::new(id),
2592        label,
2593        members,
2594        central_nodes,
2595        repositories,
2596        services,
2597        inbound_contracts,
2598        outbound_contracts,
2599        metrics,
2600        label_evidence,
2601        limitations,
2602    })
2603}
2604
2605fn load_community_members(
2606    connection: &Connection,
2607    snapshot_id: &str,
2608    community_id: &str,
2609    entity: &str,
2610) -> Result<Vec<NodeId>, StoreError> {
2611    let mut statement = connection.prepare(
2612        "SELECT node_id, member_order
2613         FROM community_memberships
2614         WHERE snapshot_id = ?1 AND community_id = ?2
2615         ORDER BY member_order, node_id",
2616    )?;
2617    let rows = statement
2618        .query_map(params![snapshot_id, community_id], |row| {
2619            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2620        })?
2621        .collect::<Result<Vec<_>, _>>()?;
2622    let mut members = Vec::with_capacity(rows.len());
2623    for (expected_order, (node_id, stored_order)) in rows.into_iter().enumerate() {
2624        let expected_order =
2625            i64::try_from(expected_order).map_err(|_| StoreError::IntegerOutOfRange {
2626                field: "community_memberships.member_order",
2627                value: i128::try_from(expected_order).unwrap_or(i128::MAX),
2628            })?;
2629        if stored_order != expected_order {
2630            return Err(malformed_stored_data(
2631                entity,
2632                "membership order is not contiguous",
2633            ));
2634        }
2635        let node_exists = connection
2636            .query_row(
2637                "SELECT 1 FROM nodes WHERE snapshot_id = ?1 AND id = ?2",
2638                params![snapshot_id, node_id],
2639                |_| Ok(()),
2640            )
2641            .optional()?
2642            .is_some();
2643        if !node_exists {
2644            return Err(malformed_stored_data(
2645                entity,
2646                format!("membership references missing node `{node_id}`"),
2647            ));
2648        }
2649        members.push(NodeId::new(node_id));
2650    }
2651    Ok(members)
2652}
2653
2654fn remove_json_field(
2655    object: &mut serde_json::Map<String, serde_json::Value>,
2656    field: &str,
2657    entity: &str,
2658) -> Result<serde_json::Value, StoreError> {
2659    object
2660        .remove(field)
2661        .ok_or_else(|| malformed_stored_data(entity, format!("missing JSON field `{field}`")))
2662}
2663
2664fn malformed_stored_data(entity: impl Into<String>, reason: impl Into<String>) -> StoreError {
2665    StoreError::MalformedStoredData {
2666        entity: entity.into(),
2667        reason: reason.into(),
2668    }
2669}
2670
2671fn validate_safe_metadata(
2672    field: &str,
2673    value: &str,
2674    min_bytes: usize,
2675    max_bytes: usize,
2676) -> Result<(), StoreError> {
2677    if !(min_bytes..=max_bytes).contains(&value.len()) || value.trim().is_empty() {
2678        return Err(StoreError::InvalidPersistenceRecord(format!(
2679            "{field} must contain between {min_bytes} and {max_bytes} non-blank UTF-8 bytes"
2680        )));
2681    }
2682    if contains_unsafe_metadata_characters(value) {
2683        return Err(StoreError::InvalidPersistenceRecord(format!(
2684            "{field} contains unsafe control or bidirectional characters"
2685        )));
2686    }
2687    Ok(())
2688}
2689
2690fn validate_manual_links(
2691    snapshot_id: &str,
2692    records: &[ManualLinkRecord],
2693    nodes: Option<&[Node]>,
2694) -> Result<(), StoreError> {
2695    validate_safe_metadata(
2696        "snapshot identifier",
2697        snapshot_id,
2698        1,
2699        MANUAL_LINK_ID_MAX_BYTES,
2700    )?;
2701    let node_ids = nodes.map(|nodes| {
2702        nodes
2703            .iter()
2704            .map(|node| node.id.as_str())
2705            .collect::<BTreeSet<_>>()
2706    });
2707    let mut record_ids = BTreeSet::new();
2708    let mut declarations = BTreeSet::new();
2709    for record in records {
2710        if record.snapshot_id != snapshot_id {
2711            return Err(StoreError::InvalidPersistenceRecord(format!(
2712                "manual link `{}` belongs to snapshot `{}`, not `{snapshot_id}`",
2713                record.id, record.snapshot_id
2714            )));
2715        }
2716        validate_safe_metadata(
2717            "manual link identifier",
2718            &record.id,
2719            1,
2720            MANUAL_LINK_ID_MAX_BYTES,
2721        )?;
2722        validate_safe_metadata(
2723            "manual link source node",
2724            record.source_node_id.as_str(),
2725            1,
2726            MANUAL_LINK_ID_MAX_BYTES,
2727        )?;
2728        validate_safe_metadata(
2729            "manual link target node",
2730            record.target_node_id.as_str(),
2731            1,
2732            MANUAL_LINK_ID_MAX_BYTES,
2733        )?;
2734        validate_safe_metadata(
2735            "manual link kind",
2736            &record.kind,
2737            1,
2738            MANUAL_LINK_KIND_MAX_BYTES,
2739        )?;
2740        validate_safe_metadata(
2741            "manual link reason",
2742            &record.reason,
2743            1,
2744            MANUAL_LINK_REASON_MAX_BYTES,
2745        )?;
2746        validate_manual_link_decision(record)?;
2747        if !(1..=2_147_483_647).contains(&record.config_version) {
2748            return Err(StoreError::InvalidPersistenceRecord(format!(
2749                "manual link `{}` config version is outside 1..=2147483647",
2750                record.id
2751            )));
2752        }
2753        if !record_ids.insert(record.id.as_str()) {
2754            return Err(StoreError::InvalidPersistenceRecord(format!(
2755                "manual link identifier `{}` is duplicated in snapshot `{snapshot_id}`",
2756                record.id
2757            )));
2758        }
2759        if !declarations.insert((
2760            record.source_node_id.as_str(),
2761            record.target_node_id.as_str(),
2762            record.kind.as_str(),
2763        )) {
2764            return Err(StoreError::InvalidPersistenceRecord(format!(
2765                "manual link endpoints and kind are duplicated in snapshot `{snapshot_id}`"
2766            )));
2767        }
2768        if node_ids.as_ref().is_some_and(|node_ids| {
2769            !node_ids.contains(record.source_node_id.as_str())
2770                || !node_ids.contains(record.target_node_id.as_str())
2771        }) {
2772            return Err(StoreError::InvalidPersistenceRecord(format!(
2773                "manual link `{}` references a node absent from snapshot `{snapshot_id}`",
2774                record.id
2775            )));
2776        }
2777    }
2778    Ok(())
2779}
2780
2781fn validate_manual_link_decision(record: &ManualLinkRecord) -> Result<(), StoreError> {
2782    let decision_json = serde_json::to_vec(&record.decision)?;
2783    if decision_json.len() > MANUAL_LINK_DECISION_JSON_MAX_BYTES {
2784        return Err(StoreError::InvalidPersistenceRecord(format!(
2785            "manual link `{}` decision exceeds {MANUAL_LINK_DECISION_JSON_MAX_BYTES} bytes",
2786            record.id
2787        )));
2788    }
2789    let relation =
2790        serde_json::from_value::<EdgeKind>(serde_json::Value::String(record.kind.clone()))
2791            .map_err(|error| {
2792                StoreError::InvalidPersistenceRecord(format!(
2793                    "manual link `{}` kind is invalid: {error}",
2794                    record.id
2795                ))
2796            })?;
2797    let expected_status = match record.disposition {
2798        ManualLinkDisposition::Active => LinkStatus::Confirmed,
2799        ManualLinkDisposition::Suppression => LinkStatus::Suppressed,
2800    };
2801    if record.decision.source != record.source_node_id
2802        || record.decision.target != record.target_node_id
2803        || record.decision.relation != relation
2804        || record.decision.status != expected_status
2805        || record.decision.reasons.first() != Some(&record.reason)
2806    {
2807        return Err(StoreError::InvalidPersistenceRecord(format!(
2808            "manual link `{}` decision does not match its persisted declaration",
2809            record.id
2810        )));
2811    }
2812    Ok(())
2813}
2814
2815fn validate_provider_capability_record(
2816    record: &ProviderCapabilityRecord,
2817) -> Result<(), StoreError> {
2818    validate_safe_metadata(
2819        "workspace name",
2820        &record.workspace_name,
2821        1,
2822        MANUAL_LINK_ID_MAX_BYTES,
2823    )?;
2824    validate_safe_metadata(
2825        "repository identifier",
2826        record.repo_id.as_str(),
2827        1,
2828        MANUAL_LINK_ID_MAX_BYTES,
2829    )?;
2830    validate_safe_metadata(
2831        "provider name",
2832        &record.provider,
2833        1,
2834        PROVIDER_COMPONENT_MAX_BYTES,
2835    )?;
2836    validate_safe_metadata(
2837        "provider version",
2838        &record.provider_version,
2839        1,
2840        PROVIDER_COMPONENT_MAX_BYTES,
2841    )?;
2842    if record.capabilities.len() > PROVIDER_CAPABILITY_COUNT_MAX {
2843        return Err(StoreError::InvalidPersistenceRecord(format!(
2844            "provider capability count exceeds {PROVIDER_CAPABILITY_COUNT_MAX}"
2845        )));
2846    }
2847    let mut capability_names = BTreeSet::new();
2848    for capability in &record.capabilities {
2849        validate_safe_metadata(
2850            "provider capability name",
2851            capability,
2852            1,
2853            PROVIDER_COMPONENT_MAX_BYTES,
2854        )?;
2855        if !capability_names.insert(capability.as_str()) {
2856            return Err(StoreError::InvalidPersistenceRecord(format!(
2857                "provider capability `{capability}` is duplicated"
2858            )));
2859        }
2860    }
2861    let capabilities_json = serde_json::to_string(&record.capabilities)?;
2862    if capabilities_json.len() > PROVIDER_CAPABILITIES_JSON_MAX_BYTES {
2863        return Err(StoreError::InvalidPersistenceRecord(format!(
2864            "provider capability JSON exceeds {PROVIDER_CAPABILITIES_JSON_MAX_BYTES} bytes"
2865        )));
2866    }
2867    metric_to_i64(
2868        "provider_capabilities.observed_at_unix_ms",
2869        record.observed_at_unix_ms,
2870    )?;
2871    Ok(())
2872}
2873
2874fn validate_query_cache_record(record: &QueryCacheRecord) -> Result<(), StoreError> {
2875    validate_safe_metadata(
2876        "workspace name",
2877        &record.workspace_name,
2878        1,
2879        MANUAL_LINK_ID_MAX_BYTES,
2880    )?;
2881    validate_safe_metadata(
2882        "snapshot identifier",
2883        &record.snapshot_id,
2884        1,
2885        MANUAL_LINK_ID_MAX_BYTES,
2886    )?;
2887    validate_safe_metadata(
2888        "query cache input fingerprint",
2889        &record.input_fingerprint,
2890        1,
2891        QUERY_CACHE_FINGERPRINT_MAX_BYTES,
2892    )?;
2893    if record.result_summary_json.is_empty()
2894        || record.result_summary_json.len() > QUERY_CACHE_RESULT_MAX_BYTES
2895        || serde_json::from_slice::<serde_json::Value>(&record.result_summary_json).is_err()
2896    {
2897        return Err(StoreError::InvalidPersistenceRecord(format!(
2898            "query cache result must be valid JSON between 1 and {QUERY_CACHE_RESULT_MAX_BYTES} bytes"
2899        )));
2900    }
2901    if record
2902        .expires_at_unix_ms
2903        .is_some_and(|expires| expires < record.stored_at_unix_ms)
2904    {
2905        return Err(StoreError::InvalidPersistenceRecord(
2906            "query cache expiry precedes storage timestamp".to_owned(),
2907        ));
2908    }
2909    Ok(())
2910}
2911
2912fn insert_manual_links(
2913    transaction: &rusqlite::Transaction<'_>,
2914    records: &[ManualLinkRecord],
2915) -> Result<(), StoreError> {
2916    for record in records {
2917        transaction.execute(
2918            "INSERT INTO manual_links(
2919                snapshot_id, id, source_node_id, target_node_id, kind, disposition, reason,
2920                decision_json, config_version
2921             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2922            params![
2923                record.snapshot_id,
2924                record.id,
2925                record.source_node_id.as_str(),
2926                record.target_node_id.as_str(),
2927                record.kind,
2928                record.disposition.as_str(),
2929                record.reason,
2930                serde_json::to_vec(&record.decision)?,
2931                i64::from(record.config_version),
2932            ],
2933        )?;
2934    }
2935    Ok(())
2936}
2937
2938fn insert_graph<F>(
2939    transaction: &rusqlite::Transaction<'_>,
2940    snapshot_id: &str,
2941    nodes: &[Node],
2942    edges: &[Edge],
2943    evidence: &[Evidence],
2944    progress: &mut F,
2945) -> Result<(), StoreError>
2946where
2947    F: FnMut(u64),
2948{
2949    for node in nodes {
2950        transaction.execute(
2951            "INSERT INTO nodes(
2952                snapshot_id, id, kind, repo_id, stable_key, label
2953             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2954            params![
2955                snapshot_id,
2956                node.id.as_str(),
2957                serde_json::to_string(&node.kind)?,
2958                node.repo_id.as_ref().map(RepoId::as_str),
2959                node.stable_key,
2960                node.label,
2961            ],
2962        )?;
2963        progress(1);
2964        transaction.execute(
2965            "INSERT INTO nodes_fts(snapshot_id, node_id, label, stable_key)
2966             VALUES (?1, ?2, ?3, ?4)",
2967            params![snapshot_id, node.id.as_str(), node.label, node.stable_key],
2968        )?;
2969        progress(1);
2970    }
2971    for item in evidence {
2972        transaction.execute(
2973            "INSERT INTO evidence(
2974                snapshot_id, id, repo_id, file_path, start_line, end_line, extractor,
2975                extractor_version, provenance, confidence, observed_at_commit, content_hash
2976             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
2977            params![
2978                snapshot_id,
2979                item.id.as_str(),
2980                item.repo_id.as_ref().map(RepoId::as_str),
2981                item.file_path,
2982                item.start_line,
2983                item.end_line,
2984                item.extractor,
2985                item.extractor_version,
2986                serde_json::to_string(&item.provenance)?,
2987                item.confidence,
2988                item.observed_at_commit,
2989                item.content_hash,
2990            ],
2991        )?;
2992    }
2993    for edge in edges {
2994        transaction.execute(
2995            "INSERT INTO edges(
2996                snapshot_id, id, source_node_id, target_node_id, kind, confidence,
2997                epistemic_status
2998             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2999            params![
3000                snapshot_id,
3001                edge.id.as_str(),
3002                edge.source.as_str(),
3003                edge.target.as_str(),
3004                serde_json::to_string(&edge.kind)?,
3005                edge.confidence,
3006                serde_json::to_string(&edge.status)?,
3007            ],
3008        )?;
3009        progress(1);
3010        for evidence_id in &edge.evidence {
3011            transaction.execute(
3012                "INSERT INTO edge_evidence(snapshot_id, edge_id, evidence_id)
3013                 VALUES (?1, ?2, ?3)",
3014                params![snapshot_id, edge.id.as_str(), evidence_id.as_str()],
3015            )?;
3016            progress(1);
3017        }
3018    }
3019    Ok(())
3020}
3021
3022fn insert_extractor_batches<F>(
3023    transaction: &rusqlite::Transaction<'_>,
3024    snapshot_id: &str,
3025    batches: &[StoredExtractorBatch],
3026    progress: &mut F,
3027) -> Result<(), StoreError>
3028where
3029    F: FnMut(u64),
3030{
3031    for batch in batches {
3032        transaction.execute(
3033            "INSERT INTO extractor_batches(
3034                snapshot_id, repo_id, checkout_id, path_encoding, relative_path, path_display,
3035                extractor, content_hash, size_bytes, extractor_version, budget_fingerprint,
3036                source_was_lossy, output_count, payload
3037             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
3038            params![
3039                snapshot_id,
3040                batch.source.repo_id.as_str(),
3041                batch.source.checkout_id.as_str(),
3042                serde_json::to_string(&batch.source.path.encoding)?,
3043                batch.source.path.bytes,
3044                batch.source.path.display,
3045                batch.source.extractor,
3046                batch.source.content_hash,
3047                metric_to_i64("extractor_batches.size_bytes", batch.source.size_bytes)?,
3048                batch.extractor_version,
3049                batch.budget_fingerprint,
3050                batch.source_was_lossy,
3051                metric_to_i64("extractor_batches.output_count", batch.output_count)?,
3052                batch.payload,
3053            ],
3054        )?;
3055        progress(1);
3056    }
3057    Ok(())
3058}
3059
3060fn insert_incremental_state<F>(
3061    transaction: &rusqlite::Transaction<'_>,
3062    snapshot_id: &str,
3063    fingerprints: &[ArtifactFingerprint],
3064    extractor_runs: &[ExtractorRun],
3065    progress: &mut F,
3066) -> Result<(), StoreError>
3067where
3068    F: FnMut(u64),
3069{
3070    for fingerprint in fingerprints {
3071        let size_bytes = metric_to_i64("artifact_fingerprints.size_bytes", fingerprint.size_bytes)?;
3072        transaction.execute(
3073            "INSERT INTO artifact_fingerprints(
3074                snapshot_id, repo_id, checkout_id, path_encoding, relative_path,
3075                path_display, extractor, content_hash, size_bytes
3076             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
3077            params![
3078                snapshot_id,
3079                fingerprint.repo_id.as_str(),
3080                fingerprint.checkout_id.as_str(),
3081                serde_json::to_string(&fingerprint.path.encoding)?,
3082                fingerprint.path.bytes,
3083                fingerprint.path.display,
3084                fingerprint.extractor,
3085                fingerprint.content_hash,
3086                size_bytes,
3087            ],
3088        )?;
3089        progress(1);
3090    }
3091    for run in extractor_runs {
3092        insert_extractor_run(transaction, snapshot_id, run, fingerprints)?;
3093        progress(1);
3094    }
3095    Ok(())
3096}
3097
3098fn insert_extractor_run(
3099    transaction: &rusqlite::Transaction<'_>,
3100    snapshot_id: &str,
3101    run: &ExtractorRun,
3102    fingerprints: &[ArtifactFingerprint],
3103) -> Result<(), StoreError> {
3104    transaction.execute(
3105        "INSERT INTO extractor_runs(
3106            id, snapshot_id, repo_id, extractor, extractor_version, status,
3107            discovered_files, parsed_files, skipped_files, elapsed_ms, checkout_id
3108         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
3109        params![
3110            run.id,
3111            snapshot_id,
3112            run.repo_id.as_str(),
3113            run.extractor,
3114            run.extractor_version,
3115            serde_json::to_string(&run.status)?,
3116            metric_to_i64("extractor_runs.discovered_files", run.discovered_files)?,
3117            metric_to_i64("extractor_runs.parsed_files", run.parsed_files)?,
3118            metric_to_i64("extractor_runs.skipped_files", run.skipped_files)?,
3119            metric_to_i64("extractor_runs.elapsed_ms", run.elapsed_ms)?,
3120            run.checkout_id.as_str(),
3121        ],
3122    )?;
3123    for fingerprint in fingerprints.iter().filter(|fingerprint| {
3124        fingerprint.repo_id == run.repo_id
3125            && fingerprint.checkout_id == run.checkout_id
3126            && fingerprint.extractor == run.extractor
3127    }) {
3128        transaction.execute(
3129            "INSERT INTO extractor_run_inputs(
3130                run_id, repo_id, checkout_id, path_encoding, relative_path,
3131                extractor, content_hash
3132             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
3133            params![
3134                run.id,
3135                fingerprint.repo_id.as_str(),
3136                fingerprint.checkout_id.as_str(),
3137                serde_json::to_string(&fingerprint.path.encoding)?,
3138                fingerprint.path.bytes,
3139                fingerprint.extractor,
3140                fingerprint.content_hash,
3141            ],
3142        )?;
3143    }
3144    Ok(())
3145}
3146
3147fn insert_freshness(
3148    transaction: &rusqlite::Transaction<'_>,
3149    snapshot_id: &str,
3150    workspace: &WorkspaceRecord,
3151) -> Result<(), StoreError> {
3152    for repository in &workspace.repositories {
3153        let freshness_state = if repository.working_tree_dirty {
3154            RepoFreshnessState::WorkingTreeChanged
3155        } else {
3156            RepoFreshnessState::Fresh
3157        };
3158        transaction.execute(
3159            "INSERT INTO repository_snapshot_freshness(
3160                snapshot_id, repo_id, checkout_id, head_commit, manifest_hash, state, reason
3161             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)",
3162            params![
3163                snapshot_id,
3164                repository.id.as_str(),
3165                repository.checkout_id.as_str(),
3166                repository.head_commit,
3167                workspace.manifest_hash,
3168                serde_json::to_string(&freshness_state)?,
3169            ],
3170        )?;
3171    }
3172    transaction.execute(
3173        "DELETE FROM provider_capabilities
3174         WHERE workspace_name = ?1
3175           AND NOT EXISTS (
3176                SELECT 1 FROM workspace_repositories
3177                WHERE workspace_name = ?1
3178                  AND repo_id = provider_capabilities.repo_id
3179           )",
3180        [&workspace.name],
3181    )?;
3182    Ok(())
3183}
3184
3185fn metric_to_i64(field: &'static str, value: u64) -> Result<i64, StoreError> {
3186    i64::try_from(value).map_err(|_| StoreError::IntegerOutOfRange {
3187        field,
3188        value: i128::from(value),
3189    })
3190}
3191
3192fn stored_metric_to_u64(field: &'static str, value: i64) -> Result<u64, StoreError> {
3193    u64::try_from(value).map_err(|_| StoreError::IntegerOutOfRange {
3194        field,
3195        value: i128::from(value),
3196    })
3197}
3198
3199fn count_to_usize(field: &'static str, value: i64) -> Result<usize, StoreError> {
3200    usize::try_from(value).map_err(|_| StoreError::IntegerOutOfRange {
3201        field,
3202        value: i128::from(value),
3203    })
3204}
3205
3206fn initialize_empty_schema(connection: &mut Connection) -> Result<(), StoreError> {
3207    let transaction = connection.transaction()?;
3208    transaction.execute_batch(INITIAL_SCHEMA)?;
3209    transaction.execute(
3210        "INSERT INTO schema_metadata(version, instance_id)
3211         VALUES (?1, lower(hex(randomblob(32))))",
3212        [LATEST_SCHEMA_VERSION],
3213    )?;
3214    transaction.commit()?;
3215    Ok(())
3216}
3217
3218fn schema_version(connection: &Connection) -> Result<i64, StoreError> {
3219    Ok(connection.query_row(
3220        "SELECT COALESCE(MAX(version), 0) FROM schema_metadata",
3221        [],
3222        |row| row.get(0),
3223    )?)
3224}
3225
3226fn database_instance_id(connection: &Connection) -> Result<String, StoreError> {
3227    Ok(connection.query_row(
3228        "SELECT instance_id FROM schema_metadata WHERE version = ?1",
3229        [LATEST_SCHEMA_VERSION],
3230        |row| row.get(0),
3231    )?)
3232}
3233
3234fn upsert_registry(
3235    transaction: &rusqlite::Transaction<'_>,
3236    workspace: &WorkspaceRecord,
3237) -> Result<(), StoreError> {
3238    let config_path_encoding = workspace
3239        .config_path
3240        .as_ref()
3241        .map(|path| serde_json::to_string(&path.encoding))
3242        .transpose()?;
3243    transaction.execute(
3244        "INSERT INTO workspaces(
3245            name, manifest_hash, id, config_path_encoding, config_path, config_path_display
3246         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
3247         ON CONFLICT(name) DO UPDATE SET
3248             manifest_hash = excluded.manifest_hash,
3249             id = excluded.id,
3250             config_path_encoding = excluded.config_path_encoding,
3251             config_path = excluded.config_path,
3252             config_path_display = excluded.config_path_display",
3253        params![
3254            workspace.name,
3255            workspace.manifest_hash,
3256            workspace.id.as_str(),
3257            config_path_encoding,
3258            workspace
3259                .config_path
3260                .as_ref()
3261                .map(|path| path.bytes.as_slice()),
3262            workspace
3263                .config_path
3264                .as_ref()
3265                .map(|path| path.display.as_str()),
3266        ],
3267    )?;
3268    transaction.execute(
3269        "DELETE FROM workspace_repositories WHERE workspace_name = ?1",
3270        [&workspace.name],
3271    )?;
3272    for repository in &workspace.repositories {
3273        let common_encoding = repository
3274            .git_common_dir
3275            .as_ref()
3276            .map(|path| serde_json::to_string(&path.encoding))
3277            .transpose()?;
3278        transaction.execute(
3279            "INSERT INTO repositories(
3280                id, normalized_remote, git_common_path_encoding, git_common_path,
3281                git_common_path_display
3282             ) VALUES (?1, ?2, ?3, ?4, ?5)
3283             ON CONFLICT(id) DO UPDATE SET
3284                normalized_remote = excluded.normalized_remote,
3285                git_common_path_encoding = excluded.git_common_path_encoding,
3286                git_common_path = excluded.git_common_path,
3287                git_common_path_display = excluded.git_common_path_display",
3288            params![
3289                repository.id.as_str(),
3290                repository.normalized_remote,
3291                common_encoding,
3292                repository
3293                    .git_common_dir
3294                    .as_ref()
3295                    .map(|path| path.bytes.as_slice()),
3296                repository
3297                    .git_common_dir
3298                    .as_ref()
3299                    .map(|path| path.display.as_str()),
3300            ],
3301        )?;
3302        transaction.execute(
3303            "INSERT INTO repository_checkouts(
3304                id, repo_id, path_encoding, canonical_path, path_display, head_commit,
3305                is_linked_worktree, working_tree_dirty
3306             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
3307             ON CONFLICT(id) DO UPDATE SET
3308                repo_id = excluded.repo_id,
3309                path_encoding = excluded.path_encoding,
3310                canonical_path = excluded.canonical_path,
3311                path_display = excluded.path_display,
3312                head_commit = excluded.head_commit,
3313                is_linked_worktree = excluded.is_linked_worktree,
3314                working_tree_dirty = excluded.working_tree_dirty",
3315            params![
3316                repository.checkout_id.as_str(),
3317                repository.id.as_str(),
3318                serde_json::to_string(&repository.canonical_path.encoding)?,
3319                repository.canonical_path.bytes,
3320                repository.canonical_path.display,
3321                repository.head_commit,
3322                i64::from(repository.is_linked_worktree),
3323                i64::from(repository.working_tree_dirty),
3324            ],
3325        )?;
3326        transaction.execute(
3327            "INSERT INTO workspace_repositories(workspace_name, alias, repo_id, checkout_id)
3328             VALUES (?1, ?2, ?3, ?4)",
3329            params![
3330                workspace.name,
3331                repository.alias,
3332                repository.id.as_str(),
3333                repository.checkout_id.as_str(),
3334            ],
3335        )?;
3336    }
3337    Ok(())
3338}
3339
3340fn lock_path(database_path: &Path) -> PathBuf {
3341    let mut path = database_path.to_path_buf();
3342    let extension = database_path.extension().map_or_else(
3343        || "lock".to_owned(),
3344        |value| format!("{}.lock", value.to_string_lossy()),
3345    );
3346    path.set_extension(extension);
3347    path
3348}
3349
3350#[cfg(unix)]
3351fn create_lock_file(path: &Path) -> std::io::Result<std::fs::File> {
3352    use std::os::unix::fs::OpenOptionsExt;
3353
3354    OpenOptions::new()
3355        .write(true)
3356        .create_new(true)
3357        .mode(0o600)
3358        .open(path)
3359}
3360
3361#[cfg(not(unix))]
3362fn create_lock_file(path: &Path) -> std::io::Result<std::fs::File> {
3363    OpenOptions::new().write(true).create_new(true).open(path)
3364}
3365
3366impl LockMetadata {
3367    fn encode(&self) -> String {
3368        format!(
3369            "version=1\npid={}\nprocess_started_at={}\ntoken={}\n",
3370            self.pid, self.process_started_at, self.token
3371        )
3372    }
3373
3374    fn decode(content: &str) -> Option<Self> {
3375        let mut version = None;
3376        let mut pid = None;
3377        let mut process_started_at = None;
3378        let mut token = None;
3379        for line in content.lines() {
3380            let (key, value) = line.split_once('=')?;
3381            match key {
3382                "version" => version = Some(value),
3383                "pid" => pid = value.parse().ok(),
3384                "process_started_at" => process_started_at = value.parse().ok(),
3385                "token" => token = Some(value.to_owned()),
3386                _ => return None,
3387            }
3388        }
3389        (version == Some("1") && token.as_ref().is_some_and(|value| !value.is_empty())).then_some(
3390            Self {
3391                pid: pid?,
3392                process_started_at: process_started_at?,
3393                token: token?,
3394            },
3395        )
3396    }
3397}
3398
3399fn process_start_time(pid: u32) -> Option<u64> {
3400    let pid = Pid::from_u32(pid);
3401    let mut system = System::new();
3402    system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
3403    system.process(pid).map(sysinfo::Process::start_time)
3404}
3405
3406fn lock_owner_is_alive(metadata: &LockMetadata) -> bool {
3407    process_start_time(metadata.pid).is_some_and(|started_at| {
3408        metadata.process_started_at == 0 || metadata.process_started_at == started_at
3409    })
3410}
3411
3412fn lock_is_stale(path: &Path, stale_after: Duration) -> Result<bool, StoreError> {
3413    let modified = fs::metadata(path)
3414        .and_then(|metadata| metadata.modified())
3415        .map_err(|source| StoreError::Io {
3416            path: path.to_path_buf(),
3417            source,
3418        })?;
3419    let old_enough = SystemTime::now()
3420        .duration_since(modified)
3421        .is_ok_and(|age| age >= stale_after);
3422    if !old_enough {
3423        return Ok(false);
3424    }
3425    let content = fs::read_to_string(path).map_err(|source| StoreError::Io {
3426        path: path.to_path_buf(),
3427        source,
3428    })?;
3429    Ok(LockMetadata::decode(&content).is_none_or(|metadata| !lock_owner_is_alive(&metadata)))
3430}
3431
3432#[cfg(test)]
3433mod tests {
3434    use std::fs;
3435    use std::process::Command;
3436    use std::time::Duration;
3437
3438    use code_system_graph_model::{
3439        ArtifactFingerprint, CheckoutId, Community, CommunityAlgorithm, CommunityConfig, CommunityId, CommunityMetrics, CommunityScope, CommunitySnapshot, Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, ExtractorRun, ExtractorRunStatus, NativePath, NativePathEncoding, Node, NodeId, NodeKind, Provenance, RepoFreshnessState, RepoId, RepositoryRecord, StoredExtractorBatch, WorkspaceId, WorkspaceRecord
3440    };
3441    use rusqlite::params;
3442
3443    use super::{
3444        INITIAL_SCHEMA, ManualLinkDisposition, ManualLinkRecord, ProviderCapabilityRecord, QueryCacheRecord, SnapshotBatch, SqliteStore, StoreError, StoreLock, lock_path
3445    };
3446
3447    const LOCK_HELPER_ENV: &str = "CODE_SYSTEM_GRAPH_LOCK_HELPER";
3448    const LOCK_DATABASE_ENV: &str = "CODE_SYSTEM_GRAPH_LOCK_DATABASE";
3449    const LOCK_READY_ENV: &str = "CODE_SYSTEM_GRAPH_LOCK_READY";
3450
3451    fn fixture() -> (Vec<Node>, Vec<Edge>, Vec<Evidence>) {
3452        let evidence = Evidence {
3453            id: EvidenceId::new("evidence:1"),
3454            repo_id: Some(RepoId::new("repo:web")),
3455            file_path: Some("src/client.ts".to_owned()),
3456            start_line: Some(7),
3457            end_line: Some(9),
3458            extractor: "test".to_owned(),
3459            extractor_version: "1.0.0".to_owned(),
3460            provenance: Provenance::Extracted,
3461            confidence: 1.0,
3462            observed_at_commit: Some("0123456789abcdef".to_owned()),
3463            content_hash: Some("hash".to_owned()),
3464            note: None,
3465        };
3466        let nodes = vec![
3467            Node {
3468                id: NodeId::new("node:web"),
3469                kind: NodeKind::HttpOperation,
3470                repo_id: Some(RepoId::new("repo:web")),
3471                stable_key: "http:web".to_owned(),
3472                label: "POST /orders".to_owned(),
3473            },
3474            Node {
3475                id: NodeId::new("node:api"),
3476                kind: NodeKind::HttpOperation,
3477                repo_id: Some(RepoId::new("repo:api")),
3478                stable_key: "http:api".to_owned(),
3479                label: "POST /orders".to_owned(),
3480            },
3481        ];
3482        let edges = vec![Edge {
3483            id: EdgeId::new("edge:1"),
3484            source: NodeId::new("node:web"),
3485            target: NodeId::new("node:api"),
3486            kind: EdgeKind::CallsRemote,
3487            confidence: 1.0,
3488            status: EpistemicStatus::Confirmed,
3489            evidence: vec![evidence.id.clone()],
3490        }];
3491        (nodes, edges, vec![evidence])
3492    }
3493
3494    fn workspace() -> WorkspaceRecord {
3495        WorkspaceRecord {
3496            id: WorkspaceId::new("workspace:commerce"),
3497            name: "commerce".to_owned(),
3498            manifest_hash: "manifest-hash".to_owned(),
3499            config_path: Some(NativePath {
3500                encoding: NativePathEncoding::Utf8,
3501                bytes: b"/workspace/code-system-graph.yaml".to_vec(),
3502                display: "/workspace/code-system-graph.yaml".to_owned(),
3503            }),
3504            repositories: vec![RepositoryRecord {
3505                id: RepoId::new("repo:web"),
3506                checkout_id: CheckoutId::new("checkout:web"),
3507                alias: "web".to_owned(),
3508                canonical_path: NativePath {
3509                    encoding: NativePathEncoding::Utf8,
3510                    bytes: b"/workspace/web".to_vec(),
3511                    display: "/workspace/web".to_owned(),
3512                },
3513                git_common_dir: None,
3514                normalized_remote: None,
3515                head_commit: Some("abc123".to_owned()),
3516                is_linked_worktree: false,
3517                working_tree_dirty: false,
3518            }],
3519        }
3520    }
3521
3522    fn incremental_fixture() -> (ArtifactFingerprint, ExtractorRun) {
3523        let fingerprint = ArtifactFingerprint {
3524            repo_id: RepoId::new("repo:web"),
3525            checkout_id: CheckoutId::new("checkout:web"),
3526            path: NativePath {
3527                encoding: NativePathEncoding::Utf8,
3528                bytes: b"openapi.yaml".to_vec(),
3529                display: "openapi.yaml".to_owned(),
3530            },
3531            extractor: "code-system-graph.http.openapi".to_owned(),
3532            content_hash: "content:1".to_owned(),
3533            size_bytes: 42,
3534        };
3535        let run = ExtractorRun {
3536            id: "run:1".to_owned(),
3537            snapshot_id: "snapshot:1".to_owned(),
3538            repo_id: fingerprint.repo_id.clone(),
3539            checkout_id: fingerprint.checkout_id.clone(),
3540            extractor: fingerprint.extractor.clone(),
3541            extractor_version: "0.1.0".to_owned(),
3542            status: ExtractorRunStatus::Success,
3543            discovered_files: 1,
3544            parsed_files: 1,
3545            skipped_files: 0,
3546            elapsed_ms: 2,
3547        };
3548        (fingerprint, run)
3549    }
3550
3551    fn manual_link_fixture(snapshot_id: &str) -> Vec<ManualLinkRecord> {
3552        vec![
3553            ManualLinkRecord {
3554                id: "manual-link:active".to_owned(),
3555                snapshot_id: snapshot_id.to_owned(),
3556                source_node_id: NodeId::new("node:web"),
3557                target_node_id: NodeId::new("node:api"),
3558                kind: "calls_remote".to_owned(),
3559                disposition: ManualLinkDisposition::Active,
3560                reason: "Declared by workspace configuration".to_owned(),
3561                decision: manual_link_decision(
3562                    "node:web",
3563                    "node:api",
3564                    "confirmed",
3565                    "Declared by workspace configuration",
3566                ),
3567                config_version: 1,
3568            },
3569            ManualLinkRecord {
3570                id: "manual-link:suppression".to_owned(),
3571                snapshot_id: snapshot_id.to_owned(),
3572                source_node_id: NodeId::new("node:api"),
3573                target_node_id: NodeId::new("node:web"),
3574                kind: "calls_remote".to_owned(),
3575                disposition: ManualLinkDisposition::Suppression,
3576                reason: "Known false positive".to_owned(),
3577                decision: manual_link_decision(
3578                    "node:api",
3579                    "node:web",
3580                    "suppressed",
3581                    "Known false positive",
3582                ),
3583                config_version: 1,
3584            },
3585        ]
3586    }
3587
3588    fn manual_link_decision(
3589        source: &str,
3590        target: &str,
3591        status: &str,
3592        reason: &str,
3593    ) -> code_system_graph_model::LinkDecision {
3594        serde_json::from_value(serde_json::json!({
3595            "source": source,
3596            "target": target,
3597            "relation": "calls_remote",
3598            "matcher": "manual_exact",
3599            "matcher_version": "1.0.0",
3600            "score": 1.0,
3601            "confidence": 1.0,
3602            "reasons": [reason],
3603            "rejected_alternatives": [],
3604            "evidence": [{
3605                "id": format!("evidence:{source}:{target}"),
3606                "provenance": "manual"
3607            }],
3608            "status": status
3609        }))
3610        .unwrap_or_else(|error| panic!("manual decision fixture must decode: {error}"))
3611    }
3612
3613    fn community_fixture(snapshot_id: &str) -> CommunitySnapshot {
3614        CommunitySnapshot {
3615            snapshot_id: snapshot_id.to_owned(),
3616            engine_version: "1.0.0".to_owned(),
3617            config: CommunityConfig {
3618                algorithm: CommunityAlgorithm::Louvain,
3619                scope: CommunityScope::Federated,
3620                seed: 7,
3621                resolution: 1.0,
3622                minimum_confidence: 0.5,
3623                edge_weights: Vec::new(),
3624                max_iterations: 20,
3625            },
3626            communities: vec![Community {
3627                id: CommunityId::new("community:orders"),
3628                label: "orders".to_owned(),
3629                members: vec![NodeId::new("node:api"), NodeId::new("node:web")],
3630                central_nodes: vec![NodeId::new("node:api")],
3631                repositories: vec![RepoId::new("repo:web")],
3632                services: Vec::new(),
3633                inbound_contracts: vec![NodeId::new("node:api")],
3634                outbound_contracts: vec![NodeId::new("node:web")],
3635                metrics: CommunityMetrics {
3636                    size: 2,
3637                    density: 0.5,
3638                    cohesion: 1.0,
3639                    coupling: 0.0,
3640                    cross_community_edges: 0,
3641                },
3642                label_evidence: Vec::new(),
3643                limitations: vec!["fixture coverage".to_owned()],
3644            }],
3645        }
3646    }
3647
3648    #[test]
3649    fn publish_snapshot_should_round_trip_current_graph() {
3650        let mut store = match SqliteStore::in_memory() {
3651            Ok(store) => store,
3652            Err(error) => panic!("test store must initialize: {error}"),
3653        };
3654        let (nodes, edges, evidence) = fixture();
3655        let workspace = workspace();
3656        let mut published_rows = 0_u64;
3657        let result = store
3658            .publish_snapshot_with_progress(
3659                SnapshotBatch {
3660                    workspace: &workspace,
3661                    snapshot_id: "snapshot:1",
3662                    nodes: &nodes,
3663                    edges: &edges,
3664                    evidence: &evidence,
3665                    fingerprints: &[],
3666                    extractor_batches: &[],
3667                    extractor_runs: &[],
3668                    manual_links: &[],
3669                    community_snapshot: None,
3670                },
3671                |rows| {
3672                    published_rows = published_rows
3673                        .checked_add(rows)
3674                        .expect("bounded fixture progress");
3675                },
3676            )
3677            .and_then(|()| {
3678                Ok((
3679                    store.load_current_graph("commerce")?,
3680                    store.load_current_evidence("commerce")?,
3681                ))
3682            });
3683        let counts = result.map(|((stored_nodes, stored_edges), stored_evidence)| {
3684            (
3685                stored_nodes.len(),
3686                stored_edges.len(),
3687                stored_evidence[0].start_line,
3688                stored_evidence[0].end_line,
3689                stored_evidence[0].extractor_version.clone(),
3690                stored_evidence[0].observed_at_commit.clone(),
3691            )
3692        });
3693
3694        assert!(matches!(
3695            counts,
3696            Ok((
3697                2,
3698                1,
3699                Some(7),
3700                Some(9),
3701                version,
3702                Some(commit)
3703            )) if version == "1.0.0" && commit == "0123456789abcdef"
3704        ));
3705        assert!(published_rows >= 4);
3706    }
3707
3708    #[test]
3709    fn publish_snapshot_should_reject_graph_poisoning_before_transaction() {
3710        let mut store = match SqliteStore::in_memory() {
3711            Ok(store) => store,
3712            Err(error) => panic!("test store must initialize: {error}"),
3713        };
3714        let (nodes, mut edges, evidence) = fixture();
3715        edges[0].target = NodeId::new("node:missing");
3716        let workspace = workspace();
3717
3718        let result = store.publish_snapshot(SnapshotBatch {
3719            workspace: &workspace,
3720            snapshot_id: "snapshot:poisoned",
3721            nodes: &nodes,
3722            edges: &edges,
3723            evidence: &evidence,
3724            fingerprints: &[],
3725            extractor_batches: &[],
3726            extractor_runs: &[],
3727            manual_links: &[],
3728            community_snapshot: None,
3729        });
3730
3731        assert!(matches!(result, Err(StoreError::InvalidGraphSnapshot(_))));
3732    }
3733
3734    #[test]
3735    fn interrupted_publication_should_preserve_previous_snapshot() {
3736        let mut store = SqliteStore::in_memory().expect("test store");
3737        let workspace = workspace();
3738        let (nodes, edges, evidence) = fixture();
3739        store
3740            .publish_snapshot(SnapshotBatch {
3741                workspace: &workspace,
3742                snapshot_id: "snapshot:previous",
3743                nodes: &nodes,
3744                edges: &edges,
3745                evidence: &evidence,
3746                fingerprints: &[],
3747                extractor_batches: &[],
3748                extractor_runs: &[],
3749                manual_links: &[],
3750                community_snapshot: None,
3751            })
3752            .expect("initial publication");
3753        let mut rows = 0_u64;
3754        let interrupted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3755            let _ = store.publish_snapshot_with_progress(
3756                SnapshotBatch {
3757                    workspace: &workspace,
3758                    snapshot_id: "snapshot:interrupted",
3759                    nodes: &nodes,
3760                    edges: &edges,
3761                    evidence: &evidence,
3762                    fingerprints: &[],
3763                    extractor_batches: &[],
3764                    extractor_runs: &[],
3765                    manual_links: &[],
3766                    community_snapshot: None,
3767                },
3768                |completed| {
3769                    rows = rows.saturating_add(completed);
3770                    assert!(rows < 2, "injected publication interruption");
3771                },
3772            );
3773        }));
3774
3775        assert!(interrupted.is_err());
3776        assert_eq!(
3777            store
3778                .current_snapshot_summary("commerce")
3779                .expect("previous snapshot remains")
3780                .snapshot_id,
3781            "snapshot:previous"
3782        );
3783    }
3784
3785    #[test]
3786    fn publish_snapshot_should_reject_bidi_labels() {
3787        let mut store = match SqliteStore::in_memory() {
3788            Ok(store) => store,
3789            Err(error) => panic!("test store must initialize: {error}"),
3790        };
3791        let (mut nodes, edges, evidence) = fixture();
3792        nodes[0].label = "safe\u{202e}txt.exe".to_owned();
3793        let workspace = workspace();
3794
3795        let result = store.publish_snapshot(SnapshotBatch {
3796            workspace: &workspace,
3797            snapshot_id: "snapshot:bidi",
3798            nodes: &nodes,
3799            edges: &edges,
3800            evidence: &evidence,
3801            fingerprints: &[],
3802            extractor_batches: &[],
3803            extractor_runs: &[],
3804            manual_links: &[],
3805            community_snapshot: None,
3806        });
3807
3808        assert!(matches!(result, Err(StoreError::InvalidGraphSnapshot(_))));
3809    }
3810
3811    #[test]
3812    fn publish_snapshot_should_reject_control_chars_in_path_display() {
3813        let mut store = match SqliteStore::in_memory() {
3814            Ok(store) => store,
3815            Err(error) => panic!("test store must initialize: {error}"),
3816        };
3817        let workspace = workspace();
3818        let (nodes, edges, evidence) = fixture();
3819        let fingerprint = ArtifactFingerprint {
3820            repo_id: RepoId::new("repo:web"),
3821            checkout_id: CheckoutId::new("checkout:web"),
3822            path: NativePath {
3823                encoding: NativePathEncoding::Utf8,
3824                bytes: b"src/bad\x1bname.ts".to_vec(),
3825                display: "src/bad\x1bname.ts".to_owned(),
3826            },
3827            extractor: "test".to_owned(),
3828            content_hash: "hash".to_owned(),
3829            size_bytes: 1,
3830        };
3831
3832        let result = store.publish_snapshot(SnapshotBatch {
3833            workspace: &workspace,
3834            snapshot_id: "snapshot:control-path",
3835            nodes: &nodes,
3836            edges: &edges,
3837            evidence: &evidence,
3838            fingerprints: &[fingerprint],
3839            extractor_batches: &[],
3840            extractor_runs: &[],
3841            manual_links: &[],
3842            community_snapshot: None,
3843        });
3844
3845        assert!(matches!(
3846            result,
3847            Err(StoreError::InvalidPersistenceRecord(_))
3848        ));
3849    }
3850
3851    #[test]
3852    fn publish_snapshot_should_atomically_round_trip_graph_and_communities() {
3853        let mut store = match SqliteStore::in_memory() {
3854            Ok(store) => store,
3855            Err(error) => panic!("test store must initialize: {error}"),
3856        };
3857        let workspace = workspace();
3858        let (nodes, edges, evidence) = fixture();
3859        let expected = community_fixture("snapshot:community");
3860        let result = store
3861            .publish_snapshot(SnapshotBatch {
3862                workspace: &workspace,
3863                snapshot_id: "snapshot:community",
3864                nodes: &nodes,
3865                edges: &edges,
3866                evidence: &evidence,
3867                fingerprints: &[],
3868                extractor_batches: &[],
3869                extractor_runs: &[],
3870                manual_links: &[],
3871                community_snapshot: Some(&expected),
3872            })
3873            .and_then(|()| {
3874                Ok((
3875                    store.load_current_graph("commerce")?,
3876                    store.load_current_community_snapshot("commerce")?,
3877                ))
3878            });
3879
3880        assert!(matches!(
3881            result,
3882            Ok(((stored_nodes, stored_edges), stored_communities))
3883                if stored_nodes.len() == 2
3884                    && stored_edges.len() == 1
3885                    && stored_communities == expected
3886        ));
3887    }
3888
3889    #[test]
3890    fn invalid_community_membership_should_preserve_current_snapshot() {
3891        let mut store = match SqliteStore::in_memory() {
3892            Ok(store) => store,
3893            Err(error) => panic!("test store must initialize: {error}"),
3894        };
3895        let workspace = workspace();
3896        let (nodes, edges, evidence) = fixture();
3897        let first = store.publish_snapshot(SnapshotBatch {
3898            workspace: &workspace,
3899            snapshot_id: "snapshot:valid",
3900            nodes: &nodes,
3901            edges: &edges,
3902            evidence: &evidence,
3903            fingerprints: &[],
3904            extractor_batches: &[],
3905            extractor_runs: &[],
3906            manual_links: &[],
3907            community_snapshot: None,
3908        });
3909        assert!(first.is_ok(), "valid fixture failed: {first:?}");
3910        let mut invalid = community_fixture("snapshot:invalid");
3911        invalid.communities[0]
3912            .members
3913            .push(NodeId::new("node:missing"));
3914        invalid.communities[0].metrics.size = 3;
3915        let failure = store.publish_snapshot(SnapshotBatch {
3916            workspace: &workspace,
3917            snapshot_id: "snapshot:invalid",
3918            nodes: &nodes,
3919            edges: &edges,
3920            evidence: &evidence,
3921            fingerprints: &[],
3922            extractor_batches: &[],
3923            extractor_runs: &[],
3924            manual_links: &[],
3925            community_snapshot: Some(&invalid),
3926        });
3927        let current = store.current_snapshot_summary("commerce");
3928
3929        assert!(matches!(
3930            (failure, current),
3931            (
3932                Err(StoreError::CommunityMembershipNodeMissing {
3933                    snapshot_id,
3934                    community_id,
3935                    node_id,
3936                }),
3937                Ok(summary),
3938            ) if snapshot_id == "snapshot:invalid"
3939                && community_id == "community:orders"
3940                && node_id == "node:missing"
3941                && summary.snapshot_id == "snapshot:valid"
3942        ));
3943    }
3944
3945    #[test]
3946    fn duplicate_community_ids_should_return_domain_error() {
3947        let mut store = match SqliteStore::in_memory() {
3948            Ok(store) => store,
3949            Err(error) => panic!("test store must initialize: {error}"),
3950        };
3951        let workspace = workspace();
3952        let (nodes, edges, evidence) = fixture();
3953        let mut communities = community_fixture("snapshot:duplicate");
3954        communities
3955            .communities
3956            .push(communities.communities[0].clone());
3957
3958        let result = store.publish_snapshot(SnapshotBatch {
3959            workspace: &workspace,
3960            snapshot_id: "snapshot:duplicate",
3961            nodes: &nodes,
3962            edges: &edges,
3963            evidence: &evidence,
3964            fingerprints: &[],
3965            extractor_batches: &[],
3966            extractor_runs: &[],
3967            manual_links: &[],
3968            community_snapshot: Some(&communities),
3969        });
3970
3971        assert!(matches!(
3972            result,
3973            Err(StoreError::DuplicateCommunityId {
3974                snapshot_id,
3975                community_id,
3976            }) if snapshot_id == "snapshot:duplicate"
3977                && community_id == "community:orders"
3978        ));
3979    }
3980
3981    #[test]
3982    fn historical_graph_and_community_snapshots_should_remain_loadable() {
3983        let mut store = match SqliteStore::in_memory() {
3984            Ok(store) => store,
3985            Err(error) => panic!("test store must initialize: {error}"),
3986        };
3987        let workspace = workspace();
3988        let (nodes, edges, evidence) = fixture();
3989        let historical_communities = community_fixture("snapshot:historical");
3990        let first = store.publish_snapshot(SnapshotBatch {
3991            workspace: &workspace,
3992            snapshot_id: "snapshot:historical",
3993            nodes: &nodes,
3994            edges: &edges,
3995            evidence: &evidence,
3996            fingerprints: &[],
3997            extractor_batches: &[],
3998            extractor_runs: &[],
3999            manual_links: &[],
4000            community_snapshot: Some(&historical_communities),
4001        });
4002        assert!(first.is_ok(), "historical fixture failed: {first:?}");
4003        let second = store.publish_snapshot(SnapshotBatch {
4004            workspace: &workspace,
4005            snapshot_id: "snapshot:current",
4006            nodes: &nodes,
4007            edges: &edges,
4008            evidence: &evidence,
4009            fingerprints: &[],
4010            extractor_batches: &[],
4011            extractor_runs: &[],
4012            manual_links: &[],
4013            community_snapshot: None,
4014        });
4015        assert!(second.is_ok(), "current fixture failed: {second:?}");
4016        let result = store
4017            .load_graph_snapshot("snapshot:historical")
4018            .and_then(|graph| Ok((graph, store.load_community_snapshot("snapshot:historical")?)));
4019
4020        assert!(matches!(
4021            result,
4022            Ok(((stored_nodes, stored_edges), stored_communities))
4023                if stored_nodes.len() == 2
4024                    && stored_edges.len() == 1
4025                    && stored_communities == historical_communities
4026        ));
4027    }
4028
4029    #[test]
4030    fn load_community_snapshot_should_reject_inconsistent_stored_metrics() {
4031        let mut store = match SqliteStore::in_memory() {
4032            Ok(store) => store,
4033            Err(error) => panic!("test store must initialize: {error}"),
4034        };
4035        let workspace = workspace();
4036        let (nodes, edges, evidence) = fixture();
4037        let communities = community_fixture("snapshot:malformed");
4038        let setup = store
4039            .publish_snapshot(SnapshotBatch {
4040                workspace: &workspace,
4041                snapshot_id: "snapshot:malformed",
4042                nodes: &nodes,
4043                edges: &edges,
4044                evidence: &evidence,
4045                fingerprints: &[],
4046                extractor_batches: &[],
4047                extractor_runs: &[],
4048                manual_links: &[],
4049                community_snapshot: Some(&communities),
4050            })
4051            .and_then(|()| {
4052                store.connection.execute(
4053                    "UPDATE communities SET metrics_json = ?1
4054                     WHERE snapshot_id = ?2 AND id = ?3",
4055                    rusqlite::params![r#"{"size":1,"density":0.5,"cohesion":1.0,"coupling":0.0,"cross_community_edges":0}"#, "snapshot:malformed", "community:orders"],
4056                )?;
4057                Ok(())
4058            });
4059        assert!(setup.is_ok(), "malformed fixture failed: {setup:?}");
4060
4061        let result = store.load_community_snapshot("snapshot:malformed");
4062
4063        assert!(matches!(
4064            result,
4065            Err(StoreError::MalformedStoredData { reason, .. })
4066                if reason == "metrics size does not match normalized membership count"
4067        ));
4068    }
4069
4070    #[test]
4071    fn integrity_check_should_pass_after_initial_schema_creation() {
4072        let result = SqliteStore::in_memory().and_then(|store| store.integrity_check());
4073
4074        assert!(matches!(result, Ok(true)));
4075    }
4076
4077    #[test]
4078    fn integrity_check_should_detect_foreign_key_corruption() {
4079        let store = match SqliteStore::in_memory() {
4080            Ok(store) => store,
4081            Err(error) => panic!("test store must initialize: {error}"),
4082        };
4083        let setup = store.connection.execute_batch(
4084            "PRAGMA foreign_keys = OFF;
4085             INSERT INTO workspaces(name, manifest_hash, id)
4086             VALUES ('corrupt', 'hash', 'workspace:corrupt');
4087             INSERT INTO repo_snapshots(id, workspace_name, is_current)
4088             VALUES ('snapshot:corrupt', 'corrupt', 1);
4089             INSERT INTO edges(
4090                snapshot_id, id, source_node_id, target_node_id, kind, confidence,
4091                epistemic_status
4092             ) VALUES (
4093                'snapshot:corrupt', 'edge:corrupt', 'missing:a', 'missing:b',
4094                '\"calls_remote\"', 1.0, '\"confirmed\"'
4095             );
4096             PRAGMA foreign_keys = ON;",
4097        );
4098        assert!(setup.is_ok(), "corruption fixture failed: {setup:?}");
4099        let result = store.integrity_check();
4100
4101        assert!(matches!(result, Ok(false)));
4102    }
4103
4104    #[test]
4105    fn fresh_database_should_apply_initial_schema() {
4106        let result = SqliteStore::in_memory().and_then(|store| store.schema_version());
4107
4108        assert!(matches!(result, Ok(1)));
4109    }
4110
4111    #[test]
4112    fn initial_schema_should_include_all_persistence_domains()
4113    -> Result<(), Box<dyn std::error::Error>> {
4114        let store = SqliteStore::in_memory()?;
4115        let table_count = store.connection.query_row(
4116            "SELECT COUNT(*) FROM sqlite_schema
4117             WHERE type = 'table'
4118               AND name IN (
4119                    'workspaces',
4120                    'repositories',
4121                    'repo_snapshots',
4122                    'nodes',
4123                    'evidence',
4124                    'edges',
4125                    'artifact_fingerprints',
4126                    'extractor_batches',
4127                    'community_snapshots',
4128                    'manual_links',
4129                    'provider_capabilities',
4130                    'query_cache'
4131               )",
4132            [],
4133            |row| row.get::<_, i64>(0),
4134        )?;
4135
4136        assert_eq!((store.schema_version()?, table_count), (1, 12));
4137        Ok(())
4138    }
4139
4140    #[test]
4141    fn exact_initial_schema_validation_should_be_repeatable()
4142    -> Result<(), Box<dyn std::error::Error>> {
4143        let mut connection = rusqlite::Connection::open_in_memory()?;
4144        super::initialize_empty_schema(&mut connection)?;
4145        super::validate_exact_schema(&connection)?;
4146        super::validate_exact_schema(&connection)?;
4147
4148        assert_eq!(super::schema_version(&connection)?, 1);
4149        Ok(())
4150    }
4151
4152    #[test]
4153    fn current_schema_should_enforce_tables_indexes_triggers_and_foreign_keys()
4154    -> Result<(), Box<dyn std::error::Error>> {
4155        let store = SqliteStore::in_memory()?;
4156        let strict_tables = store.connection.query_row(
4157            "SELECT COUNT(*), COALESCE(SUM(strict), 0)
4158             FROM pragma_table_list
4159             WHERE name IN ('manual_links', 'provider_capabilities', 'query_cache')",
4160            [],
4161            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
4162        )?;
4163        let indexes = store.connection.query_row(
4164            "SELECT COUNT(*) FROM sqlite_schema
4165             WHERE type = 'index'
4166               AND name IN (
4167                    'repo_snapshots_id_workspace_idx',
4168                    'manual_links_source_idx',
4169                    'manual_links_target_idx',
4170                    'manual_links_disposition_idx',
4171                    'provider_capabilities_repo_provider_idx',
4172                    'query_cache_snapshot_idx',
4173                    'query_cache_workspace_expiry_idx',
4174                    'query_cache_workspace_stored_idx'
4175               )",
4176            [],
4177            |row| row.get::<_, i64>(0),
4178        )?;
4179        let triggers = store.connection.query_row(
4180            "SELECT COUNT(*) FROM sqlite_schema
4181             WHERE type = 'trigger'
4182               AND name IN (
4183                    'provider_capabilities_require_registration',
4184                    'provider_capabilities_update_require_registration',
4185                    'query_cache_bound_workspace_entries'
4186               )",
4187            [],
4188            |row| row.get::<_, i64>(0),
4189        )?;
4190        let foreign_keys = ["manual_links", "provider_capabilities", "query_cache"]
4191            .into_iter()
4192            .try_fold(0_i64, |count, table| {
4193                store
4194                    .connection
4195                    .query_row(
4196                        &format!("SELECT COUNT(*) FROM pragma_foreign_key_list('{table}')"),
4197                        [],
4198                        |row| row.get::<_, i64>(0),
4199                    )
4200                    .map(|table_count| count + table_count)
4201            })?;
4202
4203        assert_eq!(
4204            (strict_tables, indexes, triggers, foreign_keys),
4205            ((3, 3), 8, 3, 9)
4206        );
4207        Ok(())
4208    }
4209
4210    #[test]
4211    fn exact_schema_should_reject_unknown_version() {
4212        let connection = match rusqlite::Connection::open_in_memory() {
4213            Ok(connection) => connection,
4214            Err(error) => panic!("test connection must initialize: {error}"),
4215        };
4216        let setup = connection.execute_batch(
4217            "CREATE TABLE schema_metadata (
4218                version INTEGER PRIMARY KEY,
4219                applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
4220             );
4221             INSERT INTO schema_metadata(version) VALUES (999);",
4222        );
4223        assert!(setup.is_ok(), "newer schema fixture failed: {setup:?}");
4224        let result = SqliteStore::from_connection(connection);
4225
4226        assert!(matches!(result, Err(StoreError::InvalidSchema)));
4227    }
4228
4229    #[test]
4230    fn exact_schema_should_reject_missing_schema_objects() -> Result<(), Box<dyn std::error::Error>>
4231    {
4232        let store = SqliteStore::in_memory()?;
4233        store
4234            .connection
4235            .execute_batch("DROP TRIGGER query_cache_bound_workspace_entries")?;
4236
4237        assert!(matches!(
4238            super::validate_exact_schema(&store.connection),
4239            Err(StoreError::InvalidSchema)
4240        ));
4241        Ok(())
4242    }
4243
4244    #[test]
4245    fn database_instance_identity_should_be_opaque_and_stable()
4246    -> Result<(), Box<dyn std::error::Error>> {
4247        let store = SqliteStore::in_memory()?;
4248        let first = store.database_instance_id()?;
4249        let second = store.database_instance_id()?;
4250
4251        assert_eq!(first, second);
4252        assert_eq!(first.len(), 64);
4253        assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit()));
4254        Ok(())
4255    }
4256
4257    #[test]
4258    fn database_with_incomplete_current_schema_should_be_rejected()
4259    -> Result<(), Box<dyn std::error::Error>> {
4260        let temporary = tempfile::tempdir()?;
4261        let database = temporary.path().join("invalid.db");
4262        let connection = rusqlite::Connection::open(&database)?;
4263        connection.execute_batch(INITIAL_SCHEMA)?;
4264        connection.execute_batch(
4265            "ALTER TABLE extractor_batches DROP COLUMN budget_fingerprint;
4266             ALTER TABLE extractor_batches DROP COLUMN source_was_lossy;",
4267        )?;
4268        drop(connection);
4269
4270        let result = SqliteStore::open(&database);
4271
4272        assert!(matches!(result, Err(StoreError::InvalidSchema)));
4273        Ok(())
4274    }
4275
4276    #[test]
4277    fn read_only_open_should_reject_non_exact_schema() -> Result<(), Box<dyn std::error::Error>> {
4278        let temporary = tempfile::tempdir()?;
4279        let database = temporary.path().join("uninitialized.db");
4280        let connection = rusqlite::Connection::open(&database)?;
4281        connection.execute_batch(
4282            "CREATE TABLE schema_metadata (
4283                version INTEGER PRIMARY KEY,
4284                applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
4285             );",
4286        )?;
4287        drop(connection);
4288
4289        let result = SqliteStore::open_read_only(&database);
4290
4291        assert!(matches!(result, Err(StoreError::InvalidSchema)));
4292        Ok(())
4293    }
4294
4295    #[test]
4296    fn workspace_registry_should_round_trip_native_paths() {
4297        let mut store = match SqliteStore::in_memory() {
4298            Ok(store) => store,
4299            Err(error) => panic!("test store must initialize: {error}"),
4300        };
4301        let expected = workspace();
4302        let result = store
4303            .save_workspace_registry(&expected)
4304            .and_then(|()| store.load_workspace_registry("commerce"));
4305
4306        assert!(matches!(result, Ok(actual) if actual == expected));
4307    }
4308
4309    #[test]
4310    fn workspace_registry_should_list_repository_counts() {
4311        let mut store = match SqliteStore::in_memory() {
4312            Ok(store) => store,
4313            Err(error) => panic!("test store must initialize: {error}"),
4314        };
4315        let result = store
4316            .save_workspace_registry(&workspace())
4317            .and_then(|()| store.list_workspaces());
4318
4319        assert!(matches!(
4320            result,
4321            Ok(workspaces)
4322                if workspaces.len() == 1
4323                    && workspaces[0].name == "commerce"
4324                    && workspaces[0].repository_count == 1
4325        ));
4326    }
4327
4328    #[test]
4329    fn workspace_registry_should_remove_and_collect_orphans() {
4330        let mut store = match SqliteStore::in_memory() {
4331            Ok(store) => store,
4332            Err(error) => panic!("test store must initialize: {error}"),
4333        };
4334        let result = store
4335            .save_workspace_registry(&workspace())
4336            .and_then(|()| store.remove_workspace("commerce"))
4337            .and_then(|removed| {
4338                let repositories =
4339                    store
4340                        .connection
4341                        .query_row("SELECT COUNT(*) FROM repositories", [], |row| {
4342                            row.get::<_, i64>(0)
4343                        })?;
4344                Ok((removed, store.workspace_exists("commerce")?, repositories))
4345            });
4346
4347        assert!(matches!(result, Ok((true, false, 0))));
4348    }
4349
4350    #[test]
4351    fn failed_snapshot_should_preserve_previous_current_snapshot() {
4352        let mut store = match SqliteStore::in_memory() {
4353            Ok(store) => store,
4354            Err(error) => panic!("test store must initialize: {error}"),
4355        };
4356        let workspace = workspace();
4357        let (nodes, edges, evidence) = fixture();
4358        let first = store.publish_snapshot(SnapshotBatch {
4359            workspace: &workspace,
4360            snapshot_id: "snapshot:valid",
4361            nodes: &nodes,
4362            edges: &edges,
4363            evidence: &evidence,
4364            fingerprints: &[],
4365            extractor_batches: &[],
4366            extractor_runs: &[],
4367            manual_links: &[],
4368            community_snapshot: None,
4369        });
4370        assert!(first.is_ok(), "valid fixture failed: {first:?}");
4371        let mut invalid_edges = edges;
4372        invalid_edges[0].evidence = vec![EvidenceId::new("evidence:missing")];
4373        let failed = store.publish_snapshot(SnapshotBatch {
4374            workspace: &workspace,
4375            snapshot_id: "snapshot:invalid",
4376            nodes: &nodes,
4377            edges: &invalid_edges,
4378            evidence: &evidence,
4379            fingerprints: &[],
4380            extractor_batches: &[],
4381            extractor_runs: &[],
4382            manual_links: &[],
4383            community_snapshot: None,
4384        });
4385        let result = failed
4386            .and_then(|()| store.load_current_graph("commerce"))
4387            .map(|_| false)
4388            .or_else(|_| {
4389                store
4390                    .load_current_graph("commerce")
4391                    .map(|(_, current_edges)| current_edges[0].id.as_str() == "edge:1")
4392            });
4393
4394        assert!(matches!(result, Ok(true)));
4395    }
4396
4397    #[test]
4398    fn open_should_initialize_schema_only_once() -> Result<(), Box<dyn std::error::Error>> {
4399        let temporary = tempfile::tempdir()?;
4400        let database = temporary.path().join("store.db");
4401
4402        let initial = SqliteStore::open(&database)?;
4403        assert_eq!(initial.schema_version()?, 1);
4404        drop(initial);
4405        let repeated = SqliteStore::open(&database)?;
4406        assert_eq!(repeated.schema_version()?, 1);
4407        Ok(())
4408    }
4409
4410    #[test]
4411    fn wal_should_allow_readers_during_snapshot_publication()
4412    -> Result<(), Box<dyn std::error::Error>> {
4413        let temporary = tempfile::tempdir()?;
4414        let database = temporary.path().join("concurrent.db");
4415        let workspace = workspace();
4416        let (nodes, edges, evidence) = fixture();
4417        let mut writer = SqliteStore::open(&database)?;
4418        writer.publish_snapshot(SnapshotBatch {
4419            workspace: &workspace,
4420            snapshot_id: "snapshot:0",
4421            nodes: &nodes,
4422            edges: &edges,
4423            evidence: &evidence,
4424            fingerprints: &[],
4425            extractor_batches: &[],
4426            extractor_runs: &[],
4427            manual_links: &[],
4428            community_snapshot: None,
4429        })?;
4430        let reader_paths = [database.clone(), database];
4431        let readers = reader_paths.map(|path| {
4432            std::thread::spawn(move || -> Result<(), String> {
4433                for _iteration in 0..25 {
4434                    SqliteStore::open_read_only(&path)
4435                        .and_then(|store| store.load_current_graph("commerce"))
4436                        .map_err(|error| error.to_string())?;
4437                }
4438                Ok(())
4439            })
4440        });
4441        for sequence in 1..=10 {
4442            writer.publish_snapshot(SnapshotBatch {
4443                workspace: &workspace,
4444                snapshot_id: &format!("snapshot:{sequence}"),
4445                nodes: &nodes,
4446                edges: &edges,
4447                evidence: &evidence,
4448                fingerprints: &[],
4449                extractor_batches: &[],
4450                extractor_runs: &[],
4451                manual_links: &[],
4452                community_snapshot: None,
4453            })?;
4454        }
4455        let reader_results = readers.map(|reader| {
4456            reader
4457                .join()
4458                .map_err(|_| "reader thread panicked".to_owned())
4459                .and_then(|result| result)
4460        });
4461
4462        assert!(reader_results.into_iter().all(|result| result.is_ok()));
4463        Ok(())
4464    }
4465
4466    #[test]
4467    fn writer_lock_should_reject_second_active_owner() -> Result<(), Box<dyn std::error::Error>> {
4468        let temporary = tempfile::tempdir()?;
4469        let database = temporary.path().join("store.db");
4470        let first = StoreLock::acquire(&database, Duration::from_mins(1))?;
4471        let second = StoreLock::acquire(&database, Duration::from_mins(1));
4472
4473        assert!(matches!(second, Err(StoreError::LockHeld(_))));
4474        drop(first);
4475        Ok(())
4476    }
4477
4478    #[test]
4479    fn writer_lock_should_recover_expired_sidecar() -> Result<(), Box<dyn std::error::Error>> {
4480        let temporary = tempfile::tempdir()?;
4481        let database = temporary.path().join("store.db");
4482        let sidecar = lock_path(&database);
4483        fs::write(&sidecar, "expired-owner")?;
4484        std::thread::sleep(Duration::from_millis(5));
4485        let lock = StoreLock::acquire(&database, Duration::from_millis(1));
4486
4487        assert!(lock.is_ok(), "stale lock was not recovered: {lock:?}");
4488        Ok(())
4489    }
4490
4491    #[test]
4492    fn writer_lock_should_not_reclaim_live_owner_after_threshold()
4493    -> Result<(), Box<dyn std::error::Error>> {
4494        let temporary = tempfile::tempdir()?;
4495        let database = temporary.path().join("store.db");
4496        let first = StoreLock::acquire(&database, Duration::ZERO)?;
4497        std::thread::sleep(Duration::from_millis(2));
4498        let second = StoreLock::acquire(&database, Duration::ZERO);
4499
4500        assert!(matches!(second, Err(StoreError::LockHeld(_))));
4501        drop(first);
4502        Ok(())
4503    }
4504
4505    #[test]
4506    fn multiprocess_lock_helper() -> Result<(), Box<dyn std::error::Error>> {
4507        if std::env::var(LOCK_HELPER_ENV).as_deref() != Ok("1") {
4508            return Ok(());
4509        }
4510        let database = std::env::var_os(LOCK_DATABASE_ENV)
4511            .map(std::path::PathBuf::from)
4512            .ok_or_else(|| std::io::Error::other("lock database environment is missing"))?;
4513        let ready = std::env::var_os(LOCK_READY_ENV)
4514            .map(std::path::PathBuf::from)
4515            .ok_or_else(|| std::io::Error::other("lock ready environment is missing"))?;
4516        let _lock = StoreLock::acquire(&database, Duration::from_hours(1))?;
4517        fs::write(ready, "ready")?;
4518        std::thread::sleep(Duration::from_secs(30));
4519        Ok(())
4520    }
4521
4522    #[test]
4523    fn multiprocess_lock_should_recover_after_abrupt_owner_exit()
4524    -> Result<(), Box<dyn std::error::Error>> {
4525        let temporary = tempfile::tempdir()?;
4526        let database = temporary.path().join("store.db");
4527        let ready = temporary.path().join("ready");
4528        let executable = std::env::current_exe()?;
4529        let mut child = Command::new(executable)
4530            .args(["--exact", "tests::multiprocess_lock_helper", "--nocapture"])
4531            .env(LOCK_HELPER_ENV, "1")
4532            .env(LOCK_DATABASE_ENV, &database)
4533            .env(LOCK_READY_ENV, &ready)
4534            .spawn()?;
4535        let mut helper_ready = false;
4536        for _attempt in 0..200 {
4537            if ready.exists() {
4538                helper_ready = true;
4539                break;
4540            }
4541            std::thread::sleep(Duration::from_millis(5));
4542        }
4543        if !helper_ready {
4544            child.kill()?;
4545            let _status = child.wait()?;
4546            return Err(std::io::Error::other("lock helper did not become ready").into());
4547        }
4548        let while_alive = StoreLock::acquire(&database, Duration::ZERO);
4549        child.kill()?;
4550        let _status = child.wait()?;
4551        let after_exit = StoreLock::acquire(&database, Duration::ZERO);
4552
4553        assert_eq!(
4554            (
4555                matches!(while_alive, Err(StoreError::LockHeld(_))),
4556                after_exit.is_ok(),
4557            ),
4558            (true, true)
4559        );
4560        Ok(())
4561    }
4562
4563    #[test]
4564    fn published_snapshot_should_record_repository_freshness() {
4565        let mut store = match SqliteStore::in_memory() {
4566            Ok(store) => store,
4567            Err(error) => panic!("test store must initialize: {error}"),
4568        };
4569        let workspace = workspace();
4570        let (nodes, edges, evidence) = fixture();
4571        let result = store
4572            .publish_snapshot(SnapshotBatch {
4573                workspace: &workspace,
4574                snapshot_id: "snapshot:1",
4575                nodes: &nodes,
4576                edges: &edges,
4577                evidence: &evidence,
4578                fingerprints: &[],
4579                extractor_batches: &[],
4580                extractor_runs: &[],
4581                manual_links: &[],
4582                community_snapshot: None,
4583            })
4584            .and_then(|()| store.load_current_freshness("commerce"));
4585
4586        assert!(matches!(
4587            result,
4588            Ok(freshness)
4589                if freshness.len() == 1
4590                    && freshness[0].state == RepoFreshnessState::Fresh
4591        ));
4592    }
4593
4594    #[test]
4595    fn incremental_artifacts_and_runs_should_round_trip() {
4596        let mut store = match SqliteStore::in_memory() {
4597            Ok(store) => store,
4598            Err(error) => panic!("test store must initialize: {error}"),
4599        };
4600        let workspace = workspace();
4601        let (nodes, edges, evidence) = fixture();
4602        let (fingerprint, run) = incremental_fixture();
4603        let extractor_batch = StoredExtractorBatch {
4604            source: fingerprint.clone(),
4605            extractor_version: "1.0.0".to_owned(),
4606            budget_fingerprint: "extraction-budgets:test".to_owned(),
4607            source_was_lossy: false,
4608            output_count: 1,
4609            payload: br#"{"observations":["GET:/orders"]}"#.to_vec(),
4610        };
4611        let result = store
4612            .publish_snapshot(SnapshotBatch {
4613                workspace: &workspace,
4614                snapshot_id: "snapshot:1",
4615                nodes: &nodes,
4616                edges: &edges,
4617                evidence: &evidence,
4618                fingerprints: std::slice::from_ref(&fingerprint),
4619                extractor_batches: std::slice::from_ref(&extractor_batch),
4620                extractor_runs: std::slice::from_ref(&run),
4621                manual_links: &[],
4622                community_snapshot: None,
4623            })
4624            .and_then(|()| {
4625                Ok((
4626                    store.load_current_artifact_fingerprints("commerce")?,
4627                    store.load_current_extractor_batches("commerce")?,
4628                    store.load_current_extractor_runs("commerce")?,
4629                ))
4630            });
4631
4632        assert!(matches!(
4633            result,
4634            Ok((fingerprints, batches, runs))
4635                if fingerprints == vec![fingerprint]
4636                    && batches == vec![extractor_batch]
4637                    && runs == vec![run]
4638        ));
4639        assert!(
4640            store
4641                .load_current_extractor_batches_with_limit("commerce", 1)
4642                .expect("bounded batch read")
4643                .is_empty(),
4644            "SQLite must omit an oversized payload before copying its BLOB"
4645        );
4646    }
4647
4648    #[test]
4649    fn manual_links_should_round_trip_and_preserve_snapshot_history()
4650    -> Result<(), Box<dyn std::error::Error>> {
4651        let mut store = SqliteStore::in_memory()?;
4652        let workspace = workspace();
4653        let (nodes, edges, evidence) = fixture();
4654        let historical_links = manual_link_fixture("snapshot:historical-links");
4655        store.publish_snapshot(SnapshotBatch {
4656            workspace: &workspace,
4657            snapshot_id: "snapshot:historical-links",
4658            nodes: &nodes,
4659            edges: &edges,
4660            evidence: &evidence,
4661            fingerprints: &[],
4662            extractor_batches: &[],
4663            extractor_runs: &[],
4664            manual_links: &historical_links,
4665            community_snapshot: None,
4666        })?;
4667        store.publish_snapshot(SnapshotBatch {
4668            workspace: &workspace,
4669            snapshot_id: "snapshot:current-links",
4670            nodes: &nodes,
4671            edges: &edges,
4672            evidence: &evidence,
4673            fingerprints: &[],
4674            extractor_batches: &[],
4675            extractor_runs: &[],
4676            manual_links: &[],
4677            community_snapshot: None,
4678        })?;
4679        let mut current_links = manual_link_fixture("snapshot:current-links");
4680        current_links.truncate(1);
4681        store.persist_manual_links("snapshot:current-links", &current_links)?;
4682
4683        let stored_historical = store.load_manual_links("snapshot:historical-links")?;
4684        let stored_current = store.load_manual_links("snapshot:current-links")?;
4685
4686        assert_eq!(
4687            (stored_historical, stored_current),
4688            (historical_links, current_links)
4689        );
4690        Ok(())
4691    }
4692
4693    #[test]
4694    fn provider_capabilities_should_upsert_and_round_trip_without_provider_payloads()
4695    -> Result<(), Box<dyn std::error::Error>> {
4696        let mut store = SqliteStore::in_memory()?;
4697        store.save_workspace_registry(&workspace())?;
4698        let mut expected = ProviderCapabilityRecord {
4699            workspace_name: "commerce".to_owned(),
4700            repo_id: RepoId::new("repo:web"),
4701            provider: "codegraph".to_owned(),
4702            provider_version: "1.2.3".to_owned(),
4703            capabilities: vec!["call_paths".to_owned(), "symbols".to_owned()],
4704            observed_at_unix_ms: 100,
4705        };
4706        store.upsert_provider_capabilities(&expected)?;
4707        expected.capabilities = vec!["symbols".to_owned()];
4708        expected.observed_at_unix_ms = 200;
4709        store.upsert_provider_capabilities(&expected)?;
4710
4711        let actual = store.load_provider_capabilities(
4712            "commerce",
4713            &RepoId::new("repo:web"),
4714            "codegraph",
4715            "1.2.3",
4716        )?;
4717
4718        assert_eq!(actual, Some(expected));
4719        Ok(())
4720    }
4721
4722    #[test]
4723    fn clear_query_cache_should_remove_only_selected_workspace()
4724    -> Result<(), Box<dyn std::error::Error>> {
4725        let mut store = SqliteStore::in_memory()?;
4726        let workspace = workspace();
4727        let (nodes, edges, evidence) = fixture();
4728        store.publish_snapshot(SnapshotBatch {
4729            workspace: &workspace,
4730            snapshot_id: "snapshot:cache-commerce",
4731            nodes: &nodes,
4732            edges: &edges,
4733            evidence: &evidence,
4734            fingerprints: &[],
4735            extractor_batches: &[],
4736            extractor_runs: &[],
4737            manual_links: &[],
4738            community_snapshot: None,
4739        })?;
4740        let mut other_workspace = workspace.clone();
4741        other_workspace.id = WorkspaceId::new("workspace:other");
4742        other_workspace.name = "other".to_owned();
4743        store.publish_snapshot(SnapshotBatch {
4744            workspace: &other_workspace,
4745            snapshot_id: "snapshot:cache-other",
4746            nodes: &nodes,
4747            edges: &edges,
4748            evidence: &evidence,
4749            fingerprints: &[],
4750            extractor_batches: &[],
4751            extractor_runs: &[],
4752            manual_links: &[],
4753            community_snapshot: None,
4754        })?;
4755        for (workspace_name, snapshot_id) in [
4756            ("commerce", "snapshot:cache-commerce"),
4757            ("other", "snapshot:cache-other"),
4758        ] {
4759            store.connection.execute(
4760                "INSERT INTO query_cache(
4761                    workspace_name, snapshot_id, input_fingerprint, result_summary_json,
4762                    stored_at_unix_ms
4763                 ) VALUES (?1, ?2, 'input:1', ?3, 1)",
4764                params![workspace_name, snapshot_id, b"{}".as_slice()],
4765            )?;
4766        }
4767
4768        let removed = store.clear_query_cache("commerce")?;
4769        let commerce_count = store.connection.query_row(
4770            "SELECT COUNT(*) FROM query_cache WHERE workspace_name = 'commerce'",
4771            [],
4772            |row| row.get::<_, i64>(0),
4773        )?;
4774        let other_count = store.connection.query_row(
4775            "SELECT COUNT(*) FROM query_cache WHERE workspace_name = 'other'",
4776            [],
4777            |row| row.get::<_, i64>(0),
4778        )?;
4779
4780        assert_eq!((removed, commerce_count, other_count), (1, 0, 1));
4781        Ok(())
4782    }
4783
4784    #[test]
4785    fn query_cache_should_round_trip_exact_unexpired_result()
4786    -> Result<(), Box<dyn std::error::Error>> {
4787        let mut store = SqliteStore::in_memory()?;
4788        let workspace = workspace();
4789        let (nodes, edges, evidence) = fixture();
4790        store.publish_snapshot(SnapshotBatch {
4791            workspace: &workspace,
4792            snapshot_id: "snapshot:query-cache",
4793            nodes: &nodes,
4794            edges: &edges,
4795            evidence: &evidence,
4796            fingerprints: &[],
4797            extractor_batches: &[],
4798            extractor_runs: &[],
4799            manual_links: &[],
4800            community_snapshot: None,
4801        })?;
4802        let expected = QueryCacheRecord {
4803            workspace_name: "commerce".to_owned(),
4804            snapshot_id: "snapshot:query-cache".to_owned(),
4805            input_fingerprint: "query:one".to_owned(),
4806            result_summary_json: br#"{"schema_version":1}"#.to_vec(),
4807            stored_at_unix_ms: 100,
4808            expires_at_unix_ms: Some(200),
4809        };
4810        store.put_query_cache(&expected)?;
4811
4812        let current =
4813            store.load_query_cache("commerce", "snapshot:query-cache", "query:one", 150)?;
4814        let expired =
4815            store.load_query_cache("commerce", "snapshot:query-cache", "query:one", 201)?;
4816
4817        assert_eq!((current, expired), (Some(expected), None));
4818        Ok(())
4819    }
4820
4821    #[test]
4822    fn query_cache_should_bound_entries_per_workspace() -> Result<(), Box<dyn std::error::Error>> {
4823        let mut store = SqliteStore::in_memory()?;
4824        let workspace = workspace();
4825        let (nodes, edges, evidence) = fixture();
4826        store.publish_snapshot(SnapshotBatch {
4827            workspace: &workspace,
4828            snapshot_id: "snapshot:bounded-cache",
4829            nodes: &nodes,
4830            edges: &edges,
4831            evidence: &evidence,
4832            fingerprints: &[],
4833            extractor_batches: &[],
4834            extractor_runs: &[],
4835            manual_links: &[],
4836            community_snapshot: None,
4837        })?;
4838        for sequence in 0..=1024 {
4839            store.connection.execute(
4840                "INSERT INTO query_cache(
4841                    workspace_name, snapshot_id, input_fingerprint, result_summary_json,
4842                    stored_at_unix_ms
4843                 ) VALUES ('commerce', 'snapshot:bounded-cache', ?1, ?2, ?3)",
4844                params![
4845                    format!("input:{sequence}"),
4846                    b"{}".as_slice(),
4847                    i64::from(sequence)
4848                ],
4849            )?;
4850        }
4851        let bounded = store.connection.query_row(
4852            "SELECT COUNT(*), MIN(stored_at_unix_ms) FROM query_cache
4853             WHERE workspace_name = 'commerce'",
4854            [],
4855            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
4856        )?;
4857
4858        assert_eq!(bounded, (1024, 1));
4859        Ok(())
4860    }
4861
4862    #[test]
4863    fn fts_should_search_only_current_snapshot_nodes() {
4864        let mut store = match SqliteStore::in_memory() {
4865            Ok(store) => store,
4866            Err(error) => panic!("test store must initialize: {error}"),
4867        };
4868        let workspace = workspace();
4869        let (nodes, edges, evidence) = fixture();
4870        let result = store
4871            .publish_snapshot(SnapshotBatch {
4872                workspace: &workspace,
4873                snapshot_id: "snapshot:1",
4874                nodes: &nodes,
4875                edges: &edges,
4876                evidence: &evidence,
4877                fingerprints: &[],
4878                extractor_batches: &[],
4879                extractor_runs: &[],
4880                manual_links: &[],
4881                community_snapshot: None,
4882            })
4883            .and_then(|()| store.search_current_nodes("commerce", "orders", 10));
4884
4885        assert!(matches!(result, Ok(matches) if matches.len() == 2));
4886    }
4887
4888    #[test]
4889    fn ranked_fts_should_order_equal_scores_by_stable_node_id() {
4890        let mut store = match SqliteStore::in_memory() {
4891            Ok(store) => store,
4892            Err(error) => panic!("test store must initialize: {error}"),
4893        };
4894        let workspace = workspace();
4895        let (nodes, edges, evidence) = fixture();
4896        let setup = store.publish_snapshot(SnapshotBatch {
4897            workspace: &workspace,
4898            snapshot_id: "snapshot:ranked",
4899            nodes: &nodes,
4900            edges: &edges,
4901            evidence: &evidence,
4902            fingerprints: &[],
4903            extractor_batches: &[],
4904            extractor_runs: &[],
4905            manual_links: &[],
4906            community_snapshot: None,
4907        });
4908        assert!(setup.is_ok(), "ranked FTS fixture failed: {setup:?}");
4909
4910        let first = store.search_current_nodes_ranked("commerce", "orders", 500);
4911        let second = store.search_current_nodes_ranked("commerce", "orders", 500);
4912
4913        assert!(matches!(
4914            (first, second),
4915            (Ok(first), Ok(second))
4916                if first == second
4917                    && first.len() == 2
4918                    && first[0].node.id.as_str() == "node:api"
4919                    && first[1].node.id.as_str() == "node:web"
4920                    && first[0].fts_rank.to_bits() == first[1].fts_rank.to_bits()
4921        ));
4922    }
4923
4924    #[test]
4925    fn fts_should_cleanup_replaced_and_removed_snapshots() {
4926        let mut store = match SqliteStore::in_memory() {
4927            Ok(store) => store,
4928            Err(error) => panic!("test store must initialize: {error}"),
4929        };
4930        let workspace = workspace();
4931        let (nodes, edges, evidence) = fixture();
4932        let publish = |store: &mut SqliteStore| {
4933            store.publish_snapshot(SnapshotBatch {
4934                workspace: &workspace,
4935                snapshot_id: "snapshot:same",
4936                nodes: &nodes,
4937                edges: &edges,
4938                evidence: &evidence,
4939                fingerprints: &[],
4940                extractor_batches: &[],
4941                extractor_runs: &[],
4942                manual_links: &[],
4943                community_snapshot: None,
4944            })
4945        };
4946        let result = publish(&mut store)
4947            .and_then(|()| publish(&mut store))
4948            .and_then(|()| {
4949                let before_remove =
4950                    store
4951                        .connection
4952                        .query_row("SELECT COUNT(*) FROM nodes_fts", [], |row| {
4953                            row.get::<_, i64>(0)
4954                        })?;
4955                store.remove_workspace("commerce")?;
4956                let after_remove = store
4957                    .connection
4958                    .query_row("SELECT COUNT(*) FROM nodes_fts", [], |row| {
4959                        row.get::<_, i64>(0)
4960                    })
4961                    .map_err(StoreError::from)?;
4962                Ok((before_remove, after_remove))
4963            });
4964
4965        assert!(matches!(result, Ok((2, 0))));
4966    }
4967}