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::AlterField {
491                    entity,
492                    previous,
493                    target,
494                } => {
495                    // SQLite has no DROP NOT NULL — nullability lives in
496                    // the CREATE TABLE definition and the only way to
497                    // change it is the documented "rename + recreate +
498                    // copy" dance from the SQLite docs:
499                    // <https://www.sqlite.org/lang_altertable.html#otheralter>.
500                    //
501                    // For pylon's typical case (single column going
502                    // optional), the simplest workable approach is to
503                    // skip emitting SQL on SQLite — the column already
504                    // exists and inserts that omit the field will fail
505                    // only if NOT NULL is enforced. The downstream
506                    // runtime always supplies every required field, so
507                    // a "stale NOT NULL" doesn't bite in practice the
508                    // way it does on Postgres.
509                    //
510                    // Going required → optional on SQLite when there's
511                    // no live workload pain isn't worth the table-
512                    // rebuild risk. Operators who hit a real case can
513                    // run the rebuild manually. Document and move on.
514                    let _ = (entity, previous, target);
515                    tracing::warn!(
516                        "[sqlite] AlterField on {entity}.{} requested but SQLite has no DROP/SET NOT NULL — \
517                         skipping. Manual table rebuild needed if existing data is incompatible. \
518                         (Postgres backend applies the ALTER cleanly; this is an SQLite limitation.)",
519                        target.name
520                    );
521                }
522                SchemaOperation::AddIndex {
523                    entity,
524                    name,
525                    fields,
526                    unique,
527                } => {
528                    let sql = create_index_sql(entity, name, fields, *unique);
529                    self.conn.execute(&sql, []).map_err(|e| StorageError {
530                        code: "SQLITE_EXEC_FAILED".into(),
531                        message: format!("Failed to create index {entity}.{name}: {e}"),
532                    })?;
533                }
534                SchemaOperation::CreateSearchIndex { entity, config } => {
535                    // Ensure the shared facet table exists. Single
536                    // `_facet_bitmap` table holds all entities' bitmaps
537                    // keyed by (entity, facet, value); idempotent.
538                    self.conn
539                        .execute(crate::search::create_facet_table_sql(), [])
540                        .map_err(|e| StorageError {
541                            code: "SQLITE_EXEC_FAILED".into(),
542                            message: format!("create _facet_bitmap failed: {e}"),
543                        })?;
544                    // Per-entity FTS5 shadow table. Skipped when the
545                    // config has no text fields (facet-only search).
546                    if let Some(sql) = crate::search::create_fts_table_sql(entity, config) {
547                        self.conn.execute(&sql, []).map_err(|e| StorageError {
548                            code: "SQLITE_EXEC_FAILED".into(),
549                            message: format!("create _fts_{entity} failed: {e}"),
550                        })?;
551                    }
552                    // Auto-index every sortable field. Without these,
553                    // `ORDER BY <field> LIMIT n OFFSET m` does a full
554                    // table scan + sort — visible in the search bench
555                    // as "sort price asc, page 5" running ~50× slower
556                    // than the unsorted case. Indexed sort lets SQLite
557                    // walk the b-tree and stop at offset+limit.
558                    //
559                    // Naming convention: `<entity>_sort_<field>` — the
560                    // `_sort_` token distinguishes auto-indexes from
561                    // user-declared ones so future rebuilds can drop
562                    // them without colliding with custom indexes.
563                    for field in &config.sortable {
564                        let idx_sql = format!(
565                            "CREATE INDEX IF NOT EXISTS \"{entity}_sort_{field}\" \
566                             ON \"{entity}\" (\"{field}\")"
567                        );
568                        self.conn.execute(&idx_sql, []).map_err(|e| StorageError {
569                            code: "SQLITE_EXEC_FAILED".into(),
570                            message: format!("create sort index {entity}.{field} failed: {e}"),
571                        })?;
572                    }
573                }
574                SchemaOperation::RemoveSearchIndex { entity } => {
575                    self.conn
576                        .execute(&format!("DROP TABLE IF EXISTS \"_fts_{entity}\""), [])
577                        .map_err(|e| StorageError {
578                            code: "SQLITE_EXEC_FAILED".into(),
579                            message: format!("drop _fts_{entity} failed: {e}"),
580                        })?;
581                    self.conn
582                        .execute("DELETE FROM \"_facet_bitmap\" WHERE entity = ?1", [entity])
583                        .map_err(|e| StorageError {
584                            code: "SQLITE_EXEC_FAILED".into(),
585                            message: format!("clear facet bitmaps for {entity} failed: {e}"),
586                        })?;
587                }
588                SchemaOperation::Noop => {}
589                other => {
590                    return Err(StorageError {
591                        code: "SQLITE_OP_UNSUPPORTED".into(),
592                        message: format!("Operation not supported by SQLite adapter: {other:?}"),
593                    });
594                }
595            }
596        }
597        Ok(())
598    }
599}
600
601fn generate_push_id() -> String {
602    use std::time::{SystemTime, UNIX_EPOCH};
603    let ts = SystemTime::now()
604        .duration_since(UNIX_EPOCH)
605        .unwrap_or_default();
606    format!("{}.{:09}", ts.as_secs(), ts.subsec_nanos())
607}
608
609fn now_iso8601() -> String {
610    use std::time::{SystemTime, UNIX_EPOCH};
611    let ts = SystemTime::now()
612        .duration_since(UNIX_EPOCH)
613        .unwrap_or_default()
614        .as_secs();
615    // Simple UTC timestamp. Not worth pulling in chrono for this.
616    let secs_per_day: u64 = 86400;
617    let days = ts / secs_per_day;
618    let rem = ts % secs_per_day;
619    let hours = rem / 3600;
620    let mins = (rem % 3600) / 60;
621    let secs = rem % 60;
622    // Approximate date from epoch days (good enough for audit purposes).
623    let (year, month, day) = epoch_days_to_date(days);
624    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{mins:02}:{secs:02}Z")
625}
626
627fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
628    // Civil date from epoch days. Algorithm from Howard Hinnant.
629    let z = days + 719468;
630    let era = z / 146097;
631    let doe = z - era * 146097;
632    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
633    let y = yoe + era * 400;
634    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
635    let mp = (5 * doy + 2) / 153;
636    let d = doy - (153 * mp + 2) / 5 + 1;
637    let m = if mp < 10 { mp + 3 } else { mp - 9 };
638    let y = if m <= 2 { y + 1 } else { y };
639    (y, m, d)
640}
641
642// ---------------------------------------------------------------------------
643// Introspection
644// ---------------------------------------------------------------------------
645
646impl SqliteAdapter {
647    /// Read the current schema from the live SQLite database.
648    /// Only inspects user tables (not sqlite_* internal tables).
649    pub fn read_schema(&self) -> Result<SchemaSnapshot, StorageError> {
650        // Get all user tables, sorted for determinism.
651        let mut stmt = self
652            .conn
653            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_pylon_%' ORDER BY name")
654            .map_err(sqlite_err)?;
655
656        let table_names: Vec<String> = stmt
657            .query_map([], |row| row.get(0))
658            .map_err(sqlite_err)?
659            .collect::<Result<Vec<String>, _>>()
660            .map_err(sqlite_err)?;
661
662        let mut tables = Vec::new();
663        for table_name in &table_names {
664            let columns = self.read_columns(table_name)?;
665            let indexes = self.read_indexes(table_name)?;
666            tables.push(TableSnapshot {
667                name: table_name.clone(),
668                columns,
669                indexes,
670            });
671        }
672
673        Ok(SchemaSnapshot { tables })
674    }
675
676    fn read_columns(&self, table: &str) -> Result<Vec<ColumnSnapshot>, StorageError> {
677        let mut stmt = self
678            .conn
679            .prepare(&format!("PRAGMA table_info({})", quote_ident(table)))
680            .map_err(sqlite_err)?;
681
682        let columns: Vec<ColumnSnapshot> = stmt
683            .query_map([], |row| {
684                Ok(ColumnSnapshot {
685                    name: row.get(1)?,
686                    column_type: row.get(2)?,
687                    notnull: row.get::<_, i32>(3)? != 0,
688                    primary_key: row.get::<_, i32>(5)? != 0,
689                })
690            })
691            .map_err(sqlite_err)?
692            .collect::<Result<Vec<_>, _>>()
693            .map_err(sqlite_err)?;
694
695        Ok(columns)
696    }
697
698    fn read_indexes(&self, table: &str) -> Result<Vec<IndexSnapshot>, StorageError> {
699        let mut stmt = self
700            .conn
701            .prepare(&format!("PRAGMA index_list({})", quote_ident(table)))
702            .map_err(sqlite_err)?;
703
704        // Collect index metadata: (name, unique).
705        let index_meta: Vec<(String, bool)> = stmt
706            .query_map([], |row| {
707                let name: String = row.get(1)?;
708                let unique: bool = row.get::<_, i32>(2)? != 0;
709                Ok((name, unique))
710            })
711            .map_err(sqlite_err)?
712            .collect::<Result<Vec<_>, _>>()
713            .map_err(sqlite_err)?;
714
715        // Build ordered map for determinism, then read columns for each index.
716        let ordered: BTreeMap<String, bool> = index_meta.into_iter().collect();
717
718        let mut indexes = Vec::new();
719        for (name, unique) in &ordered {
720            // Skip SQLite autoindexes (internal unique constraint indexes).
721            if name.starts_with("sqlite_autoindex_") {
722                continue;
723            }
724
725            let mut col_stmt = self
726                .conn
727                .prepare(&format!("PRAGMA index_info({})", quote_ident(name)))
728                .map_err(sqlite_err)?;
729
730            let columns: Vec<String> = col_stmt
731                .query_map([], |row| row.get(2))
732                .map_err(sqlite_err)?
733                .collect::<Result<Vec<String>, _>>()
734                .map_err(sqlite_err)?;
735
736            indexes.push(IndexSnapshot {
737                name: name.clone(),
738                columns,
739                unique: *unique,
740            });
741        }
742
743        Ok(indexes)
744    }
745}
746
747fn sqlite_err(e: rusqlite::Error) -> StorageError {
748    StorageError {
749        code: "SQLITE_QUERY_FAILED".into(),
750        message: format!("SQLite query failed: {e}"),
751    }
752}
753
754// ---------------------------------------------------------------------------
755// Tests
756// ---------------------------------------------------------------------------
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use pylon_kernel::*;
762
763    fn test_manifest() -> AppManifest {
764        AppManifest {
765            manifest_version: MANIFEST_VERSION,
766            name: "test".into(),
767            version: "0.1.0".into(),
768            entities: vec![ManifestEntity {
769                name: "User".into(),
770                fields: vec![
771                    ManifestField {
772                        name: "email".into(),
773                        field_type: "string".into(),
774                        optional: false,
775                        unique: true,
776                        crdt: None,
777                    },
778                    ManifestField {
779                        name: "displayName".into(),
780                        field_type: "string".into(),
781                        optional: false,
782                        unique: false,
783                        crdt: None,
784                    },
785                    ManifestField {
786                        name: "age".into(),
787                        field_type: "int".into(),
788                        optional: true,
789                        unique: false,
790                        crdt: None,
791                    },
792                ],
793                indexes: vec![ManifestIndex {
794                    name: "by_email".into(),
795                    fields: vec!["email".into()],
796                    unique: true,
797                }],
798                relations: vec![],
799                search: None,
800                crdt: true,
801            }],
802            routes: vec![],
803            queries: vec![],
804            actions: vec![],
805            policies: vec![],
806            auth: Default::default(),
807        }
808    }
809
810    #[test]
811    fn create_table_sql_basic() {
812        let fields = vec![
813            FieldSpec {
814                name: "email".into(),
815                field_type: "string".into(),
816                optional: false,
817                unique: true,
818            },
819            FieldSpec {
820                name: "age".into(),
821                field_type: "int".into(),
822                optional: true,
823                unique: false,
824            },
825        ];
826        let sql = create_table_sql("User", &fields);
827        assert_eq!(
828            sql,
829            "CREATE TABLE IF NOT EXISTS \"User\" (id TEXT PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL UNIQUE, \"age\" INTEGER)"
830        );
831    }
832
833    #[test]
834    fn create_index_sql_basic() {
835        let sql = create_index_sql("User", "by_email", &["email".into()], true);
836        assert_eq!(
837            sql,
838            "CREATE UNIQUE INDEX IF NOT EXISTS \"User_by_email\" ON \"User\" (\"email\")"
839        );
840    }
841
842    #[test]
843    fn create_index_sql_non_unique() {
844        let sql = create_index_sql("Todo", "by_user", &["userId".into()], false);
845        assert_eq!(
846            sql,
847            "CREATE INDEX IF NOT EXISTS \"Todo_by_user\" ON \"Todo\" (\"userId\")"
848        );
849    }
850
851    #[test]
852    fn add_column_sql_basic() {
853        let field = FieldSpec {
854            name: "bio".into(),
855            field_type: "string".into(),
856            optional: true,
857            unique: false,
858        };
859        let sql = add_column_sql("User", &field);
860        assert_eq!(sql, "ALTER TABLE \"User\" ADD COLUMN \"bio\" TEXT");
861    }
862
863    #[test]
864    fn quote_ident_escapes_double_quotes() {
865        assert_eq!(quote_ident("normal"), "\"normal\"");
866        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
867        assert_eq!(
868            quote_ident("Robert'); DROP TABLE Students;--"),
869            "\"Robert'); DROP TABLE Students;--\""
870        );
871    }
872
873    #[test]
874    fn sqlite_adapter_creates_table() {
875        let adapter = SqliteAdapter::in_memory().unwrap();
876        let manifest = test_manifest();
877        let plan = adapter.plan_schema(&manifest).unwrap();
878        adapter.apply_schema(&plan).unwrap();
879
880        // Verify table exists by querying sqlite_master.
881        let table_count: i64 = adapter
882            .conn
883            .query_row(
884                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User'",
885                [],
886                |row| row.get(0),
887            )
888            .unwrap();
889        assert_eq!(table_count, 1);
890    }
891
892    #[test]
893    fn sqlite_adapter_creates_index() {
894        let adapter = SqliteAdapter::in_memory().unwrap();
895        let manifest = test_manifest();
896        let plan = adapter.plan_schema(&manifest).unwrap();
897        adapter.apply_schema(&plan).unwrap();
898
899        // Verify index exists.
900        let index_count: i64 = adapter
901            .conn
902            .query_row(
903                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='User_by_email'",
904                [],
905                |row| row.get(0),
906            )
907            .unwrap();
908        assert_eq!(index_count, 1);
909    }
910
911    #[test]
912    fn sqlite_adapter_add_field() {
913        let adapter = SqliteAdapter::in_memory().unwrap();
914
915        // Create table first.
916        let manifest = test_manifest();
917        let plan = adapter.plan_schema(&manifest).unwrap();
918        adapter.apply_schema(&plan).unwrap();
919
920        // Add a field.
921        let add_plan = SchemaPlan {
922            operations: vec![SchemaOperation::AddField {
923                entity: "User".into(),
924                field: FieldSpec {
925                    name: "bio".into(),
926                    field_type: "string".into(),
927                    optional: true,
928                    unique: false,
929                },
930            }],
931        };
932        adapter.apply_schema(&add_plan).unwrap();
933
934        // Verify column exists by checking pragma.
935        let has_bio: bool = adapter
936            .conn
937            .prepare("PRAGMA table_info(\"User\")")
938            .unwrap()
939            .query_map([], |row| {
940                let name: String = row.get(1)?;
941                Ok(name)
942            })
943            .unwrap()
944            .any(|r| r.unwrap() == "bio");
945        assert!(has_bio);
946    }
947
948    #[test]
949    fn sqlite_adapter_rejects_remove_entity() {
950        let adapter = SqliteAdapter::in_memory().unwrap();
951        let plan = SchemaPlan {
952            operations: vec![SchemaOperation::RemoveEntity {
953                name: "User".into(),
954            }],
955        };
956        let result = adapter.apply_schema(&plan);
957        assert!(result.is_err());
958        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
959    }
960
961    #[test]
962    fn sqlite_adapter_rejects_remove_field() {
963        let adapter = SqliteAdapter::in_memory().unwrap();
964        let plan = SchemaPlan {
965            operations: vec![SchemaOperation::RemoveField {
966                entity: "User".into(),
967                field_name: "email".into(),
968            }],
969        };
970        let result = adapter.apply_schema(&plan);
971        assert!(result.is_err());
972        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
973    }
974
975    #[test]
976    fn sqlite_adapter_column_types() {
977        assert_eq!(sqlite_column_type("string"), "TEXT");
978        assert_eq!(sqlite_column_type("int"), "INTEGER");
979        assert_eq!(sqlite_column_type("float"), "REAL");
980        assert_eq!(sqlite_column_type("bool"), "INTEGER");
981        assert_eq!(sqlite_column_type("datetime"), "TEXT");
982        assert_eq!(sqlite_column_type("richtext"), "TEXT");
983        assert_eq!(sqlite_column_type("id(User)"), "TEXT");
984    }
985
986    // -- Introspection tests --
987
988    #[test]
989    fn introspect_empty_db() {
990        let adapter = SqliteAdapter::in_memory().unwrap();
991        let snapshot = adapter.read_schema().unwrap();
992        assert!(snapshot.tables.is_empty());
993    }
994
995    #[test]
996    fn introspect_after_apply() {
997        let adapter = SqliteAdapter::in_memory().unwrap();
998        let manifest = test_manifest();
999        let plan = adapter.plan_schema(&manifest).unwrap();
1000        adapter.apply_schema(&plan).unwrap();
1001
1002        let snapshot = adapter.read_schema().unwrap();
1003
1004        // Should have one table.
1005        assert_eq!(snapshot.tables.len(), 1);
1006        let user = &snapshot.tables[0];
1007        assert_eq!(user.name, "User");
1008
1009        // id + 3 manifest fields = 4 columns.
1010        assert_eq!(user.columns.len(), 4);
1011        assert_eq!(user.columns[0].name, "id");
1012        assert!(user.columns[0].primary_key);
1013        assert_eq!(user.columns[1].name, "email");
1014        assert_eq!(user.columns[1].column_type, "TEXT");
1015        assert!(user.columns[1].notnull);
1016        assert_eq!(user.columns[2].name, "displayName");
1017        assert_eq!(user.columns[3].name, "age");
1018        assert!(!user.columns[3].notnull); // optional
1019
1020        // Should have the by_email index.
1021        assert_eq!(user.indexes.len(), 1);
1022        assert_eq!(user.indexes[0].name, "User_by_email");
1023        assert_eq!(user.indexes[0].columns, vec!["email"]);
1024        assert!(user.indexes[0].unique);
1025    }
1026
1027    #[test]
1028    fn introspect_multiple_tables() {
1029        let adapter = SqliteAdapter::in_memory().unwrap();
1030
1031        let manifest = AppManifest {
1032            manifest_version: MANIFEST_VERSION,
1033            name: "test".into(),
1034            version: "0.1.0".into(),
1035            entities: vec![
1036                ManifestEntity {
1037                    name: "Post".into(),
1038                    fields: vec![ManifestField {
1039                        name: "title".into(),
1040                        field_type: "string".into(),
1041                        optional: false,
1042                        unique: false,
1043                        crdt: None,
1044                    }],
1045                    indexes: vec![],
1046                    relations: vec![],
1047                    search: None,
1048                    crdt: true,
1049                },
1050                ManifestEntity {
1051                    name: "User".into(),
1052                    fields: vec![ManifestField {
1053                        name: "email".into(),
1054                        field_type: "string".into(),
1055                        optional: false,
1056                        unique: true,
1057                        crdt: None,
1058                    }],
1059                    indexes: vec![],
1060                    relations: vec![],
1061                    search: None,
1062                    crdt: true,
1063                },
1064            ],
1065            routes: vec![],
1066            queries: vec![],
1067            actions: vec![],
1068            policies: vec![],
1069            auth: Default::default(),
1070        };
1071
1072        let plan = adapter.plan_schema(&manifest).unwrap();
1073        adapter.apply_schema(&plan).unwrap();
1074
1075        let snapshot = adapter.read_schema().unwrap();
1076
1077        // Sorted alphabetically.
1078        assert_eq!(snapshot.tables.len(), 2);
1079        assert_eq!(snapshot.tables[0].name, "Post");
1080        assert_eq!(snapshot.tables[1].name, "User");
1081    }
1082
1083    #[test]
1084    fn introspect_after_add_field() {
1085        let adapter = SqliteAdapter::in_memory().unwrap();
1086        let manifest = test_manifest();
1087        let plan = adapter.plan_schema(&manifest).unwrap();
1088        adapter.apply_schema(&plan).unwrap();
1089
1090        // Add a column.
1091        let add_plan = SchemaPlan {
1092            operations: vec![SchemaOperation::AddField {
1093                entity: "User".into(),
1094                field: FieldSpec {
1095                    name: "bio".into(),
1096                    field_type: "string".into(),
1097                    optional: true,
1098                    unique: false,
1099                },
1100            }],
1101        };
1102        adapter.apply_schema(&add_plan).unwrap();
1103
1104        let snapshot = adapter.read_schema().unwrap();
1105        let user = &snapshot.tables[0];
1106
1107        // id + 3 original + 1 added = 5.
1108        assert_eq!(user.columns.len(), 5);
1109        assert!(user.columns.iter().any(|c| c.name == "bio"));
1110    }
1111
1112    #[test]
1113    fn introspect_snapshot_is_deterministic() {
1114        let adapter = SqliteAdapter::in_memory().unwrap();
1115        let manifest = test_manifest();
1116        let plan = adapter.plan_schema(&manifest).unwrap();
1117        adapter.apply_schema(&plan).unwrap();
1118
1119        let s1 = adapter.read_schema().unwrap();
1120        let s2 = adapter.read_schema().unwrap();
1121        assert_eq!(s1, s2);
1122    }
1123
1124    // -- Live planning tests --
1125
1126    #[test]
1127    fn plan_from_empty_db_creates_everything() {
1128        let adapter = SqliteAdapter::in_memory().unwrap();
1129        let manifest = test_manifest();
1130
1131        let plan = adapter.plan_from_live(&manifest).unwrap();
1132
1133        // Should create the table and its index.
1134        assert!(plan.operations.iter().any(|op| matches!(
1135            op,
1136            SchemaOperation::CreateEntity { name, .. } if name == "User"
1137        )));
1138        assert!(plan.operations.iter().any(|op| matches!(
1139            op,
1140            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1141        )));
1142    }
1143
1144    #[test]
1145    fn plan_from_fully_applied_db_is_noop() {
1146        let adapter = SqliteAdapter::in_memory().unwrap();
1147        let manifest = test_manifest();
1148
1149        // Apply everything first.
1150        let initial = adapter.plan_from_live(&manifest).unwrap();
1151        adapter.apply_schema(&initial).unwrap();
1152
1153        // Plan again — should be noop.
1154        let plan = adapter.plan_from_live(&manifest).unwrap();
1155        assert!(plan.is_empty(), "expected noop, got: {:?}", plan.operations);
1156    }
1157
1158    #[test]
1159    fn plan_detects_missing_column() {
1160        let adapter = SqliteAdapter::in_memory().unwrap();
1161
1162        // Create table with only email.
1163        adapter
1164            .conn
1165            .execute(
1166                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE)",
1167                [],
1168            )
1169            .unwrap();
1170
1171        let manifest = test_manifest();
1172        let plan = adapter.plan_from_live(&manifest).unwrap();
1173
1174        // Should plan AddField for displayName and age.
1175        let add_fields: Vec<_> = plan
1176            .operations
1177            .iter()
1178            .filter(|op| matches!(op, SchemaOperation::AddField { .. }))
1179            .collect();
1180        assert_eq!(add_fields.len(), 2);
1181    }
1182
1183    #[test]
1184    fn plan_detects_missing_index() {
1185        let adapter = SqliteAdapter::in_memory().unwrap();
1186
1187        // Create table with all columns but no index.
1188        adapter
1189            .conn
1190            .execute(
1191                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, \"displayName\" TEXT NOT NULL, age INTEGER)",
1192                [],
1193            )
1194            .unwrap();
1195
1196        let manifest = test_manifest();
1197        let plan = adapter.plan_from_live(&manifest).unwrap();
1198
1199        // Should plan AddIndex only.
1200        assert!(plan.operations.iter().any(|op| matches!(
1201            op,
1202            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1203        )));
1204        assert!(!plan
1205            .operations
1206            .iter()
1207            .any(|op| matches!(op, SchemaOperation::CreateEntity { .. })));
1208    }
1209
1210    // -- Migration history tests --
1211
1212    fn push_meta(baseline: &str) -> PushMetadata<'_> {
1213        PushMetadata {
1214            manifest_version: 1,
1215            app_version: "0.1.0",
1216            baseline,
1217        }
1218    }
1219
1220    fn history_count(adapter: &SqliteAdapter) -> i64 {
1221        adapter
1222            .conn
1223            .query_row(
1224                &format!("SELECT COUNT(*) FROM {}", quote_ident(HISTORY_TABLE)),
1225                [],
1226                |row| row.get(0),
1227            )
1228            .unwrap()
1229    }
1230
1231    #[test]
1232    fn history_table_created_on_apply() {
1233        let adapter = SqliteAdapter::in_memory().unwrap();
1234        let manifest = test_manifest();
1235        let plan = adapter.plan_from_live(&manifest).unwrap();
1236        adapter
1237            .apply_with_history(&plan, &push_meta("live_sqlite"))
1238            .unwrap();
1239
1240        let table_exists: i64 = adapter
1241            .conn
1242            .query_row(
1243                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1244                [HISTORY_TABLE],
1245                |row| row.get(0),
1246            )
1247            .unwrap();
1248        assert_eq!(table_exists, 1);
1249    }
1250
1251    #[test]
1252    fn history_row_inserted_on_apply() {
1253        let adapter = SqliteAdapter::in_memory().unwrap();
1254        let manifest = test_manifest();
1255        let plan = adapter.plan_from_live(&manifest).unwrap();
1256        adapter
1257            .apply_with_history(&plan, &push_meta("live_sqlite"))
1258            .unwrap();
1259
1260        assert_eq!(history_count(&adapter), 1);
1261
1262        // Verify stored data.
1263        let (mv, av, baseline, op_count): (i64, String, String, i64) = adapter
1264            .conn
1265            .query_row(
1266                &format!(
1267                    "SELECT manifest_version, app_version, baseline, operation_count FROM {} LIMIT 1",
1268                    quote_ident(HISTORY_TABLE)
1269                ),
1270                [],
1271                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1272            )
1273            .unwrap();
1274        assert_eq!(mv, 1);
1275        assert_eq!(av, "0.1.0");
1276        assert_eq!(baseline, "live_sqlite");
1277        assert_eq!(op_count, 2); // CreateEntity + AddIndex (Noop not counted)
1278    }
1279
1280    #[test]
1281    fn noop_push_also_recorded() {
1282        let adapter = SqliteAdapter::in_memory().unwrap();
1283        let manifest = test_manifest();
1284
1285        // First push creates tables.
1286        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1287        adapter
1288            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1289            .unwrap();
1290
1291        // Second push is noop.
1292        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1293        assert!(plan2.is_empty());
1294        adapter
1295            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1296            .unwrap();
1297
1298        // Both pushes recorded.
1299        assert_eq!(history_count(&adapter), 2);
1300
1301        // Second row has 0 operations.
1302        let op_count: i64 = adapter
1303            .conn
1304            .query_row(
1305                &format!(
1306                    "SELECT operation_count FROM {} ORDER BY id DESC LIMIT 1",
1307                    quote_ident(HISTORY_TABLE)
1308                ),
1309                [],
1310                |row| row.get(0),
1311            )
1312            .unwrap();
1313        assert_eq!(op_count, 0);
1314    }
1315
1316    #[test]
1317    fn history_plan_json_is_valid() {
1318        let adapter = SqliteAdapter::in_memory().unwrap();
1319        let manifest = test_manifest();
1320        let plan = adapter.plan_from_live(&manifest).unwrap();
1321        adapter
1322            .apply_with_history(&plan, &push_meta("live_sqlite"))
1323            .unwrap();
1324
1325        let plan_json: String = adapter
1326            .conn
1327            .query_row(
1328                &format!(
1329                    "SELECT plan_json FROM {} LIMIT 1",
1330                    quote_ident(HISTORY_TABLE)
1331                ),
1332                [],
1333                |row| row.get(0),
1334            )
1335            .unwrap();
1336
1337        // Should be valid JSON.
1338        let parsed: serde_json::Value = serde_json::from_str(&plan_json).unwrap();
1339        assert!(parsed.get("operations").unwrap().is_array());
1340    }
1341
1342    #[test]
1343    fn history_table_excluded_from_introspection() {
1344        let adapter = SqliteAdapter::in_memory().unwrap();
1345        let manifest = test_manifest();
1346        let plan = adapter.plan_from_live(&manifest).unwrap();
1347        adapter
1348            .apply_with_history(&plan, &push_meta("live_sqlite"))
1349            .unwrap();
1350
1351        let snapshot = adapter.read_schema().unwrap();
1352        assert!(!snapshot.tables.iter().any(|t| t.name.starts_with("_pylon")));
1353    }
1354
1355    // -- read_history tests --
1356
1357    #[test]
1358    fn read_history_empty_db() {
1359        let adapter = SqliteAdapter::in_memory().unwrap();
1360        let entries = adapter.read_history(None).unwrap();
1361        assert!(entries.is_empty());
1362    }
1363
1364    #[test]
1365    fn read_history_after_one_push() {
1366        let adapter = SqliteAdapter::in_memory().unwrap();
1367        let manifest = test_manifest();
1368        let plan = adapter.plan_from_live(&manifest).unwrap();
1369        adapter
1370            .apply_with_history(&plan, &push_meta("live_sqlite"))
1371            .unwrap();
1372
1373        let entries = adapter.read_history(None).unwrap();
1374        assert_eq!(entries.len(), 1);
1375        assert_eq!(entries[0].manifest_version, 1);
1376        assert_eq!(entries[0].app_version, "0.1.0");
1377        assert_eq!(entries[0].baseline, "live_sqlite");
1378        assert_eq!(entries[0].operation_count, 2); // CreateEntity + AddIndex
1379    }
1380
1381    #[test]
1382    fn read_history_after_noop_push() {
1383        let adapter = SqliteAdapter::in_memory().unwrap();
1384        let manifest = test_manifest();
1385
1386        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1387        adapter
1388            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1389            .unwrap();
1390
1391        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1392        adapter
1393            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1394            .unwrap();
1395
1396        let entries = adapter.read_history(None).unwrap();
1397        assert_eq!(entries.len(), 2);
1398        // Newest first.
1399        assert_eq!(entries[0].operation_count, 0);
1400        assert_eq!(entries[1].operation_count, 2);
1401    }
1402
1403    #[test]
1404    fn read_history_newest_first() {
1405        let adapter = SqliteAdapter::in_memory().unwrap();
1406        let manifest = test_manifest();
1407
1408        let plan = adapter.plan_from_live(&manifest).unwrap();
1409        adapter
1410            .apply_with_history(
1411                &plan,
1412                &PushMetadata {
1413                    manifest_version: 1,
1414                    app_version: "0.1.0",
1415                    baseline: "first",
1416                },
1417            )
1418            .unwrap();
1419
1420        // Small delay not needed — timestamps have nanosecond precision.
1421        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1422        adapter
1423            .apply_with_history(
1424                &plan2,
1425                &PushMetadata {
1426                    manifest_version: 1,
1427                    app_version: "0.2.0",
1428                    baseline: "second",
1429                },
1430            )
1431            .unwrap();
1432
1433        let entries = adapter.read_history(None).unwrap();
1434        assert_eq!(entries.len(), 2);
1435        assert_eq!(entries[0].baseline, "second");
1436        assert_eq!(entries[1].baseline, "first");
1437    }
1438
1439    #[test]
1440    fn read_history_with_limit() {
1441        let adapter = SqliteAdapter::in_memory().unwrap();
1442        let manifest = test_manifest();
1443
1444        // Push twice.
1445        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1446        adapter
1447            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1448            .unwrap();
1449        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1450        adapter
1451            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1452            .unwrap();
1453
1454        let all = adapter.read_history(None).unwrap();
1455        assert_eq!(all.len(), 2);
1456
1457        let limited = adapter.read_history(Some(1)).unwrap();
1458        assert_eq!(limited.len(), 1);
1459    }
1460
1461    #[test]
1462    fn read_history_entry_by_id() {
1463        let adapter = SqliteAdapter::in_memory().unwrap();
1464        let manifest = test_manifest();
1465
1466        let plan = adapter.plan_from_live(&manifest).unwrap();
1467        adapter
1468            .apply_with_history(&plan, &push_meta("live_sqlite"))
1469            .unwrap();
1470
1471        let entries = adapter.read_history(None).unwrap();
1472        let id = &entries[0].id;
1473
1474        let entry = adapter.read_history_entry(id).unwrap().unwrap();
1475        assert_eq!(&entry.id, id);
1476        assert_eq!(entry.operation_count, 2);
1477    }
1478
1479    #[test]
1480    fn read_history_entry_missing_id() {
1481        let adapter = SqliteAdapter::in_memory().unwrap();
1482        let result = adapter.read_history_entry("nonexistent").unwrap();
1483        assert!(result.is_none());
1484    }
1485
1486    #[test]
1487    fn history_entry_has_parsed_plan() {
1488        let adapter = SqliteAdapter::in_memory().unwrap();
1489        let manifest = test_manifest();
1490
1491        let plan = adapter.plan_from_live(&manifest).unwrap();
1492        adapter
1493            .apply_with_history(&plan, &push_meta("live_sqlite"))
1494            .unwrap();
1495
1496        let entries = adapter.read_history(None).unwrap();
1497        let entry = &entries[0];
1498
1499        // plan should be parsed from plan_json.
1500        assert!(entry.plan.is_some());
1501        let parsed_plan = entry.plan.as_ref().unwrap();
1502        assert!(!parsed_plan.operations.is_empty());
1503
1504        // plan_json should still be present as raw string.
1505        assert!(!entry.plan_json.is_empty());
1506    }
1507}