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        Ok(Self { conn })
126    }
127
128    /// Create an in-memory SQLite database.
129    pub fn in_memory() -> Result<Self, StorageError> {
130        let conn = Connection::open_in_memory().map_err(|e| StorageError {
131            code: "SQLITE_OPEN_FAILED".into(),
132            message: format!("Failed to open in-memory SQLite database: {e}"),
133        })?;
134        Ok(Self { conn })
135    }
136}
137
138impl SqliteAdapter {
139    /// Plan schema changes by comparing the live DB state against the target manifest.
140    /// Only plans additive operations: CreateEntity, AddField, AddIndex.
141    pub fn plan_from_live(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
142        let snapshot = self.read_schema()?;
143        Ok(crate::plan_from_snapshot(&snapshot, target))
144    }
145}
146
147impl StorageAdapter for SqliteAdapter {
148    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
149        // Plan from live DB state.
150        self.plan_from_live(target)
151    }
152
153    fn apply_schema(&self, plan: &SchemaPlan) -> Result<(), StorageError> {
154        // Wrap the whole plan in a single transaction so that if operation N
155        // fails, operations 1..N are rolled back. Without this, a partial
156        // migration would leave the database in an inconsistent state that
157        // doesn't match either the old or the new manifest.
158        self.conn.execute("BEGIN", []).map_err(|e| StorageError {
159            code: "SQLITE_EXEC_FAILED".into(),
160            message: format!("BEGIN failed: {e}"),
161        })?;
162        match self.apply_schema_impl(plan) {
163            Ok(()) => {
164                self.conn.execute("COMMIT", []).map_err(|e| StorageError {
165                    code: "SQLITE_EXEC_FAILED".into(),
166                    message: format!("COMMIT failed after apply: {e}"),
167                })?;
168                Ok(())
169            }
170            Err(e) => {
171                if let Err(rb) = self.conn.execute("ROLLBACK", []) {
172                    // Log both — a failed rollback leaves the connection in
173                    // a broken state but the original error is what the
174                    // caller cares about.
175                    tracing::warn!("[sqlite] ROLLBACK after apply error failed: {rb}");
176                }
177                Err(e)
178            }
179        }
180    }
181}
182
183// ---------------------------------------------------------------------------
184// Migration history
185// ---------------------------------------------------------------------------
186
187const HISTORY_TABLE: &str = "_pylon_schema_history";
188
189/// A single row from the schema push history table.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
191pub struct HistoryEntry {
192    pub id: String,
193    pub manifest_version: i64,
194    pub app_version: String,
195    pub applied_at: String,
196    pub operation_count: i64,
197    pub baseline: String,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub plan: Option<SchemaPlan>,
200    pub plan_json: String,
201}
202
203/// Metadata for a schema push event.
204pub struct PushMetadata<'a> {
205    pub manifest_version: u32,
206    pub app_version: &'a str,
207    pub baseline: &'a str,
208}
209
210impl SqliteAdapter {
211    /// Ensure the history table exists.
212    fn ensure_history_table(&self) -> Result<(), StorageError> {
213        let sql = format!(
214            "CREATE TABLE IF NOT EXISTS {} (\
215                id TEXT PRIMARY KEY NOT NULL, \
216                manifest_version INTEGER NOT NULL, \
217                app_version TEXT NOT NULL, \
218                applied_at TEXT NOT NULL, \
219                operation_count INTEGER NOT NULL, \
220                baseline TEXT NOT NULL, \
221                plan_json TEXT NOT NULL\
222            )",
223            quote_ident(HISTORY_TABLE)
224        );
225        self.conn.execute(&sql, []).map_err(|e| StorageError {
226            code: "SQLITE_EXEC_FAILED".into(),
227            message: format!("Failed to create history table: {e}"),
228        })?;
229        Ok(())
230    }
231
232    /// Apply a schema plan and record the push in the history table —
233    /// atomically. If either the DDL or the history INSERT fails, the
234    /// whole transaction rolls back so the database never ends up with a
235    /// schema change that has no history row, or a history row that
236    /// points at a failed migration.
237    pub fn apply_with_history(
238        &self,
239        plan: &SchemaPlan,
240        meta: &PushMetadata<'_>,
241    ) -> Result<(), StorageError> {
242        // History table creation runs OUTSIDE the transaction because
243        // CREATE TABLE IF NOT EXISTS is a cheap idempotent bootstrap and
244        // can safely predate the real migration atomicity boundary.
245        self.ensure_history_table()?;
246
247        self.conn.execute("BEGIN", []).map_err(|e| StorageError {
248            code: "SQLITE_EXEC_FAILED".into(),
249            message: format!("BEGIN failed: {e}"),
250        })?;
251
252        let result = (|| -> Result<(), StorageError> {
253            self.apply_schema_impl(plan)?;
254
255            let plan_json = serde_json::to_string(plan).map_err(|e| StorageError {
256                code: "SQLITE_SERIALIZE_FAILED".into(),
257                message: format!("Failed to serialize plan: {e}"),
258            })?;
259
260            let id = generate_push_id();
261            let now = now_iso8601();
262            let op_count = plan
263                .operations
264                .iter()
265                .filter(|op| !matches!(op, SchemaOperation::Noop))
266                .count() as i64;
267
268            self.conn
269                .execute(
270                    &format!(
271                        "INSERT INTO {} (id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json) \
272                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
273                        quote_ident(HISTORY_TABLE)
274                    ),
275                    rusqlite::params![
276                        id,
277                        meta.manifest_version as i64,
278                        meta.app_version,
279                        now,
280                        op_count,
281                        meta.baseline,
282                        plan_json,
283                    ],
284                )
285                .map_err(|e| StorageError {
286                    code: "SQLITE_EXEC_FAILED".into(),
287                    message: format!("Failed to insert history row: {e}"),
288                })?;
289            Ok(())
290        })();
291
292        match result {
293            Ok(()) => {
294                self.conn.execute("COMMIT", []).map_err(|e| StorageError {
295                    code: "SQLITE_EXEC_FAILED".into(),
296                    message: format!("COMMIT failed: {e}"),
297                })?;
298                Ok(())
299            }
300            Err(e) => {
301                if let Err(rb) = self.conn.execute("ROLLBACK", []) {
302                    tracing::warn!("[sqlite] ROLLBACK after apply_with_history error failed: {rb}");
303                }
304                Err(e)
305            }
306        }
307    }
308
309    /// Read schema push history, newest-first.
310    /// Returns empty vec if the history table does not exist.
311    pub fn read_history(&self, limit: Option<u32>) -> Result<Vec<HistoryEntry>, StorageError> {
312        if !self.history_table_exists()? {
313            return Ok(Vec::new());
314        }
315
316        let quoted = quote_ident(HISTORY_TABLE);
317        let sql = match limit {
318            Some(n) => format!(
319                "SELECT id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json \
320                 FROM {} ORDER BY id DESC LIMIT {}",
321                quoted, n
322            ),
323            None => format!(
324                "SELECT id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json \
325                 FROM {} ORDER BY id DESC",
326                quoted
327            ),
328        };
329
330        let mut stmt = self.conn.prepare(&sql).map_err(sqlite_err)?;
331
332        let entries = stmt
333            .query_map([], |row| {
334                let plan_json: String = row.get(6)?;
335                let plan = serde_json::from_str(&plan_json).ok();
336                Ok(HistoryEntry {
337                    id: row.get(0)?,
338                    manifest_version: row.get(1)?,
339                    app_version: row.get(2)?,
340                    applied_at: row.get(3)?,
341                    operation_count: row.get(4)?,
342                    baseline: row.get(5)?,
343                    plan,
344                    plan_json,
345                })
346            })
347            .map_err(sqlite_err)?
348            .collect::<Result<Vec<_>, _>>()
349            .map_err(sqlite_err)?;
350
351        Ok(entries)
352    }
353
354    /// Read a single history entry by ID.
355    /// Returns None if the history table doesn't exist or the ID is not found.
356    pub fn read_history_entry(&self, entry_id: &str) -> Result<Option<HistoryEntry>, StorageError> {
357        if !self.history_table_exists()? {
358            return Ok(None);
359        }
360
361        let mut stmt = self
362            .conn
363            .prepare(&format!(
364                "SELECT id, manifest_version, app_version, applied_at, operation_count, baseline, plan_json \
365                 FROM {} WHERE id = ?1",
366                quote_ident(HISTORY_TABLE)
367            ))
368            .map_err(sqlite_err)?;
369
370        let mut rows = stmt
371            .query_map([entry_id], |row| {
372                let plan_json: String = row.get(6)?;
373                let plan = serde_json::from_str(&plan_json).ok();
374                Ok(HistoryEntry {
375                    id: row.get(0)?,
376                    manifest_version: row.get(1)?,
377                    app_version: row.get(2)?,
378                    applied_at: row.get(3)?,
379                    operation_count: row.get(4)?,
380                    baseline: row.get(5)?,
381                    plan,
382                    plan_json,
383                })
384            })
385            .map_err(sqlite_err)?;
386
387        match rows.next() {
388            Some(Ok(entry)) => Ok(Some(entry)),
389            Some(Err(e)) => Err(sqlite_err(e)),
390            None => Ok(None),
391        }
392    }
393
394    fn history_table_exists(&self) -> Result<bool, StorageError> {
395        let exists: bool = self
396            .conn
397            .query_row(
398                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
399                [HISTORY_TABLE],
400                |row| row.get::<_, i64>(0),
401            )
402            .map_err(sqlite_err)?
403            > 0;
404        Ok(exists)
405    }
406
407    /// Internal apply implementation shared by both `apply_schema` and `apply_with_history`.
408    fn apply_schema_impl(&self, plan: &SchemaPlan) -> Result<(), StorageError> {
409        for op in &plan.operations {
410            match op {
411                SchemaOperation::CreateEntity { name, fields } => {
412                    let sql = create_table_sql(name, fields);
413                    self.conn.execute(&sql, []).map_err(|e| StorageError {
414                        code: "SQLITE_EXEC_FAILED".into(),
415                        message: format!("Failed to create table {name}: {e}"),
416                    })?;
417                }
418                SchemaOperation::AddField { entity, field } => {
419                    let sql = add_column_sql(entity, field);
420                    self.conn.execute(&sql, []).map_err(|e| StorageError {
421                        code: "SQLITE_EXEC_FAILED".into(),
422                        message: format!("Failed to add column {}.{}: {e}", entity, field.name),
423                    })?;
424                }
425                SchemaOperation::AddIndex {
426                    entity,
427                    name,
428                    fields,
429                    unique,
430                } => {
431                    let sql = create_index_sql(entity, name, fields, *unique);
432                    self.conn.execute(&sql, []).map_err(|e| StorageError {
433                        code: "SQLITE_EXEC_FAILED".into(),
434                        message: format!("Failed to create index {entity}.{name}: {e}"),
435                    })?;
436                }
437                SchemaOperation::Noop => {}
438                other => {
439                    return Err(StorageError {
440                        code: "SQLITE_OP_UNSUPPORTED".into(),
441                        message: format!("Operation not supported by SQLite adapter: {other:?}"),
442                    });
443                }
444            }
445        }
446        Ok(())
447    }
448}
449
450fn generate_push_id() -> String {
451    use std::time::{SystemTime, UNIX_EPOCH};
452    let ts = SystemTime::now()
453        .duration_since(UNIX_EPOCH)
454        .unwrap_or_default();
455    format!("{}.{:09}", ts.as_secs(), ts.subsec_nanos())
456}
457
458fn now_iso8601() -> String {
459    use std::time::{SystemTime, UNIX_EPOCH};
460    let ts = SystemTime::now()
461        .duration_since(UNIX_EPOCH)
462        .unwrap_or_default()
463        .as_secs();
464    // Simple UTC timestamp. Not worth pulling in chrono for this.
465    let secs_per_day: u64 = 86400;
466    let days = ts / secs_per_day;
467    let rem = ts % secs_per_day;
468    let hours = rem / 3600;
469    let mins = (rem % 3600) / 60;
470    let secs = rem % 60;
471    // Approximate date from epoch days (good enough for audit purposes).
472    let (year, month, day) = epoch_days_to_date(days);
473    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{mins:02}:{secs:02}Z")
474}
475
476fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
477    // Civil date from epoch days. Algorithm from Howard Hinnant.
478    let z = days + 719468;
479    let era = z / 146097;
480    let doe = z - era * 146097;
481    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
482    let y = yoe + era * 400;
483    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
484    let mp = (5 * doy + 2) / 153;
485    let d = doy - (153 * mp + 2) / 5 + 1;
486    let m = if mp < 10 { mp + 3 } else { mp - 9 };
487    let y = if m <= 2 { y + 1 } else { y };
488    (y, m, d)
489}
490
491// ---------------------------------------------------------------------------
492// Introspection
493// ---------------------------------------------------------------------------
494
495impl SqliteAdapter {
496    /// Read the current schema from the live SQLite database.
497    /// Only inspects user tables (not sqlite_* internal tables).
498    pub fn read_schema(&self) -> Result<SchemaSnapshot, StorageError> {
499        // Get all user tables, sorted for determinism.
500        let mut stmt = self
501            .conn
502            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_pylon_%' ORDER BY name")
503            .map_err(sqlite_err)?;
504
505        let table_names: Vec<String> = stmt
506            .query_map([], |row| row.get(0))
507            .map_err(sqlite_err)?
508            .collect::<Result<Vec<String>, _>>()
509            .map_err(sqlite_err)?;
510
511        let mut tables = Vec::new();
512        for table_name in &table_names {
513            let columns = self.read_columns(table_name)?;
514            let indexes = self.read_indexes(table_name)?;
515            tables.push(TableSnapshot {
516                name: table_name.clone(),
517                columns,
518                indexes,
519            });
520        }
521
522        Ok(SchemaSnapshot { tables })
523    }
524
525    fn read_columns(&self, table: &str) -> Result<Vec<ColumnSnapshot>, StorageError> {
526        let mut stmt = self
527            .conn
528            .prepare(&format!("PRAGMA table_info({})", quote_ident(table)))
529            .map_err(sqlite_err)?;
530
531        let columns: Vec<ColumnSnapshot> = stmt
532            .query_map([], |row| {
533                Ok(ColumnSnapshot {
534                    name: row.get(1)?,
535                    column_type: row.get(2)?,
536                    notnull: row.get::<_, i32>(3)? != 0,
537                    primary_key: row.get::<_, i32>(5)? != 0,
538                })
539            })
540            .map_err(sqlite_err)?
541            .collect::<Result<Vec<_>, _>>()
542            .map_err(sqlite_err)?;
543
544        Ok(columns)
545    }
546
547    fn read_indexes(&self, table: &str) -> Result<Vec<IndexSnapshot>, StorageError> {
548        let mut stmt = self
549            .conn
550            .prepare(&format!("PRAGMA index_list({})", quote_ident(table)))
551            .map_err(sqlite_err)?;
552
553        // Collect index metadata: (name, unique).
554        let index_meta: Vec<(String, bool)> = stmt
555            .query_map([], |row| {
556                let name: String = row.get(1)?;
557                let unique: bool = row.get::<_, i32>(2)? != 0;
558                Ok((name, unique))
559            })
560            .map_err(sqlite_err)?
561            .collect::<Result<Vec<_>, _>>()
562            .map_err(sqlite_err)?;
563
564        // Build ordered map for determinism, then read columns for each index.
565        let ordered: BTreeMap<String, bool> = index_meta.into_iter().collect();
566
567        let mut indexes = Vec::new();
568        for (name, unique) in &ordered {
569            // Skip SQLite autoindexes (internal unique constraint indexes).
570            if name.starts_with("sqlite_autoindex_") {
571                continue;
572            }
573
574            let mut col_stmt = self
575                .conn
576                .prepare(&format!("PRAGMA index_info({})", quote_ident(name)))
577                .map_err(sqlite_err)?;
578
579            let columns: Vec<String> = col_stmt
580                .query_map([], |row| row.get(2))
581                .map_err(sqlite_err)?
582                .collect::<Result<Vec<String>, _>>()
583                .map_err(sqlite_err)?;
584
585            indexes.push(IndexSnapshot {
586                name: name.clone(),
587                columns,
588                unique: *unique,
589            });
590        }
591
592        Ok(indexes)
593    }
594}
595
596fn sqlite_err(e: rusqlite::Error) -> StorageError {
597    StorageError {
598        code: "SQLITE_QUERY_FAILED".into(),
599        message: format!("SQLite query failed: {e}"),
600    }
601}
602
603// ---------------------------------------------------------------------------
604// Tests
605// ---------------------------------------------------------------------------
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use pylon_kernel::*;
611
612    fn test_manifest() -> AppManifest {
613        AppManifest {
614            manifest_version: MANIFEST_VERSION,
615            name: "test".into(),
616            version: "0.1.0".into(),
617            entities: vec![ManifestEntity {
618                name: "User".into(),
619                fields: vec![
620                    ManifestField {
621                        name: "email".into(),
622                        field_type: "string".into(),
623                        optional: false,
624                        unique: true,
625                    },
626                    ManifestField {
627                        name: "displayName".into(),
628                        field_type: "string".into(),
629                        optional: false,
630                        unique: false,
631                    },
632                    ManifestField {
633                        name: "age".into(),
634                        field_type: "int".into(),
635                        optional: true,
636                        unique: false,
637                    },
638                ],
639                indexes: vec![ManifestIndex {
640                    name: "by_email".into(),
641                    fields: vec!["email".into()],
642                    unique: true,
643                }],
644                relations: vec![],
645            }],
646            routes: vec![],
647            queries: vec![],
648            actions: vec![],
649            policies: vec![],
650        }
651    }
652
653    #[test]
654    fn create_table_sql_basic() {
655        let fields = vec![
656            FieldSpec {
657                name: "email".into(),
658                field_type: "string".into(),
659                optional: false,
660                unique: true,
661            },
662            FieldSpec {
663                name: "age".into(),
664                field_type: "int".into(),
665                optional: true,
666                unique: false,
667            },
668        ];
669        let sql = create_table_sql("User", &fields);
670        assert_eq!(
671            sql,
672            "CREATE TABLE IF NOT EXISTS \"User\" (id TEXT PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL UNIQUE, \"age\" INTEGER)"
673        );
674    }
675
676    #[test]
677    fn create_index_sql_basic() {
678        let sql = create_index_sql("User", "by_email", &["email".into()], true);
679        assert_eq!(
680            sql,
681            "CREATE UNIQUE INDEX IF NOT EXISTS \"User_by_email\" ON \"User\" (\"email\")"
682        );
683    }
684
685    #[test]
686    fn create_index_sql_non_unique() {
687        let sql = create_index_sql("Todo", "by_author", &["authorId".into()], false);
688        assert_eq!(
689            sql,
690            "CREATE INDEX IF NOT EXISTS \"Todo_by_author\" ON \"Todo\" (\"authorId\")"
691        );
692    }
693
694    #[test]
695    fn add_column_sql_basic() {
696        let field = FieldSpec {
697            name: "bio".into(),
698            field_type: "string".into(),
699            optional: true,
700            unique: false,
701        };
702        let sql = add_column_sql("User", &field);
703        assert_eq!(sql, "ALTER TABLE \"User\" ADD COLUMN \"bio\" TEXT");
704    }
705
706    #[test]
707    fn quote_ident_escapes_double_quotes() {
708        assert_eq!(quote_ident("normal"), "\"normal\"");
709        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
710        assert_eq!(
711            quote_ident("Robert'); DROP TABLE Students;--"),
712            "\"Robert'); DROP TABLE Students;--\""
713        );
714    }
715
716    #[test]
717    fn sqlite_adapter_creates_table() {
718        let adapter = SqliteAdapter::in_memory().unwrap();
719        let manifest = test_manifest();
720        let plan = adapter.plan_schema(&manifest).unwrap();
721        adapter.apply_schema(&plan).unwrap();
722
723        // Verify table exists by querying sqlite_master.
724        let table_count: i64 = adapter
725            .conn
726            .query_row(
727                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User'",
728                [],
729                |row| row.get(0),
730            )
731            .unwrap();
732        assert_eq!(table_count, 1);
733    }
734
735    #[test]
736    fn sqlite_adapter_creates_index() {
737        let adapter = SqliteAdapter::in_memory().unwrap();
738        let manifest = test_manifest();
739        let plan = adapter.plan_schema(&manifest).unwrap();
740        adapter.apply_schema(&plan).unwrap();
741
742        // Verify index exists.
743        let index_count: i64 = adapter
744            .conn
745            .query_row(
746                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='User_by_email'",
747                [],
748                |row| row.get(0),
749            )
750            .unwrap();
751        assert_eq!(index_count, 1);
752    }
753
754    #[test]
755    fn sqlite_adapter_add_field() {
756        let adapter = SqliteAdapter::in_memory().unwrap();
757
758        // Create table first.
759        let manifest = test_manifest();
760        let plan = adapter.plan_schema(&manifest).unwrap();
761        adapter.apply_schema(&plan).unwrap();
762
763        // Add a field.
764        let add_plan = SchemaPlan {
765            operations: vec![SchemaOperation::AddField {
766                entity: "User".into(),
767                field: FieldSpec {
768                    name: "bio".into(),
769                    field_type: "string".into(),
770                    optional: true,
771                    unique: false,
772                },
773            }],
774        };
775        adapter.apply_schema(&add_plan).unwrap();
776
777        // Verify column exists by checking pragma.
778        let has_bio: bool = adapter
779            .conn
780            .prepare("PRAGMA table_info(\"User\")")
781            .unwrap()
782            .query_map([], |row| {
783                let name: String = row.get(1)?;
784                Ok(name)
785            })
786            .unwrap()
787            .any(|r| r.unwrap() == "bio");
788        assert!(has_bio);
789    }
790
791    #[test]
792    fn sqlite_adapter_rejects_remove_entity() {
793        let adapter = SqliteAdapter::in_memory().unwrap();
794        let plan = SchemaPlan {
795            operations: vec![SchemaOperation::RemoveEntity {
796                name: "User".into(),
797            }],
798        };
799        let result = adapter.apply_schema(&plan);
800        assert!(result.is_err());
801        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
802    }
803
804    #[test]
805    fn sqlite_adapter_rejects_remove_field() {
806        let adapter = SqliteAdapter::in_memory().unwrap();
807        let plan = SchemaPlan {
808            operations: vec![SchemaOperation::RemoveField {
809                entity: "User".into(),
810                field_name: "email".into(),
811            }],
812        };
813        let result = adapter.apply_schema(&plan);
814        assert!(result.is_err());
815        assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
816    }
817
818    #[test]
819    fn sqlite_adapter_column_types() {
820        assert_eq!(sqlite_column_type("string"), "TEXT");
821        assert_eq!(sqlite_column_type("int"), "INTEGER");
822        assert_eq!(sqlite_column_type("float"), "REAL");
823        assert_eq!(sqlite_column_type("bool"), "INTEGER");
824        assert_eq!(sqlite_column_type("datetime"), "TEXT");
825        assert_eq!(sqlite_column_type("richtext"), "TEXT");
826        assert_eq!(sqlite_column_type("id(User)"), "TEXT");
827    }
828
829    // -- Introspection tests --
830
831    #[test]
832    fn introspect_empty_db() {
833        let adapter = SqliteAdapter::in_memory().unwrap();
834        let snapshot = adapter.read_schema().unwrap();
835        assert!(snapshot.tables.is_empty());
836    }
837
838    #[test]
839    fn introspect_after_apply() {
840        let adapter = SqliteAdapter::in_memory().unwrap();
841        let manifest = test_manifest();
842        let plan = adapter.plan_schema(&manifest).unwrap();
843        adapter.apply_schema(&plan).unwrap();
844
845        let snapshot = adapter.read_schema().unwrap();
846
847        // Should have one table.
848        assert_eq!(snapshot.tables.len(), 1);
849        let user = &snapshot.tables[0];
850        assert_eq!(user.name, "User");
851
852        // id + 3 manifest fields = 4 columns.
853        assert_eq!(user.columns.len(), 4);
854        assert_eq!(user.columns[0].name, "id");
855        assert!(user.columns[0].primary_key);
856        assert_eq!(user.columns[1].name, "email");
857        assert_eq!(user.columns[1].column_type, "TEXT");
858        assert!(user.columns[1].notnull);
859        assert_eq!(user.columns[2].name, "displayName");
860        assert_eq!(user.columns[3].name, "age");
861        assert!(!user.columns[3].notnull); // optional
862
863        // Should have the by_email index.
864        assert_eq!(user.indexes.len(), 1);
865        assert_eq!(user.indexes[0].name, "User_by_email");
866        assert_eq!(user.indexes[0].columns, vec!["email"]);
867        assert!(user.indexes[0].unique);
868    }
869
870    #[test]
871    fn introspect_multiple_tables() {
872        let adapter = SqliteAdapter::in_memory().unwrap();
873
874        let manifest = AppManifest {
875            manifest_version: MANIFEST_VERSION,
876            name: "test".into(),
877            version: "0.1.0".into(),
878            entities: vec![
879                ManifestEntity {
880                    name: "Post".into(),
881                    fields: vec![ManifestField {
882                        name: "title".into(),
883                        field_type: "string".into(),
884                        optional: false,
885                        unique: false,
886                    }],
887                    indexes: vec![],
888                    relations: vec![],
889                },
890                ManifestEntity {
891                    name: "User".into(),
892                    fields: vec![ManifestField {
893                        name: "email".into(),
894                        field_type: "string".into(),
895                        optional: false,
896                        unique: true,
897                    }],
898                    indexes: vec![],
899                    relations: vec![],
900                },
901            ],
902            routes: vec![],
903            queries: vec![],
904            actions: vec![],
905            policies: vec![],
906        };
907
908        let plan = adapter.plan_schema(&manifest).unwrap();
909        adapter.apply_schema(&plan).unwrap();
910
911        let snapshot = adapter.read_schema().unwrap();
912
913        // Sorted alphabetically.
914        assert_eq!(snapshot.tables.len(), 2);
915        assert_eq!(snapshot.tables[0].name, "Post");
916        assert_eq!(snapshot.tables[1].name, "User");
917    }
918
919    #[test]
920    fn introspect_after_add_field() {
921        let adapter = SqliteAdapter::in_memory().unwrap();
922        let manifest = test_manifest();
923        let plan = adapter.plan_schema(&manifest).unwrap();
924        adapter.apply_schema(&plan).unwrap();
925
926        // Add a column.
927        let add_plan = SchemaPlan {
928            operations: vec![SchemaOperation::AddField {
929                entity: "User".into(),
930                field: FieldSpec {
931                    name: "bio".into(),
932                    field_type: "string".into(),
933                    optional: true,
934                    unique: false,
935                },
936            }],
937        };
938        adapter.apply_schema(&add_plan).unwrap();
939
940        let snapshot = adapter.read_schema().unwrap();
941        let user = &snapshot.tables[0];
942
943        // id + 3 original + 1 added = 5.
944        assert_eq!(user.columns.len(), 5);
945        assert!(user.columns.iter().any(|c| c.name == "bio"));
946    }
947
948    #[test]
949    fn introspect_snapshot_is_deterministic() {
950        let adapter = SqliteAdapter::in_memory().unwrap();
951        let manifest = test_manifest();
952        let plan = adapter.plan_schema(&manifest).unwrap();
953        adapter.apply_schema(&plan).unwrap();
954
955        let s1 = adapter.read_schema().unwrap();
956        let s2 = adapter.read_schema().unwrap();
957        assert_eq!(s1, s2);
958    }
959
960    // -- Live planning tests --
961
962    #[test]
963    fn plan_from_empty_db_creates_everything() {
964        let adapter = SqliteAdapter::in_memory().unwrap();
965        let manifest = test_manifest();
966
967        let plan = adapter.plan_from_live(&manifest).unwrap();
968
969        // Should create the table and its index.
970        assert!(plan.operations.iter().any(|op| matches!(
971            op,
972            SchemaOperation::CreateEntity { name, .. } if name == "User"
973        )));
974        assert!(plan.operations.iter().any(|op| matches!(
975            op,
976            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
977        )));
978    }
979
980    #[test]
981    fn plan_from_fully_applied_db_is_noop() {
982        let adapter = SqliteAdapter::in_memory().unwrap();
983        let manifest = test_manifest();
984
985        // Apply everything first.
986        let initial = adapter.plan_from_live(&manifest).unwrap();
987        adapter.apply_schema(&initial).unwrap();
988
989        // Plan again — should be noop.
990        let plan = adapter.plan_from_live(&manifest).unwrap();
991        assert!(plan.is_empty(), "expected noop, got: {:?}", plan.operations);
992    }
993
994    #[test]
995    fn plan_detects_missing_column() {
996        let adapter = SqliteAdapter::in_memory().unwrap();
997
998        // Create table with only email.
999        adapter
1000            .conn
1001            .execute(
1002                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE)",
1003                [],
1004            )
1005            .unwrap();
1006
1007        let manifest = test_manifest();
1008        let plan = adapter.plan_from_live(&manifest).unwrap();
1009
1010        // Should plan AddField for displayName and age.
1011        let add_fields: Vec<_> = plan
1012            .operations
1013            .iter()
1014            .filter(|op| matches!(op, SchemaOperation::AddField { .. }))
1015            .collect();
1016        assert_eq!(add_fields.len(), 2);
1017    }
1018
1019    #[test]
1020    fn plan_detects_missing_index() {
1021        let adapter = SqliteAdapter::in_memory().unwrap();
1022
1023        // Create table with all columns but no index.
1024        adapter
1025            .conn
1026            .execute(
1027                "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, \"displayName\" TEXT NOT NULL, age INTEGER)",
1028                [],
1029            )
1030            .unwrap();
1031
1032        let manifest = test_manifest();
1033        let plan = adapter.plan_from_live(&manifest).unwrap();
1034
1035        // Should plan AddIndex only.
1036        assert!(plan.operations.iter().any(|op| matches!(
1037            op,
1038            SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1039        )));
1040        assert!(!plan
1041            .operations
1042            .iter()
1043            .any(|op| matches!(op, SchemaOperation::CreateEntity { .. })));
1044    }
1045
1046    // -- Migration history tests --
1047
1048    fn push_meta(baseline: &str) -> PushMetadata<'_> {
1049        PushMetadata {
1050            manifest_version: 1,
1051            app_version: "0.1.0",
1052            baseline,
1053        }
1054    }
1055
1056    fn history_count(adapter: &SqliteAdapter) -> i64 {
1057        adapter
1058            .conn
1059            .query_row(
1060                &format!("SELECT COUNT(*) FROM {}", quote_ident(HISTORY_TABLE)),
1061                [],
1062                |row| row.get(0),
1063            )
1064            .unwrap()
1065    }
1066
1067    #[test]
1068    fn history_table_created_on_apply() {
1069        let adapter = SqliteAdapter::in_memory().unwrap();
1070        let manifest = test_manifest();
1071        let plan = adapter.plan_from_live(&manifest).unwrap();
1072        adapter
1073            .apply_with_history(&plan, &push_meta("live_sqlite"))
1074            .unwrap();
1075
1076        let table_exists: i64 = adapter
1077            .conn
1078            .query_row(
1079                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1080                [HISTORY_TABLE],
1081                |row| row.get(0),
1082            )
1083            .unwrap();
1084        assert_eq!(table_exists, 1);
1085    }
1086
1087    #[test]
1088    fn history_row_inserted_on_apply() {
1089        let adapter = SqliteAdapter::in_memory().unwrap();
1090        let manifest = test_manifest();
1091        let plan = adapter.plan_from_live(&manifest).unwrap();
1092        adapter
1093            .apply_with_history(&plan, &push_meta("live_sqlite"))
1094            .unwrap();
1095
1096        assert_eq!(history_count(&adapter), 1);
1097
1098        // Verify stored data.
1099        let (mv, av, baseline, op_count): (i64, String, String, i64) = adapter
1100            .conn
1101            .query_row(
1102                &format!(
1103                    "SELECT manifest_version, app_version, baseline, operation_count FROM {} LIMIT 1",
1104                    quote_ident(HISTORY_TABLE)
1105                ),
1106                [],
1107                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1108            )
1109            .unwrap();
1110        assert_eq!(mv, 1);
1111        assert_eq!(av, "0.1.0");
1112        assert_eq!(baseline, "live_sqlite");
1113        assert_eq!(op_count, 2); // CreateEntity + AddIndex (Noop not counted)
1114    }
1115
1116    #[test]
1117    fn noop_push_also_recorded() {
1118        let adapter = SqliteAdapter::in_memory().unwrap();
1119        let manifest = test_manifest();
1120
1121        // First push creates tables.
1122        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1123        adapter
1124            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1125            .unwrap();
1126
1127        // Second push is noop.
1128        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1129        assert!(plan2.is_empty());
1130        adapter
1131            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1132            .unwrap();
1133
1134        // Both pushes recorded.
1135        assert_eq!(history_count(&adapter), 2);
1136
1137        // Second row has 0 operations.
1138        let op_count: i64 = adapter
1139            .conn
1140            .query_row(
1141                &format!(
1142                    "SELECT operation_count FROM {} ORDER BY id DESC LIMIT 1",
1143                    quote_ident(HISTORY_TABLE)
1144                ),
1145                [],
1146                |row| row.get(0),
1147            )
1148            .unwrap();
1149        assert_eq!(op_count, 0);
1150    }
1151
1152    #[test]
1153    fn history_plan_json_is_valid() {
1154        let adapter = SqliteAdapter::in_memory().unwrap();
1155        let manifest = test_manifest();
1156        let plan = adapter.plan_from_live(&manifest).unwrap();
1157        adapter
1158            .apply_with_history(&plan, &push_meta("live_sqlite"))
1159            .unwrap();
1160
1161        let plan_json: String = adapter
1162            .conn
1163            .query_row(
1164                &format!(
1165                    "SELECT plan_json FROM {} LIMIT 1",
1166                    quote_ident(HISTORY_TABLE)
1167                ),
1168                [],
1169                |row| row.get(0),
1170            )
1171            .unwrap();
1172
1173        // Should be valid JSON.
1174        let parsed: serde_json::Value = serde_json::from_str(&plan_json).unwrap();
1175        assert!(parsed.get("operations").unwrap().is_array());
1176    }
1177
1178    #[test]
1179    fn history_table_excluded_from_introspection() {
1180        let adapter = SqliteAdapter::in_memory().unwrap();
1181        let manifest = test_manifest();
1182        let plan = adapter.plan_from_live(&manifest).unwrap();
1183        adapter
1184            .apply_with_history(&plan, &push_meta("live_sqlite"))
1185            .unwrap();
1186
1187        let snapshot = adapter.read_schema().unwrap();
1188        assert!(!snapshot.tables.iter().any(|t| t.name.starts_with("_pylon")));
1189    }
1190
1191    // -- read_history tests --
1192
1193    #[test]
1194    fn read_history_empty_db() {
1195        let adapter = SqliteAdapter::in_memory().unwrap();
1196        let entries = adapter.read_history(None).unwrap();
1197        assert!(entries.is_empty());
1198    }
1199
1200    #[test]
1201    fn read_history_after_one_push() {
1202        let adapter = SqliteAdapter::in_memory().unwrap();
1203        let manifest = test_manifest();
1204        let plan = adapter.plan_from_live(&manifest).unwrap();
1205        adapter
1206            .apply_with_history(&plan, &push_meta("live_sqlite"))
1207            .unwrap();
1208
1209        let entries = adapter.read_history(None).unwrap();
1210        assert_eq!(entries.len(), 1);
1211        assert_eq!(entries[0].manifest_version, 1);
1212        assert_eq!(entries[0].app_version, "0.1.0");
1213        assert_eq!(entries[0].baseline, "live_sqlite");
1214        assert_eq!(entries[0].operation_count, 2); // CreateEntity + AddIndex
1215    }
1216
1217    #[test]
1218    fn read_history_after_noop_push() {
1219        let adapter = SqliteAdapter::in_memory().unwrap();
1220        let manifest = test_manifest();
1221
1222        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1223        adapter
1224            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1225            .unwrap();
1226
1227        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1228        adapter
1229            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1230            .unwrap();
1231
1232        let entries = adapter.read_history(None).unwrap();
1233        assert_eq!(entries.len(), 2);
1234        // Newest first.
1235        assert_eq!(entries[0].operation_count, 0);
1236        assert_eq!(entries[1].operation_count, 2);
1237    }
1238
1239    #[test]
1240    fn read_history_newest_first() {
1241        let adapter = SqliteAdapter::in_memory().unwrap();
1242        let manifest = test_manifest();
1243
1244        let plan = adapter.plan_from_live(&manifest).unwrap();
1245        adapter
1246            .apply_with_history(
1247                &plan,
1248                &PushMetadata {
1249                    manifest_version: 1,
1250                    app_version: "0.1.0",
1251                    baseline: "first",
1252                },
1253            )
1254            .unwrap();
1255
1256        // Small delay not needed — timestamps have nanosecond precision.
1257        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1258        adapter
1259            .apply_with_history(
1260                &plan2,
1261                &PushMetadata {
1262                    manifest_version: 1,
1263                    app_version: "0.2.0",
1264                    baseline: "second",
1265                },
1266            )
1267            .unwrap();
1268
1269        let entries = adapter.read_history(None).unwrap();
1270        assert_eq!(entries.len(), 2);
1271        assert_eq!(entries[0].baseline, "second");
1272        assert_eq!(entries[1].baseline, "first");
1273    }
1274
1275    #[test]
1276    fn read_history_with_limit() {
1277        let adapter = SqliteAdapter::in_memory().unwrap();
1278        let manifest = test_manifest();
1279
1280        // Push twice.
1281        let plan1 = adapter.plan_from_live(&manifest).unwrap();
1282        adapter
1283            .apply_with_history(&plan1, &push_meta("live_sqlite"))
1284            .unwrap();
1285        let plan2 = adapter.plan_from_live(&manifest).unwrap();
1286        adapter
1287            .apply_with_history(&plan2, &push_meta("live_sqlite"))
1288            .unwrap();
1289
1290        let all = adapter.read_history(None).unwrap();
1291        assert_eq!(all.len(), 2);
1292
1293        let limited = adapter.read_history(Some(1)).unwrap();
1294        assert_eq!(limited.len(), 1);
1295    }
1296
1297    #[test]
1298    fn read_history_entry_by_id() {
1299        let adapter = SqliteAdapter::in_memory().unwrap();
1300        let manifest = test_manifest();
1301
1302        let plan = adapter.plan_from_live(&manifest).unwrap();
1303        adapter
1304            .apply_with_history(&plan, &push_meta("live_sqlite"))
1305            .unwrap();
1306
1307        let entries = adapter.read_history(None).unwrap();
1308        let id = &entries[0].id;
1309
1310        let entry = adapter.read_history_entry(id).unwrap().unwrap();
1311        assert_eq!(&entry.id, id);
1312        assert_eq!(entry.operation_count, 2);
1313    }
1314
1315    #[test]
1316    fn read_history_entry_missing_id() {
1317        let adapter = SqliteAdapter::in_memory().unwrap();
1318        let result = adapter.read_history_entry("nonexistent").unwrap();
1319        assert!(result.is_none());
1320    }
1321
1322    #[test]
1323    fn history_entry_has_parsed_plan() {
1324        let adapter = SqliteAdapter::in_memory().unwrap();
1325        let manifest = test_manifest();
1326
1327        let plan = adapter.plan_from_live(&manifest).unwrap();
1328        adapter
1329            .apply_with_history(&plan, &push_meta("live_sqlite"))
1330            .unwrap();
1331
1332        let entries = adapter.read_history(None).unwrap();
1333        let entry = &entries[0];
1334
1335        // plan should be parsed from plan_json.
1336        assert!(entry.plan.is_some());
1337        let parsed_plan = entry.plan.as_ref().unwrap();
1338        assert!(!parsed_plan.operations.is_empty());
1339
1340        // plan_json should still be present as raw string.
1341        assert!(!entry.plan_json.is_empty());
1342    }
1343}