Skip to main content

pylon_storage/
sqlite.rs

1use std::collections::BTreeMap;
2
3use rusqlite::Connection;
4use serde::Serialize;
5
6use crate::{
7    ColumnSnapshot, FieldSpec, IndexSnapshot, SchemaOperation, SchemaPlan, SchemaSnapshot,
8    StorageAdapter, StorageError, TableSnapshot,
9};
10use pylon_kernel::AppManifest;
11
12// ---------------------------------------------------------------------------
13// Type mapping: manifest field types -> SQLite column types
14//
15//   string    -> TEXT
16//   int       -> INTEGER
17//   float     -> REAL
18//   bool      -> INTEGER
19//   datetime  -> TEXT
20//   richtext  -> TEXT
21//   id(...)   -> TEXT
22// ---------------------------------------------------------------------------
23
24fn sqlite_column_type(field_type: &str) -> &'static str {
25    match field_type {
26        "string" => "TEXT",
27        "int" => "INTEGER",
28        "float" => "REAL",
29        "bool" => "INTEGER",
30        "datetime" => "TEXT",
31        "richtext" => "TEXT",
32        _ if field_type.starts_with("id(") => "TEXT",
33        _ => "TEXT",
34    }
35}
36
37// ---------------------------------------------------------------------------
38// SQL identifier quoting
39// ---------------------------------------------------------------------------
40
41/// Quote a SQLite identifier using double-quotes, escaping any embedded
42/// double-quote characters by doubling them (SQL standard).
43fn quote_ident(name: &str) -> String {
44    format!("\"{}\"", name.replace('"', "\"\""))
45}
46
47// ---------------------------------------------------------------------------
48// SQL generation
49// ---------------------------------------------------------------------------
50
51/// Generate a CREATE TABLE statement for an entity.
52pub fn create_table_sql(entity_name: &str, fields: &[FieldSpec]) -> String {
53    let mut columns = vec!["id TEXT PRIMARY KEY NOT NULL".to_string()];
54
55    for field in fields {
56        let col_type = sqlite_column_type(&field.field_type);
57        let not_null = if field.optional { "" } else { " NOT NULL" };
58        let unique = if field.unique { " UNIQUE" } else { "" };
59        columns.push(format!(
60            "{} {}{}{}",
61            quote_ident(&field.name),
62            col_type,
63            not_null,
64            unique
65        ));
66    }
67
68    format!(
69        "CREATE TABLE IF NOT EXISTS {} ({})",
70        quote_ident(entity_name),
71        columns.join(", ")
72    )
73}
74
75/// Generate an ALTER TABLE ADD COLUMN statement.
76pub fn add_column_sql(entity_name: &str, field: &FieldSpec) -> String {
77    let col_type = sqlite_column_type(&field.field_type);
78    // SQLite ALTER TABLE ADD COLUMN does not support NOT NULL without a default for existing rows.
79    // For optional fields, omit NOT NULL. For required fields, we still omit NOT NULL here
80    // because SQLite requires a default value for ADD COLUMN NOT NULL.
81    let unique = if field.unique { " UNIQUE" } else { "" };
82    format!(
83        "ALTER TABLE {} ADD COLUMN {} {}{}",
84        quote_ident(entity_name),
85        quote_ident(&field.name),
86        col_type,
87        unique,
88    )
89}
90
91/// Generate a CREATE INDEX statement.
92pub fn create_index_sql(
93    entity_name: &str,
94    index_name: &str,
95    fields: &[String],
96    unique: bool,
97) -> String {
98    let unique_str = if unique { "UNIQUE " } else { "" };
99    let full_index_name = format!("{}_{}", entity_name, index_name);
100    let quoted_fields: Vec<String> = fields.iter().map(|f| quote_ident(f)).collect();
101    format!(
102        "CREATE {}INDEX IF NOT EXISTS {} ON {} ({})",
103        unique_str,
104        quote_ident(&full_index_name),
105        quote_ident(entity_name),
106        quoted_fields.join(", ")
107    )
108}
109
110// ---------------------------------------------------------------------------
111// SqliteAdapter
112// ---------------------------------------------------------------------------
113
114pub struct SqliteAdapter {
115    conn: Connection,
116}
117
118impl SqliteAdapter {
119    /// Open or create a SQLite database at the given path.
120    pub fn open(path: &str) -> Result<Self, StorageError> {
121        let conn = Connection::open(path).map_err(|e| StorageError {
122            code: "SQLITE_OPEN_FAILED".into(),
123            message: format!("Failed to open SQLite database at {path}: {e}"),
124        })?;
125        tune_connection(&conn, /* in_memory */ false)?;
126        Ok(Self { conn })
127    }
128
129    /// Create an in-memory SQLite database.
130    pub fn in_memory() -> Result<Self, StorageError> {
131        let conn = Connection::open_in_memory().map_err(|e| StorageError {
132            code: "SQLITE_OPEN_FAILED".into(),
133            message: format!("Failed to open in-memory SQLite database: {e}"),
134        })?;
135        tune_connection(&conn, /* in_memory */ true)?;
136        Ok(Self { conn })
137    }
138}
139
140/// Apply the production pragma set on a freshly opened SQLite
141/// connection. The defaults SQLite ships with are conservative — a
142/// 5-page cache, full fsync per commit, no mmap. The values below are
143/// what every pragma-tuning post on the internet recommends and they
144/// move the needle by 5–10× on write-heavy workloads:
145///
146/// - `journal_mode=WAL`: writers don't block readers and vice versa.
147///   Critical for live queries that need to read while the change-log
148///   thread is appending.
149/// - `synchronous=NORMAL`: fsync at WAL checkpoint boundaries instead
150///   of every commit. Trades ~10ms of unflushed writes on power loss
151///   for a ~3× write throughput win. The DB file itself remains
152///   consistent — only recently-committed transactions can be lost.
153/// - `cache_size=-65536` (negative = KB): 64MB page cache. Every B-tree
154///   walk that hits cache skips a syscall. Default of 2MB drops cache
155///   on every backup or schema query.
156/// - `mmap_size=268435456`: memory-map the first 256MB of the database
157///   for reads. Bypasses the read() syscall and OS page cache double-
158///   buffering for the hot pages.
159/// - `temp_store=MEMORY`: temp tables (used by sort + GROUP BY +
160///   the search planner's `_search_hits` projection) live in RAM, not
161///   in the temp dir.
162/// - `busy_timeout=5000`: when a write conflicts with another
163///   connection's transaction, wait 5s before erroring. With WAL the
164///   only conflicts are schema migrations.
165/// - `foreign_keys=ON`: SQLite has FK declarations off by default.
166///   Defensive even though Pylon's policies enforce ownership above.
167///
168/// In-memory databases skip the persistence-relevant pragmas (WAL,
169/// synchronous, mmap) since none apply.
170fn tune_connection(conn: &Connection, in_memory: bool) -> Result<(), StorageError> {
171    let pragmas: &[(&str, &str)] = if in_memory {
172        &[
173            ("temp_store", "MEMORY"),
174            ("cache_size", "-65536"),
175            ("foreign_keys", "ON"),
176        ]
177    } else {
178        &[
179            ("journal_mode", "WAL"),
180            ("synchronous", "NORMAL"),
181            ("cache_size", "-65536"),
182            ("mmap_size", "268435456"),
183            ("temp_store", "MEMORY"),
184            ("busy_timeout", "5000"),
185            ("foreign_keys", "ON"),
186            // Auto-checkpoint every 1000 pages (~4MB). Smaller values
187            // keep WAL bounded for backup/replication; larger values
188            // amortize fsync better. 1000 is the SQLite default; we
189            // set it explicitly so it isn't surprising.
190            ("wal_autocheckpoint", "1000"),
191        ]
192    };
193    for (key, value) in pragmas {
194        conn.pragma_update(None, key, value)
195            .map_err(|e| StorageError {
196                code: "SQLITE_PRAGMA_FAILED".into(),
197                message: format!("PRAGMA {key}={value} failed: {e}"),
198            })?;
199    }
200    Ok(())
201}
202
203impl SqliteAdapter {
204    /// Plan schema changes by comparing the live DB state against the target manifest.
205    /// Only plans additive operations: CreateEntity, AddField, AddIndex.
206    pub fn plan_from_live(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
207        let snapshot = self.read_schema()?;
208        Ok(crate::plan_from_snapshot(&snapshot, target))
209    }
210}
211
212impl StorageAdapter for SqliteAdapter {
213    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
214        // Plan from live DB state.
215        self.plan_from_live(target)
216    }
217
218    fn apply_schema(&self, plan: &SchemaPlan) -> Result<(), StorageError> {
219        // Wrap the whole plan in a single transaction so that if operation N
220        // fails, operations 1..N are rolled back. Without this, a partial
221        // migration would leave the database in an inconsistent state that
222        // doesn't match either the old or the new manifest.
223        self.conn.execute("BEGIN", []).map_err(|e| StorageError {
224            code: "SQLITE_EXEC_FAILED".into(),
225            message: format!("BEGIN failed: {e}"),
226        })?;
227        match self.apply_schema_impl(plan) {
228            Ok(()) => {
229                self.conn.execute("COMMIT", []).map_err(|e| StorageError {
230                    code: "SQLITE_EXEC_FAILED".into(),
231                    message: format!("COMMIT failed after apply: {e}"),
232                })?;
233                Ok(())
234            }
235            Err(e) => {
236                if let Err(rb) = self.conn.execute("ROLLBACK", []) {
237                    // Log both — a failed rollback leaves the connection in
238                    // a broken state but the original error is what the
239                    // caller cares about.
240                    tracing::warn!("[sqlite] ROLLBACK after apply error failed: {rb}");
241                }
242                Err(e)
243            }
244        }
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Migration history
250// ---------------------------------------------------------------------------
251
252const HISTORY_TABLE: &str = "_pylon_schema_history";
253
254/// A single row from the schema push history table.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
256pub struct HistoryEntry {
257    pub id: String,
258    pub manifest_version: i64,
259    pub app_version: String,
260    pub applied_at: String,
261    pub operation_count: i64,
262    pub baseline: String,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub plan: Option<SchemaPlan>,
265    pub plan_json: String,
266}
267
268/// Metadata for a schema push event.
269pub struct PushMetadata<'a> {
270    pub manifest_version: u32,
271    pub app_version: &'a str,
272    pub baseline: &'a str,
273}
274
275impl SqliteAdapter {
276    /// Ensure the history table exists.
277    fn ensure_history_table(&self) -> Result<(), StorageError> {
278        let sql = format!(
279            "CREATE TABLE IF NOT EXISTS {} (\
280                id TEXT PRIMARY KEY NOT NULL, \
281                manifest_version INTEGER NOT NULL, \
282                app_version TEXT NOT NULL, \
283                applied_at TEXT NOT NULL, \
284                operation_count INTEGER NOT NULL, \
285                baseline TEXT NOT NULL, \
286                plan_json TEXT NOT NULL\
287            )",
288            quote_ident(HISTORY_TABLE)
289        );
290        self.conn.execute(&sql, []).map_err(|e| StorageError {
291            code: "SQLITE_EXEC_FAILED".into(),
292            message: format!("Failed to create history table: {e}"),
293        })?;
294        Ok(())
295    }
296
297    /// Apply a schema plan and record the push in the history table —
298    /// atomically. If either the DDL or the history INSERT fails, the
299    /// whole transaction rolls back so the database never ends up with a
300    /// schema change that has no history row, or a history row that
301    /// points at a failed migration.
302    pub fn apply_with_history(
303        &self,
304        plan: &SchemaPlan,
305        meta: &PushMetadata<'_>,
306    ) -> Result<(), StorageError> {
307        // History table creation runs OUTSIDE the transaction because
308        // CREATE TABLE IF NOT EXISTS is a cheap idempotent bootstrap and
309        // can safely predate the real migration atomicity boundary.
310        self.ensure_history_table()?;
311
312        self.conn.execute("BEGIN", []).map_err(|e| StorageError {
313            code: "SQLITE_EXEC_FAILED".into(),
314            message: format!("BEGIN failed: {e}"),
315        })?;
316
317        let result = (|| -> Result<(), StorageError> {
318            self.apply_schema_impl(plan)?;
319
320            let plan_json = serde_json::to_string(plan).map_err(|e| StorageError {
321                code: "SQLITE_SERIALIZE_FAILED".into(),
322                message: format!("Failed to serialize plan: {e}"),
323            })?;
324
325            let id = generate_push_id();
326            let now = now_iso8601();
327            let op_count = plan
328                .operations
329                .iter()
330                .filter(|op| !matches!(op, SchemaOperation::Noop))
331                .count() as i64;
332
333            self.conn
334                .execute(
335                    &format!(
336                        "INSERT INTO {} (id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json) \
337                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
338                        quote_ident(HISTORY_TABLE)
339                    ),
340                    rusqlite::params![
341                        id,
342                        meta.manifest_version as i64,
343                        meta.app_version,
344                        now,
345                        op_count,
346                        meta.baseline,
347                        plan_json,
348                    ],
349                )
350                .map_err(|e| StorageError {
351                    code: "SQLITE_EXEC_FAILED".into(),
352                    message: format!("Failed to insert history row: {e}"),
353                })?;
354            Ok(())
355        })();
356
357        match result {
358            Ok(()) => {
359                self.conn.execute("COMMIT", []).map_err(|e| StorageError {
360                    code: "SQLITE_EXEC_FAILED".into(),
361                    message: format!("COMMIT failed: {e}"),
362                })?;
363                Ok(())
364            }
365            Err(e) => {
366                if let Err(rb) = self.conn.execute("ROLLBACK", []) {
367                    tracing::warn!("[sqlite] ROLLBACK after apply_with_history error failed: {rb}");
368                }
369                Err(e)
370            }
371        }
372    }
373
374    /// Read schema push history, newest-first.
375    /// Returns empty vec if the history table does not exist.
376    pub fn read_history(&self, limit: Option<u32>) -> Result<Vec<HistoryEntry>, StorageError> {
377        if !self.history_table_exists()? {
378            return Ok(Vec::new());
379        }
380
381        let quoted = quote_ident(HISTORY_TABLE);
382        let sql = match limit {
383            Some(n) => format!(
384                "SELECT id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json \
385                 FROM {} ORDER BY id DESC LIMIT {}",
386                quoted, n
387            ),
388            None => format!(
389                "SELECT id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json \
390                 FROM {} ORDER BY id DESC",
391                quoted
392            ),
393        };
394
395        let mut stmt = self.conn.prepare_cached(&sql).map_err(sqlite_err)?;
396
397        let entries = stmt
398            .query_map([], |row| {
399                let plan_json: String = row.get(6)?;
400                let plan = serde_json::from_str(&plan_json).ok();
401                Ok(HistoryEntry {
402                    id: row.get(0)?,
403                    manifest_version: row.get(1)?,
404                    app_version: row.get(2)?,
405                    applied_at: row.get(3)?,
406                    operation_count: row.get(4)?,
407                    baseline: row.get(5)?,
408                    plan,
409                    plan_json,
410                })
411            })
412            .map_err(sqlite_err)?
413            .collect::<Result<Vec<_>, _>>()
414            .map_err(sqlite_err)?;
415
416        Ok(entries)
417    }
418
419    /// Read a single history entry by ID.
420    /// Returns None if the history table doesn't exist or the ID is not found.
421    pub fn read_history_entry(&self, entry_id: &str) -> Result<Option<HistoryEntry>, StorageError> {
422        if !self.history_table_exists()? {
423            return Ok(None);
424        }
425
426        let mut stmt = self
427            .conn
428            .prepare(&format!(
429                "SELECT id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json \
430                 FROM {} WHERE id = ?1",
431                quote_ident(HISTORY_TABLE)
432            ))
433            .map_err(sqlite_err)?;
434
435        let mut rows = stmt
436            .query_map([entry_id], |row| {
437                let plan_json: String = row.get(6)?;
438                let plan = serde_json::from_str(&plan_json).ok();
439                Ok(HistoryEntry {
440                    id: row.get(0)?,
441                    manifest_version: row.get(1)?,
442                    app_version: row.get(2)?,
443                    applied_at: row.get(3)?,
444                    operation_count: row.get(4)?,
445                    baseline: row.get(5)?,
446                    plan,
447                    plan_json,
448                })
449            })
450            .map_err(sqlite_err)?;
451
452        match rows.next() {
453            Some(Ok(entry)) => Ok(Some(entry)),
454            Some(Err(e)) => Err(sqlite_err(e)),
455            None => Ok(None),
456        }
457    }
458
459    fn history_table_exists(&self) -> Result<bool, StorageError> {
460        let exists: bool = self
461            .conn
462            .query_row(
463                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
464                [HISTORY_TABLE],
465                |row| row.get::<_, i64>(0),
466            )
467            .map_err(sqlite_err)?
468            > 0;
469        Ok(exists)
470    }
471
472    /// Internal apply implementation shared by both `apply_schema` and `apply_with_history`.
473    fn apply_schema_impl(&self, plan: &SchemaPlan) -> Result<(), StorageError> {
474        for op in &plan.operations {
475            match op {
476                SchemaOperation::CreateEntity { name, fields } => {
477                    let sql = create_table_sql(name, fields);
478                    self.conn.execute(&sql, []).map_err(|e| StorageError {
479                        code: "SQLITE_EXEC_FAILED".into(),
480                        message: format!("Failed to create table {name}: {e}"),
481                    })?;
482                }
483                SchemaOperation::AddField { entity, field } => {
484                    let sql = add_column_sql(entity, field);
485                    self.conn.execute(&sql, []).map_err(|e| StorageError {
486                        code: "SQLITE_EXEC_FAILED".into(),
487                        message: format!("Failed to add column {}.{}: {e}", entity, field.name),
488                    })?;
489                }
490                SchemaOperation::AddIndex {
491                    entity,
492                    name,
493                    fields,
494                    unique,
495                } => {
496                    let sql = create_index_sql(entity, name, fields, *unique);
497                    self.conn.execute(&sql, []).map_err(|e| StorageError {
498                        code: "SQLITE_EXEC_FAILED".into(),
499                        message: format!("Failed to create index {entity}.{name}: {e}"),
500                    })?;
501                }
502                SchemaOperation::CreateSearchIndex { entity, config } => {
503                    // Ensure the shared facet table exists. Single
504                    // `_facet_bitmap` table holds all entities' bitmaps
505                    // keyed by (entity, facet, value); idempotent.
506                    self.conn
507                        .execute(crate::search::create_facet_table_sql(), [])
508                        .map_err(|e| StorageError {
509                            code: "SQLITE_EXEC_FAILED".into(),
510                            message: format!("create _facet_bitmap failed: {e}"),
511                        })?;
512                    // Per-entity FTS5 shadow table. Skipped when the
513                    // config has no text fields (facet-only search).
514                    if let Some(sql) = crate::search::create_fts_table_sql(entity, config) {
515                        self.conn.execute(&sql, []).map_err(|e| StorageError {
516                            code: "SQLITE_EXEC_FAILED".into(),
517                            message: format!("create _fts_{entity} failed: {e}"),
518                        })?;
519                    }
520                    // Auto-index every sortable field. Without these,
521                    // `ORDER BY <field> LIMIT n OFFSET m` does a full
522                    // table scan + sort — visible in the search bench
523                    // as "sort price asc, page 5" running ~50× slower
524                    // than the unsorted case. Indexed sort lets SQLite
525                    // walk the b-tree and stop at offset+limit.
526                    //
527                    // Naming convention: `<entity>_sort_<field>` — the
528                    // `_sort_` token distinguishes auto-indexes from
529                    // user-declared ones so future rebuilds can drop
530                    // them without colliding with custom indexes.
531                    for field in &config.sortable {
532                        let idx_sql = format!(
533                            "CREATE INDEX IF NOT EXISTS \"{entity}_sort_{field}\" \
534                             ON \"{entity}\" (\"{field}\")"
535                        );
536                        self.conn.execute(&idx_sql, []).map_err(|e| StorageError {
537                            code: "SQLITE_EXEC_FAILED".into(),
538                            message: format!("create sort index {entity}.{field} failed: {e}"),
539                        })?;
540                    }
541                }
542                SchemaOperation::RemoveSearchIndex { entity } => {
543                    self.conn
544                        .execute(&format!("DROP TABLE IF EXISTS \"_fts_{entity}\""), [])
545                        .map_err(|e| StorageError {
546                            code: "SQLITE_EXEC_FAILED".into(),
547                            message: format!("drop _fts_{entity} failed: {e}"),
548                        })?;
549                    self.conn
550                        .execute("DELETE FROM \"_facet_bitmap\" WHERE entity = ?1", [entity])
551                        .map_err(|e| StorageError {
552                            code: "SQLITE_EXEC_FAILED".into(),
553                            message: format!("clear facet bitmaps for {entity} failed: {e}"),
554                        })?;
555                }
556                SchemaOperation::Noop => {}
557                other => {
558                    return Err(StorageError {
559                        code: "SQLITE_OP_UNSUPPORTED".into(),
560                        message: format!("Operation not supported by SQLite adapter: {other:?}"),
561                    });
562                }
563            }
564        }
565        Ok(())
566    }
567}
568
569fn generate_push_id() -> String {
570    use std::time::{SystemTime, UNIX_EPOCH};
571    let ts = SystemTime::now()
572        .duration_since(UNIX_EPOCH)
573        .unwrap_or_default();
574    format!("{}.{:09}", ts.as_secs(), ts.subsec_nanos())
575}
576
577fn now_iso8601() -> String {
578    use std::time::{SystemTime, UNIX_EPOCH};
579    let ts = SystemTime::now()
580        .duration_since(UNIX_EPOCH)
581        .unwrap_or_default()
582        .as_secs();
583    // Simple UTC timestamp. Not worth pulling in chrono for this.
584    let secs_per_day: u64 = 86400;
585    let days = ts / secs_per_day;
586    let rem = ts % secs_per_day;
587    let hours = rem / 3600;
588    let mins = (rem % 3600) / 60;
589    let secs = rem % 60;
590    // Approximate date from epoch days (good enough for audit purposes).
591    let (year, month, day) = epoch_days_to_date(days);
592    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{mins:02}:{secs:02}Z")
593}
594
595fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
596    // Civil date from epoch days. Algorithm from Howard Hinnant.
597    let z = days + 719468;
598    let era = z / 146097;
599    let doe = z - era * 146097;
600    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
601    let y = yoe + era * 400;
602    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
603    let mp = (5 * doy + 2) / 153;
604    let d = doy - (153 * mp + 2) / 5 + 1;
605    let m = if mp < 10 { mp + 3 } else { mp - 9 };
606    let y = if m <= 2 { y + 1 } else { y };
607    (y, m, d)
608}
609
610// ---------------------------------------------------------------------------
611// Introspection
612// ---------------------------------------------------------------------------
613
614impl SqliteAdapter {
615    /// Read the current schema from the live SQLite database.
616    /// Only inspects user tables (not sqlite_* internal tables).
617    pub fn read_schema(&self) -> Result<SchemaSnapshot, StorageError> {
618        // Get all user tables, sorted for determinism.
619        let mut stmt = self
620            .conn
621            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_pylon_%' ORDER BY name")
622            .map_err(sqlite_err)?;
623
624        let table_names: Vec<String> = stmt
625            .query_map([], |row| row.get(0))
626            .map_err(sqlite_err)?
627            .collect::<Result<Vec<String>, _>>()
628            .map_err(sqlite_err)?;
629
630        let mut tables = Vec::new();
631        for table_name in &table_names {
632            let columns = self.read_columns(table_name)?;
633            let indexes = self.read_indexes(table_name)?;
634            tables.push(TableSnapshot {
635                name: table_name.clone(),
636                columns,
637                indexes,
638            });
639        }
640
641        Ok(SchemaSnapshot { tables })
642    }
643
644    fn read_columns(&self, table: &str) -> Result<Vec<ColumnSnapshot>, StorageError> {
645        let mut stmt = self
646            .conn
647            .prepare(&format!("PRAGMA table_info({})", quote_ident(table)))
648            .map_err(sqlite_err)?;
649
650        let columns: Vec<ColumnSnapshot> = stmt
651            .query_map([], |row| {
652                Ok(ColumnSnapshot {
653                    name: row.get(1)?,
654                    column_type: row.get(2)?,
655                    notnull: row.get::<_, i32>(3)? != 0,
656                    primary_key: row.get::<_, i32>(5)? != 0,
657                })
658            })
659            .map_err(sqlite_err)?
660            .collect::<Result<Vec<_>, _>>()
661            .map_err(sqlite_err)?;
662
663        Ok(columns)
664    }
665
666    fn read_indexes(&self, table: &str) -> Result<Vec<IndexSnapshot>, StorageError> {
667        let mut stmt = self
668            .conn
669            .prepare(&format!("PRAGMA index_list({})", quote_ident(table)))
670            .map_err(sqlite_err)?;
671
672        // Collect index metadata: (name, unique).
673        let index_meta: Vec<(String, bool)> = stmt
674            .query_map([], |row| {
675                let name: String = row.get(1)?;
676                let unique: bool = row.get::<_, i32>(2)? != 0;
677                Ok((name, unique))
678            })
679            .map_err(sqlite_err)?
680            .collect::<Result<Vec<_>, _>>()
681            .map_err(sqlite_err)?;
682
683        // Build ordered map for determinism, then read columns for each index.
684        let ordered: BTreeMap<String, bool> = index_meta.into_iter().collect();
685
686        let mut indexes = Vec::new();
687        for (name, unique) in &ordered {
688            // Skip SQLite autoindexes (internal unique constraint indexes).
689            if name.starts_with("sqlite_autoindex_") {
690                continue;
691            }
692
693            let mut col_stmt = self
694                .conn
695                .prepare(&format!("PRAGMA index_info({})", quote_ident(name)))
696                .map_err(sqlite_err)?;
697
698            let columns: Vec<String> = col_stmt
699                .query_map([], |row| row.get(2))
700                .map_err(sqlite_err)?
701                .collect::<Result<Vec<String>, _>>()
702                .map_err(sqlite_err)?;
703
704            indexes.push(IndexSnapshot {
705                name: name.clone(),
706                columns,
707                unique: *unique,
708            });
709        }
710
711        Ok(indexes)
712    }
713}
714
715fn sqlite_err(e: rusqlite::Error) -> StorageError {
716    StorageError {
717        code: "SQLITE_QUERY_FAILED".into(),
718        message: format!("SQLite query failed: {e}"),
719    }
720}
721
722// ---------------------------------------------------------------------------
723// Tests
724// ---------------------------------------------------------------------------
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use pylon_kernel::*;
730
731    fn test_manifest() -> AppManifest {
732        AppManifest {
733            manifest_version: MANIFEST_VERSION,
734            name: "test".into(),
735            version: "0.1.0".into(),
736            entities: vec![ManifestEntity {
737                name: "User".into(),
738                fields: vec![
739                    ManifestField {
740                        name: "email".into(),
741                        field_type: "string".into(),
742                        optional: false,
743                        unique: true,
744                        crdt: None,
745                    },
746                    ManifestField {
747                        name: "displayName".into(),
748                        field_type: "string".into(),
749                        optional: false,
750                        unique: false,
751                        crdt: None,
752                    },
753                    ManifestField {
754                        name: "age".into(),
755                        field_type: "int".into(),
756                        optional: true,
757                        unique: false,
758                        crdt: None,
759                    },
760                ],
761                indexes: vec![ManifestIndex {
762                    name: "by_email".into(),
763                    fields: vec!["email".into()],
764                    unique: true,
765                }],
766                relations: vec![],
767                search: None,
768                crdt: true,
769            }],
770            routes: vec![],
771            queries: vec![],
772            actions: vec![],
773            policies: vec![],
774        }
775    }
776
777    #[test]
778    fn create_table_sql_basic() {
779        let fields = vec![
780            FieldSpec {
781                name: "email".into(),
782                field_type: "string".into(),
783                optional: false,
784                unique: true,
785            },
786            FieldSpec {
787                name: "age".into(),
788                field_type: "int".into(),
789                optional: true,
790                unique: false,
791            },
792        ];
793        let sql = create_table_sql("User", &fields);
794        assert_eq!(
795            sql,
796            "CREATE TABLE IF NOT EXISTS \"User\" (id TEXT PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL UNIQUE, \"age\" INTEGER)"
797        );
798    }
799
800    #[test]
801    fn create_index_sql_basic() {
802        let sql = create_index_sql("User", "by_email", &["email".into()], true);
803        assert_eq!(
804            sql,
805            "CREATE UNIQUE INDEX IF NOT EXISTS \"User_by_email\" ON \"User\" (\"email\")"
806        );
807    }
808
809    #[test]
810    fn create_index_sql_non_unique() {
811        let sql = create_index_sql("Todo", "by_user", &["userId".into()], false);
812        assert_eq!(
813            sql,
814            "CREATE INDEX IF NOT EXISTS \"Todo_by_user\" ON \"Todo\" (\"userId\")"
815        );
816    }
817
818    #[test]
819    fn add_column_sql_basic() {
820        let field = FieldSpec {
821            name: "bio".into(),
822            field_type: "string".into(),
823            optional: true,
824            unique: false,
825        };
826        let sql = add_column_sql("User", &field);
827        assert_eq!(sql, "ALTER TABLE \"User\" ADD COLUMN \"bio\" TEXT");
828    }
829
830    #[test]
831    fn quote_ident_escapes_double_quotes() {
832        assert_eq!(quote_ident("normal"), "\"normal\"");
833        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
834        assert_eq!(
835            quote_ident("Robert'); DROP TABLE Students;--"),
836            "\"Robert'); DROP TABLE Students;--\""
837        );
838    }
839
840    #[test]
841    fn sqlite_adapter_creates_table() {
842        let adapter = SqliteAdapter::in_memory().unwrap();
843        let manifest = test_manifest();
844        let plan = adapter.plan_schema(&manifest).unwrap();
845        adapter.apply_schema(&plan).unwrap();
846
847        // Verify table exists by querying sqlite_master.
848        let table_count: i64 = adapter
849            .conn
850            .query_row(
851                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User'",
852                [],
853                |row| row.get(0),
854            )
855            .unwrap();
856        assert_eq!(table_count, 1);
857    }
858
859    #[test]
860    fn sqlite_adapter_creates_index() {
861        let adapter = SqliteAdapter::in_memory().unwrap();
862        let manifest = test_manifest();
863        let plan = adapter.plan_schema(&manifest).unwrap();
864        adapter.apply_schema(&plan).unwrap();
865
866        // Verify index exists.
867        let index_count: i64 = adapter
868            .conn
869            .query_row(
870                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='User_by_email'",
871                [],
872                |row| row.get(0),
873            )
874            .unwrap();
875        assert_eq!(index_count, 1);
876    }
877
878    #[test]
879    fn sqlite_adapter_add_field() {
880        let adapter = SqliteAdapter::in_memory().unwrap();
881
882        // Create table first.
883        let manifest = test_manifest();
884        let plan = adapter.plan_schema(&manifest).unwrap();
885        adapter.apply_schema(&plan).unwrap();
886
887        // Add a field.
888        let add_plan = SchemaPlan {
889            operations: vec![SchemaOperation::AddField {
890                entity: "User".into(),
891                field: FieldSpec {
892                    name: "bio".into(),
893                    field_type: "string".into(),
894                    optional: true,
895                    unique: false,
896                },
897            }],
898        };
899        adapter.apply_schema(&add_plan).unwrap();
900
901        // Verify column exists by checking pragma.
902        let has_bio: bool = adapter
903            .conn
904            .prepare("PRAGMA table_info(\"User\")")
905            .unwrap()
906            .query_map([], |row| {
907                let name: String = row.get(1)?;
908                Ok(name)
909            })
910            .unwrap()
911            .any(|r| r.unwrap() == "bio");
912        assert!(has_bio);
913    }
914
915    #[test]
916    fn sqlite_adapter_rejects_remove_entity() {
917        let adapter = SqliteAdapter::in_memory().unwrap();
918        let plan = SchemaPlan {
919            operations: vec![SchemaOperation::RemoveEntity {
920                name: "User".into(),
921            }],
922        };
923        let result = adapter.apply_schema(&plan);
924        assert!(result.is_err());
925        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
926    }
927
928    #[test]
929    fn sqlite_adapter_rejects_remove_field() {
930        let adapter = SqliteAdapter::in_memory().unwrap();
931        let plan = SchemaPlan {
932            operations: vec![SchemaOperation::RemoveField {
933                entity: "User".into(),
934                field_name: "email".into(),
935            }],
936        };
937        let result = adapter.apply_schema(&plan);
938        assert!(result.is_err());
939        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
940    }
941
942    #[test]
943    fn sqlite_adapter_column_types() {
944        assert_eq!(sqlite_column_type("string"), "TEXT");
945        assert_eq!(sqlite_column_type("int"), "INTEGER");
946        assert_eq!(sqlite_column_type("float"), "REAL");
947        assert_eq!(sqlite_column_type("bool"), "INTEGER");
948        assert_eq!(sqlite_column_type("datetime"), "TEXT");
949        assert_eq!(sqlite_column_type("richtext"), "TEXT");
950        assert_eq!(sqlite_column_type("id(User)"), "TEXT");
951    }
952
953    // -- Introspection tests --
954
955    #[test]
956    fn introspect_empty_db() {
957        let adapter = SqliteAdapter::in_memory().unwrap();
958        let snapshot = adapter.read_schema().unwrap();
959        assert!(snapshot.tables.is_empty());
960    }
961
962    #[test]
963    fn introspect_after_apply() {
964        let adapter = SqliteAdapter::in_memory().unwrap();
965        let manifest = test_manifest();
966        let plan = adapter.plan_schema(&manifest).unwrap();
967        adapter.apply_schema(&plan).unwrap();
968
969        let snapshot = adapter.read_schema().unwrap();
970
971        // Should have one table.
972        assert_eq!(snapshot.tables.len(), 1);
973        let user = &snapshot.tables[0];
974        assert_eq!(user.name, "User");
975
976        // id + 3 manifest fields = 4 columns.
977        assert_eq!(user.columns.len(), 4);
978        assert_eq!(user.columns[0].name, "id");
979        assert!(user.columns[0].primary_key);
980        assert_eq!(user.columns[1].name, "email");
981        assert_eq!(user.columns[1].column_type, "TEXT");
982        assert!(user.columns[1].notnull);
983        assert_eq!(user.columns[2].name, "displayName");
984        assert_eq!(user.columns[3].name, "age");
985        assert!(!user.columns[3].notnull); // optional
986
987        // Should have the by_email index.
988        assert_eq!(user.indexes.len(), 1);
989        assert_eq!(user.indexes[0].name, "User_by_email");
990        assert_eq!(user.indexes[0].columns, vec!["email"]);
991        assert!(user.indexes[0].unique);
992    }
993
994    #[test]
995    fn introspect_multiple_tables() {
996        let adapter = SqliteAdapter::in_memory().unwrap();
997
998        let manifest = AppManifest {
999            manifest_version: MANIFEST_VERSION,
1000            name: "test".into(),
1001            version: "0.1.0".into(),
1002            entities: vec![
1003                ManifestEntity {
1004                    name: "Post".into(),
1005                    fields: vec![ManifestField {
1006                        name: "title".into(),
1007                        field_type: "string".into(),
1008                        optional: false,
1009                        unique: false,
1010                        crdt: None,
1011                    }],
1012                    indexes: vec![],
1013                    relations: vec![],
1014                    search: None,
1015                    crdt: true,
1016                },
1017                ManifestEntity {
1018                    name: "User".into(),
1019                    fields: vec![ManifestField {
1020                        name: "email".into(),
1021                        field_type: "string".into(),
1022                        optional: false,
1023                        unique: true,
1024                        crdt: None,
1025                    }],
1026                    indexes: vec![],
1027                    relations: vec![],
1028                    search: None,
1029                    crdt: true,
1030                },
1031            ],
1032            routes: vec![],
1033            queries: vec![],
1034            actions: vec![],
1035            policies: vec![],
1036        };
1037
1038        let plan = adapter.plan_schema(&manifest).unwrap();
1039        adapter.apply_schema(&plan).unwrap();
1040
1041        let snapshot = adapter.read_schema().unwrap();
1042
1043        // Sorted alphabetically.
1044        assert_eq!(snapshot.tables.len(), 2);
1045        assert_eq!(snapshot.tables[0].name, "Post");
1046        assert_eq!(snapshot.tables[1].name, "User");
1047    }
1048
1049    #[test]
1050    fn introspect_after_add_field() {
1051        let adapter = SqliteAdapter::in_memory().unwrap();
1052        let manifest = test_manifest();
1053        let plan = adapter.plan_schema(&manifest).unwrap();
1054        adapter.apply_schema(&plan).unwrap();
1055
1056        // Add a column.
1057        let add_plan = SchemaPlan {
1058            operations: vec![SchemaOperation::AddField {
1059                entity: "User".into(),
1060                field: FieldSpec {
1061                    name: "bio".into(),
1062                    field_type: "string".into(),
1063                    optional: true,
1064                    unique: false,
1065                },
1066            }],
1067        };
1068        adapter.apply_schema(&add_plan).unwrap();
1069
1070        let snapshot = adapter.read_schema().unwrap();
1071        let user = &snapshot.tables[0];
1072
1073        // id + 3 original + 1 added = 5.
1074        assert_eq!(user.columns.len(), 5);
1075        assert!(user.columns.iter().any(|c| c.name == "bio"));
1076    }
1077
1078    #[test]
1079    fn introspect_snapshot_is_deterministic() {
1080        let adapter = SqliteAdapter::in_memory().unwrap();
1081        let manifest = test_manifest();
1082        let plan = adapter.plan_schema(&manifest).unwrap();
1083        adapter.apply_schema(&plan).unwrap();
1084
1085        let s1 = adapter.read_schema().unwrap();
1086        let s2 = adapter.read_schema().unwrap();
1087        assert_eq!(s1, s2);
1088    }
1089
1090    // -- Live planning tests --
1091
1092    #[test]
1093    fn plan_from_empty_db_creates_everything() {
1094        let adapter = SqliteAdapter::in_memory().unwrap();
1095        let manifest = test_manifest();
1096
1097        let plan = adapter.plan_from_live(&manifest).unwrap();
1098
1099        // Should create the table and its index.
1100        assert!(plan.operations.iter().any(|op| matches!(
1101            op,
1102            SchemaOperation::CreateEntity { name, .. } if name == "User"
1103        )));
1104        assert!(plan.operations.iter().any(|op| matches!(
1105            op,
1106            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1107        )));
1108    }
1109
1110    #[test]
1111    fn plan_from_fully_applied_db_is_noop() {
1112        let adapter = SqliteAdapter::in_memory().unwrap();
1113        let manifest = test_manifest();
1114
1115        // Apply everything first.
1116        let initial = adapter.plan_from_live(&manifest).unwrap();
1117        adapter.apply_schema(&initial).unwrap();
1118
1119        // Plan again — should be noop.
1120        let plan = adapter.plan_from_live(&manifest).unwrap();
1121        assert!(plan.is_empty(), "expected noop, got: {:?}", plan.operations);
1122    }
1123
1124    #[test]
1125    fn plan_detects_missing_column() {
1126        let adapter = SqliteAdapter::in_memory().unwrap();
1127
1128        // Create table with only email.
1129        adapter
1130            .conn
1131            .execute(
1132                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE)",
1133                [],
1134            )
1135            .unwrap();
1136
1137        let manifest = test_manifest();
1138        let plan = adapter.plan_from_live(&manifest).unwrap();
1139
1140        // Should plan AddField for displayName and age.
1141        let add_fields: Vec<_> = plan
1142            .operations
1143            .iter()
1144            .filter(|op| matches!(op, SchemaOperation::AddField { .. }))
1145            .collect();
1146        assert_eq!(add_fields.len(), 2);
1147    }
1148
1149    #[test]
1150    fn plan_detects_missing_index() {
1151        let adapter = SqliteAdapter::in_memory().unwrap();
1152
1153        // Create table with all columns but no index.
1154        adapter
1155            .conn
1156            .execute(
1157                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, \"displayName\" TEXT NOT NULL, age INTEGER)",
1158                [],
1159            )
1160            .unwrap();
1161
1162        let manifest = test_manifest();
1163        let plan = adapter.plan_from_live(&manifest).unwrap();
1164
1165        // Should plan AddIndex only.
1166        assert!(plan.operations.iter().any(|op| matches!(
1167            op,
1168            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1169        )));
1170        assert!(!plan
1171            .operations
1172            .iter()
1173            .any(|op| matches!(op, SchemaOperation::CreateEntity { .. })));
1174    }
1175
1176    // -- Migration history tests --
1177
1178    fn push_meta(baseline: &str) -> PushMetadata<'_> {
1179        PushMetadata {
1180            manifest_version: 1,
1181            app_version: "0.1.0",
1182            baseline,
1183        }
1184    }
1185
1186    fn history_count(adapter: &SqliteAdapter) -> i64 {
1187        adapter
1188            .conn
1189            .query_row(
1190                &format!("SELECT COUNT(*) FROM {}", quote_ident(HISTORY_TABLE)),
1191                [],
1192                |row| row.get(0),
1193            )
1194            .unwrap()
1195    }
1196
1197    #[test]
1198    fn history_table_created_on_apply() {
1199        let adapter = SqliteAdapter::in_memory().unwrap();
1200        let manifest = test_manifest();
1201        let plan = adapter.plan_from_live(&manifest).unwrap();
1202        adapter
1203            .apply_with_history(&plan, &push_meta("live_sqlite"))
1204            .unwrap();
1205
1206        let table_exists: i64 = adapter
1207            .conn
1208            .query_row(
1209                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1210                [HISTORY_TABLE],
1211                |row| row.get(0),
1212            )
1213            .unwrap();
1214        assert_eq!(table_exists, 1);
1215    }
1216
1217    #[test]
1218    fn history_row_inserted_on_apply() {
1219        let adapter = SqliteAdapter::in_memory().unwrap();
1220        let manifest = test_manifest();
1221        let plan = adapter.plan_from_live(&manifest).unwrap();
1222        adapter
1223            .apply_with_history(&plan, &push_meta("live_sqlite"))
1224            .unwrap();
1225
1226        assert_eq!(history_count(&adapter), 1);
1227
1228        // Verify stored data.
1229        let (mv, av, baseline, op_count): (i64, String, String, i64) = adapter
1230            .conn
1231            .query_row(
1232                &format!(
1233                    "SELECT manifest_version, app_version, baseline, operation_count FROM {} LIMIT 1",
1234                    quote_ident(HISTORY_TABLE)
1235                ),
1236                [],
1237                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1238            )
1239            .unwrap();
1240        assert_eq!(mv, 1);
1241        assert_eq!(av, "0.1.0");
1242        assert_eq!(baseline, "live_sqlite");
1243        assert_eq!(op_count, 2); // CreateEntity + AddIndex (Noop not counted)
1244    }
1245
1246    #[test]
1247    fn noop_push_also_recorded() {
1248        let adapter = SqliteAdapter::in_memory().unwrap();
1249        let manifest = test_manifest();
1250
1251        // First push creates tables.
1252        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1253        adapter
1254            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1255            .unwrap();
1256
1257        // Second push is noop.
1258        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1259        assert!(plan2.is_empty());
1260        adapter
1261            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1262            .unwrap();
1263
1264        // Both pushes recorded.
1265        assert_eq!(history_count(&adapter), 2);
1266
1267        // Second row has 0 operations.
1268        let op_count: i64 = adapter
1269            .conn
1270            .query_row(
1271                &format!(
1272                    "SELECT operation_count FROM {} ORDER BY id DESC LIMIT 1",
1273                    quote_ident(HISTORY_TABLE)
1274                ),
1275                [],
1276                |row| row.get(0),
1277            )
1278            .unwrap();
1279        assert_eq!(op_count, 0);
1280    }
1281
1282    #[test]
1283    fn history_plan_json_is_valid() {
1284        let adapter = SqliteAdapter::in_memory().unwrap();
1285        let manifest = test_manifest();
1286        let plan = adapter.plan_from_live(&manifest).unwrap();
1287        adapter
1288            .apply_with_history(&plan, &push_meta("live_sqlite"))
1289            .unwrap();
1290
1291        let plan_json: String = adapter
1292            .conn
1293            .query_row(
1294                &format!(
1295                    "SELECT plan_json FROM {} LIMIT 1",
1296                    quote_ident(HISTORY_TABLE)
1297                ),
1298                [],
1299                |row| row.get(0),
1300            )
1301            .unwrap();
1302
1303        // Should be valid JSON.
1304        let parsed: serde_json::Value = serde_json::from_str(&plan_json).unwrap();
1305        assert!(parsed.get("operations").unwrap().is_array());
1306    }
1307
1308    #[test]
1309    fn history_table_excluded_from_introspection() {
1310        let adapter = SqliteAdapter::in_memory().unwrap();
1311        let manifest = test_manifest();
1312        let plan = adapter.plan_from_live(&manifest).unwrap();
1313        adapter
1314            .apply_with_history(&plan, &push_meta("live_sqlite"))
1315            .unwrap();
1316
1317        let snapshot = adapter.read_schema().unwrap();
1318        assert!(!snapshot.tables.iter().any(|t| t.name.starts_with("_pylon")));
1319    }
1320
1321    // -- read_history tests --
1322
1323    #[test]
1324    fn read_history_empty_db() {
1325        let adapter = SqliteAdapter::in_memory().unwrap();
1326        let entries = adapter.read_history(None).unwrap();
1327        assert!(entries.is_empty());
1328    }
1329
1330    #[test]
1331    fn read_history_after_one_push() {
1332        let adapter = SqliteAdapter::in_memory().unwrap();
1333        let manifest = test_manifest();
1334        let plan = adapter.plan_from_live(&manifest).unwrap();
1335        adapter
1336            .apply_with_history(&plan, &push_meta("live_sqlite"))
1337            .unwrap();
1338
1339        let entries = adapter.read_history(None).unwrap();
1340        assert_eq!(entries.len(), 1);
1341        assert_eq!(entries[0].manifest_version, 1);
1342        assert_eq!(entries[0].app_version, "0.1.0");
1343        assert_eq!(entries[0].baseline, "live_sqlite");
1344        assert_eq!(entries[0].operation_count, 2); // CreateEntity + AddIndex
1345    }
1346
1347    #[test]
1348    fn read_history_after_noop_push() {
1349        let adapter = SqliteAdapter::in_memory().unwrap();
1350        let manifest = test_manifest();
1351
1352        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1353        adapter
1354            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1355            .unwrap();
1356
1357        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1358        adapter
1359            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1360            .unwrap();
1361
1362        let entries = adapter.read_history(None).unwrap();
1363        assert_eq!(entries.len(), 2);
1364        // Newest first.
1365        assert_eq!(entries[0].operation_count, 0);
1366        assert_eq!(entries[1].operation_count, 2);
1367    }
1368
1369    #[test]
1370    fn read_history_newest_first() {
1371        let adapter = SqliteAdapter::in_memory().unwrap();
1372        let manifest = test_manifest();
1373
1374        let plan = adapter.plan_from_live(&manifest).unwrap();
1375        adapter
1376            .apply_with_history(
1377                &plan,
1378                &PushMetadata {
1379                    manifest_version: 1,
1380                    app_version: "0.1.0",
1381                    baseline: "first",
1382                },
1383            )
1384            .unwrap();
1385
1386        // Small delay not needed — timestamps have nanosecond precision.
1387        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1388        adapter
1389            .apply_with_history(
1390                &plan2,
1391                &PushMetadata {
1392                    manifest_version: 1,
1393                    app_version: "0.2.0",
1394                    baseline: "second",
1395                },
1396            )
1397            .unwrap();
1398
1399        let entries = adapter.read_history(None).unwrap();
1400        assert_eq!(entries.len(), 2);
1401        assert_eq!(entries[0].baseline, "second");
1402        assert_eq!(entries[1].baseline, "first");
1403    }
1404
1405    #[test]
1406    fn read_history_with_limit() {
1407        let adapter = SqliteAdapter::in_memory().unwrap();
1408        let manifest = test_manifest();
1409
1410        // Push twice.
1411        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1412        adapter
1413            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1414            .unwrap();
1415        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1416        adapter
1417            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1418            .unwrap();
1419
1420        let all = adapter.read_history(None).unwrap();
1421        assert_eq!(all.len(), 2);
1422
1423        let limited = adapter.read_history(Some(1)).unwrap();
1424        assert_eq!(limited.len(), 1);
1425    }
1426
1427    #[test]
1428    fn read_history_entry_by_id() {
1429        let adapter = SqliteAdapter::in_memory().unwrap();
1430        let manifest = test_manifest();
1431
1432        let plan = adapter.plan_from_live(&manifest).unwrap();
1433        adapter
1434            .apply_with_history(&plan, &push_meta("live_sqlite"))
1435            .unwrap();
1436
1437        let entries = adapter.read_history(None).unwrap();
1438        let id = &entries[0].id;
1439
1440        let entry = adapter.read_history_entry(id).unwrap().unwrap();
1441        assert_eq!(&entry.id, id);
1442        assert_eq!(entry.operation_count, 2);
1443    }
1444
1445    #[test]
1446    fn read_history_entry_missing_id() {
1447        let adapter = SqliteAdapter::in_memory().unwrap();
1448        let result = adapter.read_history_entry("nonexistent").unwrap();
1449        assert!(result.is_none());
1450    }
1451
1452    #[test]
1453    fn history_entry_has_parsed_plan() {
1454        let adapter = SqliteAdapter::in_memory().unwrap();
1455        let manifest = test_manifest();
1456
1457        let plan = adapter.plan_from_live(&manifest).unwrap();
1458        adapter
1459            .apply_with_history(&plan, &push_meta("live_sqlite"))
1460            .unwrap();
1461
1462        let entries = adapter.read_history(None).unwrap();
1463        let entry = &entries[0];
1464
1465        // plan should be parsed from plan_json.
1466        assert!(entry.plan.is_some());
1467        let parsed_plan = entry.plan.as_ref().unwrap();
1468        assert!(!parsed_plan.operations.is_empty());
1469
1470        // plan_json should still be present as raw string.
1471        assert!(!entry.plan_json.is_empty());
1472    }
1473}