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        }
807    }
808
809    #[test]
810    fn create_table_sql_basic() {
811        let fields = vec![
812            FieldSpec {
813                name: "email".into(),
814                field_type: "string".into(),
815                optional: false,
816                unique: true,
817            },
818            FieldSpec {
819                name: "age".into(),
820                field_type: "int".into(),
821                optional: true,
822                unique: false,
823            },
824        ];
825        let sql = create_table_sql("User", &fields);
826        assert_eq!(
827            sql,
828            "CREATE TABLE IF NOT EXISTS \"User\" (id TEXT PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL UNIQUE, \"age\" INTEGER)"
829        );
830    }
831
832    #[test]
833    fn create_index_sql_basic() {
834        let sql = create_index_sql("User", "by_email", &["email".into()], true);
835        assert_eq!(
836            sql,
837            "CREATE UNIQUE INDEX IF NOT EXISTS \"User_by_email\" ON \"User\" (\"email\")"
838        );
839    }
840
841    #[test]
842    fn create_index_sql_non_unique() {
843        let sql = create_index_sql("Todo", "by_user", &["userId".into()], false);
844        assert_eq!(
845            sql,
846            "CREATE INDEX IF NOT EXISTS \"Todo_by_user\" ON \"Todo\" (\"userId\")"
847        );
848    }
849
850    #[test]
851    fn add_column_sql_basic() {
852        let field = FieldSpec {
853            name: "bio".into(),
854            field_type: "string".into(),
855            optional: true,
856            unique: false,
857        };
858        let sql = add_column_sql("User", &field);
859        assert_eq!(sql, "ALTER TABLE \"User\" ADD COLUMN \"bio\" TEXT");
860    }
861
862    #[test]
863    fn quote_ident_escapes_double_quotes() {
864        assert_eq!(quote_ident("normal"), "\"normal\"");
865        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
866        assert_eq!(
867            quote_ident("Robert'); DROP TABLE Students;--"),
868            "\"Robert'); DROP TABLE Students;--\""
869        );
870    }
871
872    #[test]
873    fn sqlite_adapter_creates_table() {
874        let adapter = SqliteAdapter::in_memory().unwrap();
875        let manifest = test_manifest();
876        let plan = adapter.plan_schema(&manifest).unwrap();
877        adapter.apply_schema(&plan).unwrap();
878
879        // Verify table exists by querying sqlite_master.
880        let table_count: i64 = adapter
881            .conn
882            .query_row(
883                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User'",
884                [],
885                |row| row.get(0),
886            )
887            .unwrap();
888        assert_eq!(table_count, 1);
889    }
890
891    #[test]
892    fn sqlite_adapter_creates_index() {
893        let adapter = SqliteAdapter::in_memory().unwrap();
894        let manifest = test_manifest();
895        let plan = adapter.plan_schema(&manifest).unwrap();
896        adapter.apply_schema(&plan).unwrap();
897
898        // Verify index exists.
899        let index_count: i64 = adapter
900            .conn
901            .query_row(
902                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='User_by_email'",
903                [],
904                |row| row.get(0),
905            )
906            .unwrap();
907        assert_eq!(index_count, 1);
908    }
909
910    #[test]
911    fn sqlite_adapter_add_field() {
912        let adapter = SqliteAdapter::in_memory().unwrap();
913
914        // Create table first.
915        let manifest = test_manifest();
916        let plan = adapter.plan_schema(&manifest).unwrap();
917        adapter.apply_schema(&plan).unwrap();
918
919        // Add a field.
920        let add_plan = SchemaPlan {
921            operations: vec![SchemaOperation::AddField {
922                entity: "User".into(),
923                field: FieldSpec {
924                    name: "bio".into(),
925                    field_type: "string".into(),
926                    optional: true,
927                    unique: false,
928                },
929            }],
930        };
931        adapter.apply_schema(&add_plan).unwrap();
932
933        // Verify column exists by checking pragma.
934        let has_bio: bool = adapter
935            .conn
936            .prepare("PRAGMA table_info(\"User\")")
937            .unwrap()
938            .query_map([], |row| {
939                let name: String = row.get(1)?;
940                Ok(name)
941            })
942            .unwrap()
943            .any(|r| r.unwrap() == "bio");
944        assert!(has_bio);
945    }
946
947    #[test]
948    fn sqlite_adapter_rejects_remove_entity() {
949        let adapter = SqliteAdapter::in_memory().unwrap();
950        let plan = SchemaPlan {
951            operations: vec![SchemaOperation::RemoveEntity {
952                name: "User".into(),
953            }],
954        };
955        let result = adapter.apply_schema(&plan);
956        assert!(result.is_err());
957        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
958    }
959
960    #[test]
961    fn sqlite_adapter_rejects_remove_field() {
962        let adapter = SqliteAdapter::in_memory().unwrap();
963        let plan = SchemaPlan {
964            operations: vec![SchemaOperation::RemoveField {
965                entity: "User".into(),
966                field_name: "email".into(),
967            }],
968        };
969        let result = adapter.apply_schema(&plan);
970        assert!(result.is_err());
971        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
972    }
973
974    #[test]
975    fn sqlite_adapter_column_types() {
976        assert_eq!(sqlite_column_type("string"), "TEXT");
977        assert_eq!(sqlite_column_type("int"), "INTEGER");
978        assert_eq!(sqlite_column_type("float"), "REAL");
979        assert_eq!(sqlite_column_type("bool"), "INTEGER");
980        assert_eq!(sqlite_column_type("datetime"), "TEXT");
981        assert_eq!(sqlite_column_type("richtext"), "TEXT");
982        assert_eq!(sqlite_column_type("id(User)"), "TEXT");
983    }
984
985    // -- Introspection tests --
986
987    #[test]
988    fn introspect_empty_db() {
989        let adapter = SqliteAdapter::in_memory().unwrap();
990        let snapshot = adapter.read_schema().unwrap();
991        assert!(snapshot.tables.is_empty());
992    }
993
994    #[test]
995    fn introspect_after_apply() {
996        let adapter = SqliteAdapter::in_memory().unwrap();
997        let manifest = test_manifest();
998        let plan = adapter.plan_schema(&manifest).unwrap();
999        adapter.apply_schema(&plan).unwrap();
1000
1001        let snapshot = adapter.read_schema().unwrap();
1002
1003        // Should have one table.
1004        assert_eq!(snapshot.tables.len(), 1);
1005        let user = &snapshot.tables[0];
1006        assert_eq!(user.name, "User");
1007
1008        // id + 3 manifest fields = 4 columns.
1009        assert_eq!(user.columns.len(), 4);
1010        assert_eq!(user.columns[0].name, "id");
1011        assert!(user.columns[0].primary_key);
1012        assert_eq!(user.columns[1].name, "email");
1013        assert_eq!(user.columns[1].column_type, "TEXT");
1014        assert!(user.columns[1].notnull);
1015        assert_eq!(user.columns[2].name, "displayName");
1016        assert_eq!(user.columns[3].name, "age");
1017        assert!(!user.columns[3].notnull); // optional
1018
1019        // Should have the by_email index.
1020        assert_eq!(user.indexes.len(), 1);
1021        assert_eq!(user.indexes[0].name, "User_by_email");
1022        assert_eq!(user.indexes[0].columns, vec!["email"]);
1023        assert!(user.indexes[0].unique);
1024    }
1025
1026    #[test]
1027    fn introspect_multiple_tables() {
1028        let adapter = SqliteAdapter::in_memory().unwrap();
1029
1030        let manifest = AppManifest {
1031            manifest_version: MANIFEST_VERSION,
1032            name: "test".into(),
1033            version: "0.1.0".into(),
1034            entities: vec![
1035                ManifestEntity {
1036                    name: "Post".into(),
1037                    fields: vec![ManifestField {
1038                        name: "title".into(),
1039                        field_type: "string".into(),
1040                        optional: false,
1041                        unique: false,
1042                        crdt: None,
1043                    }],
1044                    indexes: vec![],
1045                    relations: vec![],
1046                    search: None,
1047                    crdt: true,
1048                },
1049                ManifestEntity {
1050                    name: "User".into(),
1051                    fields: vec![ManifestField {
1052                        name: "email".into(),
1053                        field_type: "string".into(),
1054                        optional: false,
1055                        unique: true,
1056                        crdt: None,
1057                    }],
1058                    indexes: vec![],
1059                    relations: vec![],
1060                    search: None,
1061                    crdt: true,
1062                },
1063            ],
1064            routes: vec![],
1065            queries: vec![],
1066            actions: vec![],
1067            policies: vec![],
1068        };
1069
1070        let plan = adapter.plan_schema(&manifest).unwrap();
1071        adapter.apply_schema(&plan).unwrap();
1072
1073        let snapshot = adapter.read_schema().unwrap();
1074
1075        // Sorted alphabetically.
1076        assert_eq!(snapshot.tables.len(), 2);
1077        assert_eq!(snapshot.tables[0].name, "Post");
1078        assert_eq!(snapshot.tables[1].name, "User");
1079    }
1080
1081    #[test]
1082    fn introspect_after_add_field() {
1083        let adapter = SqliteAdapter::in_memory().unwrap();
1084        let manifest = test_manifest();
1085        let plan = adapter.plan_schema(&manifest).unwrap();
1086        adapter.apply_schema(&plan).unwrap();
1087
1088        // Add a column.
1089        let add_plan = SchemaPlan {
1090            operations: vec![SchemaOperation::AddField {
1091                entity: "User".into(),
1092                field: FieldSpec {
1093                    name: "bio".into(),
1094                    field_type: "string".into(),
1095                    optional: true,
1096                    unique: false,
1097                },
1098            }],
1099        };
1100        adapter.apply_schema(&add_plan).unwrap();
1101
1102        let snapshot = adapter.read_schema().unwrap();
1103        let user = &snapshot.tables[0];
1104
1105        // id + 3 original + 1 added = 5.
1106        assert_eq!(user.columns.len(), 5);
1107        assert!(user.columns.iter().any(|c| c.name == "bio"));
1108    }
1109
1110    #[test]
1111    fn introspect_snapshot_is_deterministic() {
1112        let adapter = SqliteAdapter::in_memory().unwrap();
1113        let manifest = test_manifest();
1114        let plan = adapter.plan_schema(&manifest).unwrap();
1115        adapter.apply_schema(&plan).unwrap();
1116
1117        let s1 = adapter.read_schema().unwrap();
1118        let s2 = adapter.read_schema().unwrap();
1119        assert_eq!(s1, s2);
1120    }
1121
1122    // -- Live planning tests --
1123
1124    #[test]
1125    fn plan_from_empty_db_creates_everything() {
1126        let adapter = SqliteAdapter::in_memory().unwrap();
1127        let manifest = test_manifest();
1128
1129        let plan = adapter.plan_from_live(&manifest).unwrap();
1130
1131        // Should create the table and its index.
1132        assert!(plan.operations.iter().any(|op| matches!(
1133            op,
1134            SchemaOperation::CreateEntity { name, .. } if name == "User"
1135        )));
1136        assert!(plan.operations.iter().any(|op| matches!(
1137            op,
1138            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1139        )));
1140    }
1141
1142    #[test]
1143    fn plan_from_fully_applied_db_is_noop() {
1144        let adapter = SqliteAdapter::in_memory().unwrap();
1145        let manifest = test_manifest();
1146
1147        // Apply everything first.
1148        let initial = adapter.plan_from_live(&manifest).unwrap();
1149        adapter.apply_schema(&initial).unwrap();
1150
1151        // Plan again — should be noop.
1152        let plan = adapter.plan_from_live(&manifest).unwrap();
1153        assert!(plan.is_empty(), "expected noop, got: {:?}", plan.operations);
1154    }
1155
1156    #[test]
1157    fn plan_detects_missing_column() {
1158        let adapter = SqliteAdapter::in_memory().unwrap();
1159
1160        // Create table with only email.
1161        adapter
1162            .conn
1163            .execute(
1164                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE)",
1165                [],
1166            )
1167            .unwrap();
1168
1169        let manifest = test_manifest();
1170        let plan = adapter.plan_from_live(&manifest).unwrap();
1171
1172        // Should plan AddField for displayName and age.
1173        let add_fields: Vec<_> = plan
1174            .operations
1175            .iter()
1176            .filter(|op| matches!(op, SchemaOperation::AddField { .. }))
1177            .collect();
1178        assert_eq!(add_fields.len(), 2);
1179    }
1180
1181    #[test]
1182    fn plan_detects_missing_index() {
1183        let adapter = SqliteAdapter::in_memory().unwrap();
1184
1185        // Create table with all columns but no index.
1186        adapter
1187            .conn
1188            .execute(
1189                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, \"displayName\" TEXT NOT NULL, age INTEGER)",
1190                [],
1191            )
1192            .unwrap();
1193
1194        let manifest = test_manifest();
1195        let plan = adapter.plan_from_live(&manifest).unwrap();
1196
1197        // Should plan AddIndex only.
1198        assert!(plan.operations.iter().any(|op| matches!(
1199            op,
1200            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1201        )));
1202        assert!(!plan
1203            .operations
1204            .iter()
1205            .any(|op| matches!(op, SchemaOperation::CreateEntity { .. })));
1206    }
1207
1208    // -- Migration history tests --
1209
1210    fn push_meta(baseline: &str) -> PushMetadata<'_> {
1211        PushMetadata {
1212            manifest_version: 1,
1213            app_version: "0.1.0",
1214            baseline,
1215        }
1216    }
1217
1218    fn history_count(adapter: &SqliteAdapter) -> i64 {
1219        adapter
1220            .conn
1221            .query_row(
1222                &format!("SELECT COUNT(*) FROM {}", quote_ident(HISTORY_TABLE)),
1223                [],
1224                |row| row.get(0),
1225            )
1226            .unwrap()
1227    }
1228
1229    #[test]
1230    fn history_table_created_on_apply() {
1231        let adapter = SqliteAdapter::in_memory().unwrap();
1232        let manifest = test_manifest();
1233        let plan = adapter.plan_from_live(&manifest).unwrap();
1234        adapter
1235            .apply_with_history(&plan, &push_meta("live_sqlite"))
1236            .unwrap();
1237
1238        let table_exists: i64 = adapter
1239            .conn
1240            .query_row(
1241                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1242                [HISTORY_TABLE],
1243                |row| row.get(0),
1244            )
1245            .unwrap();
1246        assert_eq!(table_exists, 1);
1247    }
1248
1249    #[test]
1250    fn history_row_inserted_on_apply() {
1251        let adapter = SqliteAdapter::in_memory().unwrap();
1252        let manifest = test_manifest();
1253        let plan = adapter.plan_from_live(&manifest).unwrap();
1254        adapter
1255            .apply_with_history(&plan, &push_meta("live_sqlite"))
1256            .unwrap();
1257
1258        assert_eq!(history_count(&adapter), 1);
1259
1260        // Verify stored data.
1261        let (mv, av, baseline, op_count): (i64, String, String, i64) = adapter
1262            .conn
1263            .query_row(
1264                &format!(
1265                    "SELECT manifest_version, app_version, baseline, operation_count FROM {} LIMIT 1",
1266                    quote_ident(HISTORY_TABLE)
1267                ),
1268                [],
1269                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1270            )
1271            .unwrap();
1272        assert_eq!(mv, 1);
1273        assert_eq!(av, "0.1.0");
1274        assert_eq!(baseline, "live_sqlite");
1275        assert_eq!(op_count, 2); // CreateEntity + AddIndex (Noop not counted)
1276    }
1277
1278    #[test]
1279    fn noop_push_also_recorded() {
1280        let adapter = SqliteAdapter::in_memory().unwrap();
1281        let manifest = test_manifest();
1282
1283        // First push creates tables.
1284        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1285        adapter
1286            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1287            .unwrap();
1288
1289        // Second push is noop.
1290        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1291        assert!(plan2.is_empty());
1292        adapter
1293            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1294            .unwrap();
1295
1296        // Both pushes recorded.
1297        assert_eq!(history_count(&adapter), 2);
1298
1299        // Second row has 0 operations.
1300        let op_count: i64 = adapter
1301            .conn
1302            .query_row(
1303                &format!(
1304                    "SELECT operation_count FROM {} ORDER BY id DESC LIMIT 1",
1305                    quote_ident(HISTORY_TABLE)
1306                ),
1307                [],
1308                |row| row.get(0),
1309            )
1310            .unwrap();
1311        assert_eq!(op_count, 0);
1312    }
1313
1314    #[test]
1315    fn history_plan_json_is_valid() {
1316        let adapter = SqliteAdapter::in_memory().unwrap();
1317        let manifest = test_manifest();
1318        let plan = adapter.plan_from_live(&manifest).unwrap();
1319        adapter
1320            .apply_with_history(&plan, &push_meta("live_sqlite"))
1321            .unwrap();
1322
1323        let plan_json: String = adapter
1324            .conn
1325            .query_row(
1326                &format!(
1327                    "SELECT plan_json FROM {} LIMIT 1",
1328                    quote_ident(HISTORY_TABLE)
1329                ),
1330                [],
1331                |row| row.get(0),
1332            )
1333            .unwrap();
1334
1335        // Should be valid JSON.
1336        let parsed: serde_json::Value = serde_json::from_str(&plan_json).unwrap();
1337        assert!(parsed.get("operations").unwrap().is_array());
1338    }
1339
1340    #[test]
1341    fn history_table_excluded_from_introspection() {
1342        let adapter = SqliteAdapter::in_memory().unwrap();
1343        let manifest = test_manifest();
1344        let plan = adapter.plan_from_live(&manifest).unwrap();
1345        adapter
1346            .apply_with_history(&plan, &push_meta("live_sqlite"))
1347            .unwrap();
1348
1349        let snapshot = adapter.read_schema().unwrap();
1350        assert!(!snapshot.tables.iter().any(|t| t.name.starts_with("_pylon")));
1351    }
1352
1353    // -- read_history tests --
1354
1355    #[test]
1356    fn read_history_empty_db() {
1357        let adapter = SqliteAdapter::in_memory().unwrap();
1358        let entries = adapter.read_history(None).unwrap();
1359        assert!(entries.is_empty());
1360    }
1361
1362    #[test]
1363    fn read_history_after_one_push() {
1364        let adapter = SqliteAdapter::in_memory().unwrap();
1365        let manifest = test_manifest();
1366        let plan = adapter.plan_from_live(&manifest).unwrap();
1367        adapter
1368            .apply_with_history(&plan, &push_meta("live_sqlite"))
1369            .unwrap();
1370
1371        let entries = adapter.read_history(None).unwrap();
1372        assert_eq!(entries.len(), 1);
1373        assert_eq!(entries[0].manifest_version, 1);
1374        assert_eq!(entries[0].app_version, "0.1.0");
1375        assert_eq!(entries[0].baseline, "live_sqlite");
1376        assert_eq!(entries[0].operation_count, 2); // CreateEntity + AddIndex
1377    }
1378
1379    #[test]
1380    fn read_history_after_noop_push() {
1381        let adapter = SqliteAdapter::in_memory().unwrap();
1382        let manifest = test_manifest();
1383
1384        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1385        adapter
1386            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1387            .unwrap();
1388
1389        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1390        adapter
1391            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1392            .unwrap();
1393
1394        let entries = adapter.read_history(None).unwrap();
1395        assert_eq!(entries.len(), 2);
1396        // Newest first.
1397        assert_eq!(entries[0].operation_count, 0);
1398        assert_eq!(entries[1].operation_count, 2);
1399    }
1400
1401    #[test]
1402    fn read_history_newest_first() {
1403        let adapter = SqliteAdapter::in_memory().unwrap();
1404        let manifest = test_manifest();
1405
1406        let plan = adapter.plan_from_live(&manifest).unwrap();
1407        adapter
1408            .apply_with_history(
1409                &plan,
1410                &PushMetadata {
1411                    manifest_version: 1,
1412                    app_version: "0.1.0",
1413                    baseline: "first",
1414                },
1415            )
1416            .unwrap();
1417
1418        // Small delay not needed — timestamps have nanosecond precision.
1419        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1420        adapter
1421            .apply_with_history(
1422                &plan2,
1423                &PushMetadata {
1424                    manifest_version: 1,
1425                    app_version: "0.2.0",
1426                    baseline: "second",
1427                },
1428            )
1429            .unwrap();
1430
1431        let entries = adapter.read_history(None).unwrap();
1432        assert_eq!(entries.len(), 2);
1433        assert_eq!(entries[0].baseline, "second");
1434        assert_eq!(entries[1].baseline, "first");
1435    }
1436
1437    #[test]
1438    fn read_history_with_limit() {
1439        let adapter = SqliteAdapter::in_memory().unwrap();
1440        let manifest = test_manifest();
1441
1442        // Push twice.
1443        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1444        adapter
1445            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1446            .unwrap();
1447        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1448        adapter
1449            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1450            .unwrap();
1451
1452        let all = adapter.read_history(None).unwrap();
1453        assert_eq!(all.len(), 2);
1454
1455        let limited = adapter.read_history(Some(1)).unwrap();
1456        assert_eq!(limited.len(), 1);
1457    }
1458
1459    #[test]
1460    fn read_history_entry_by_id() {
1461        let adapter = SqliteAdapter::in_memory().unwrap();
1462        let manifest = test_manifest();
1463
1464        let plan = adapter.plan_from_live(&manifest).unwrap();
1465        adapter
1466            .apply_with_history(&plan, &push_meta("live_sqlite"))
1467            .unwrap();
1468
1469        let entries = adapter.read_history(None).unwrap();
1470        let id = &entries[0].id;
1471
1472        let entry = adapter.read_history_entry(id).unwrap().unwrap();
1473        assert_eq!(&entry.id, id);
1474        assert_eq!(entry.operation_count, 2);
1475    }
1476
1477    #[test]
1478    fn read_history_entry_missing_id() {
1479        let adapter = SqliteAdapter::in_memory().unwrap();
1480        let result = adapter.read_history_entry("nonexistent").unwrap();
1481        assert!(result.is_none());
1482    }
1483
1484    #[test]
1485    fn history_entry_has_parsed_plan() {
1486        let adapter = SqliteAdapter::in_memory().unwrap();
1487        let manifest = test_manifest();
1488
1489        let plan = adapter.plan_from_live(&manifest).unwrap();
1490        adapter
1491            .apply_with_history(&plan, &push_meta("live_sqlite"))
1492            .unwrap();
1493
1494        let entries = adapter.read_history(None).unwrap();
1495        let entry = &entries[0];
1496
1497        // plan should be parsed from plan_json.
1498        assert!(entry.plan.is_some());
1499        let parsed_plan = entry.plan.as_ref().unwrap();
1500        assert!(!parsed_plan.operations.is_empty());
1501
1502        // plan_json should still be present as raw string.
1503        assert!(!entry.plan_json.is_empty());
1504    }
1505}