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
12fn 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
37fn quote_ident(name: &str) -> String {
44 format!("\"{}\"", name.replace('"', "\"\""))
45}
46
47pub 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
75pub fn add_column_sql(entity_name: &str, field: &FieldSpec) -> String {
77 let col_type = sqlite_column_type(&field.field_type);
78 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
91pub 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
110pub struct SqliteAdapter {
115 conn: Connection,
116}
117
118impl SqliteAdapter {
119 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, false)?;
126 Ok(Self { conn })
127 }
128
129 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, true)?;
136 Ok(Self { conn })
137 }
138}
139
140fn 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 ("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 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 self.plan_from_live(target)
216 }
217
218 fn apply_schema(&self, plan: &SchemaPlan) -> Result<(), StorageError> {
219 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 tracing::warn!("[sqlite] ROLLBACK after apply error failed: {rb}");
241 }
242 Err(e)
243 }
244 }
245 }
246}
247
248const HISTORY_TABLE: &str = "_pylon_schema_history";
253
254#[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
268pub struct PushMetadata<'a> {
270 pub manifest_version: u32,
271 pub app_version: &'a str,
272 pub baseline: &'a str,
273}
274
275impl SqliteAdapter {
276 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 pub fn apply_with_history(
303 &self,
304 plan: &SchemaPlan,
305 meta: &PushMetadata<'_>,
306 ) -> Result<(), StorageError> {
307 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 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 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 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 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 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 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 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 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 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 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
642impl SqliteAdapter {
647 pub fn read_schema(&self) -> Result<SchemaSnapshot, StorageError> {
650 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 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 let ordered: BTreeMap<String, bool> = index_meta.into_iter().collect();
717
718 let mut indexes = Vec::new();
719 for (name, unique) in &ordered {
720 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#[cfg(test)]
759mod tests {
760 use super::*;
761 use pylon_kernel::*;
762
763 fn test_manifest() -> AppManifest {
764 AppManifest {
765 manifest_version: MANIFEST_VERSION,
766 name: "test".into(),
767 version: "0.1.0".into(),
768 entities: vec![ManifestEntity {
769 name: "User".into(),
770 fields: vec![
771 ManifestField {
772 name: "email".into(),
773 field_type: "string".into(),
774 optional: false,
775 unique: true,
776 crdt: None,
777 },
778 ManifestField {
779 name: "displayName".into(),
780 field_type: "string".into(),
781 optional: false,
782 unique: false,
783 crdt: None,
784 },
785 ManifestField {
786 name: "age".into(),
787 field_type: "int".into(),
788 optional: true,
789 unique: false,
790 crdt: None,
791 },
792 ],
793 indexes: vec![ManifestIndex {
794 name: "by_email".into(),
795 fields: vec!["email".into()],
796 unique: true,
797 }],
798 relations: vec![],
799 search: None,
800 crdt: true,
801 }],
802 routes: vec![],
803 queries: vec![],
804 actions: vec![],
805 policies: vec![],
806 auth: Default::default(),
807 }
808 }
809
810 #[test]
811 fn create_table_sql_basic() {
812 let fields = vec![
813 FieldSpec {
814 name: "email".into(),
815 field_type: "string".into(),
816 optional: false,
817 unique: true,
818 },
819 FieldSpec {
820 name: "age".into(),
821 field_type: "int".into(),
822 optional: true,
823 unique: false,
824 },
825 ];
826 let sql = create_table_sql("User", &fields);
827 assert_eq!(
828 sql,
829 "CREATE TABLE IF NOT EXISTS \"User\" (id TEXT PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL UNIQUE, \"age\" INTEGER)"
830 );
831 }
832
833 #[test]
834 fn create_index_sql_basic() {
835 let sql = create_index_sql("User", "by_email", &["email".into()], true);
836 assert_eq!(
837 sql,
838 "CREATE UNIQUE INDEX IF NOT EXISTS \"User_by_email\" ON \"User\" (\"email\")"
839 );
840 }
841
842 #[test]
843 fn create_index_sql_non_unique() {
844 let sql = create_index_sql("Todo", "by_user", &["userId".into()], false);
845 assert_eq!(
846 sql,
847 "CREATE INDEX IF NOT EXISTS \"Todo_by_user\" ON \"Todo\" (\"userId\")"
848 );
849 }
850
851 #[test]
852 fn add_column_sql_basic() {
853 let field = FieldSpec {
854 name: "bio".into(),
855 field_type: "string".into(),
856 optional: true,
857 unique: false,
858 };
859 let sql = add_column_sql("User", &field);
860 assert_eq!(sql, "ALTER TABLE \"User\" ADD COLUMN \"bio\" TEXT");
861 }
862
863 #[test]
864 fn quote_ident_escapes_double_quotes() {
865 assert_eq!(quote_ident("normal"), "\"normal\"");
866 assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
867 assert_eq!(
868 quote_ident("Robert'); DROP TABLE Students;--"),
869 "\"Robert'); DROP TABLE Students;--\""
870 );
871 }
872
873 #[test]
874 fn sqlite_adapter_creates_table() {
875 let adapter = SqliteAdapter::in_memory().unwrap();
876 let manifest = test_manifest();
877 let plan = adapter.plan_schema(&manifest).unwrap();
878 adapter.apply_schema(&plan).unwrap();
879
880 let table_count: i64 = adapter
882 .conn
883 .query_row(
884 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User'",
885 [],
886 |row| row.get(0),
887 )
888 .unwrap();
889 assert_eq!(table_count, 1);
890 }
891
892 #[test]
893 fn sqlite_adapter_creates_index() {
894 let adapter = SqliteAdapter::in_memory().unwrap();
895 let manifest = test_manifest();
896 let plan = adapter.plan_schema(&manifest).unwrap();
897 adapter.apply_schema(&plan).unwrap();
898
899 let index_count: i64 = adapter
901 .conn
902 .query_row(
903 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='User_by_email'",
904 [],
905 |row| row.get(0),
906 )
907 .unwrap();
908 assert_eq!(index_count, 1);
909 }
910
911 #[test]
912 fn sqlite_adapter_add_field() {
913 let adapter = SqliteAdapter::in_memory().unwrap();
914
915 let manifest = test_manifest();
917 let plan = adapter.plan_schema(&manifest).unwrap();
918 adapter.apply_schema(&plan).unwrap();
919
920 let add_plan = SchemaPlan {
922 operations: vec![SchemaOperation::AddField {
923 entity: "User".into(),
924 field: FieldSpec {
925 name: "bio".into(),
926 field_type: "string".into(),
927 optional: true,
928 unique: false,
929 },
930 }],
931 };
932 adapter.apply_schema(&add_plan).unwrap();
933
934 let has_bio: bool = adapter
936 .conn
937 .prepare("PRAGMA table_info(\"User\")")
938 .unwrap()
939 .query_map([], |row| {
940 let name: String = row.get(1)?;
941 Ok(name)
942 })
943 .unwrap()
944 .any(|r| r.unwrap() == "bio");
945 assert!(has_bio);
946 }
947
948 #[test]
949 fn sqlite_adapter_rejects_remove_entity() {
950 let adapter = SqliteAdapter::in_memory().unwrap();
951 let plan = SchemaPlan {
952 operations: vec![SchemaOperation::RemoveEntity {
953 name: "User".into(),
954 }],
955 };
956 let result = adapter.apply_schema(&plan);
957 assert!(result.is_err());
958 assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
959 }
960
961 #[test]
962 fn sqlite_adapter_rejects_remove_field() {
963 let adapter = SqliteAdapter::in_memory().unwrap();
964 let plan = SchemaPlan {
965 operations: vec![SchemaOperation::RemoveField {
966 entity: "User".into(),
967 field_name: "email".into(),
968 }],
969 };
970 let result = adapter.apply_schema(&plan);
971 assert!(result.is_err());
972 assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
973 }
974
975 #[test]
976 fn sqlite_adapter_column_types() {
977 assert_eq!(sqlite_column_type("string"), "TEXT");
978 assert_eq!(sqlite_column_type("int"), "INTEGER");
979 assert_eq!(sqlite_column_type("float"), "REAL");
980 assert_eq!(sqlite_column_type("bool"), "INTEGER");
981 assert_eq!(sqlite_column_type("datetime"), "TEXT");
982 assert_eq!(sqlite_column_type("richtext"), "TEXT");
983 assert_eq!(sqlite_column_type("id(User)"), "TEXT");
984 }
985
986 #[test]
989 fn introspect_empty_db() {
990 let adapter = SqliteAdapter::in_memory().unwrap();
991 let snapshot = adapter.read_schema().unwrap();
992 assert!(snapshot.tables.is_empty());
993 }
994
995 #[test]
996 fn introspect_after_apply() {
997 let adapter = SqliteAdapter::in_memory().unwrap();
998 let manifest = test_manifest();
999 let plan = adapter.plan_schema(&manifest).unwrap();
1000 adapter.apply_schema(&plan).unwrap();
1001
1002 let snapshot = adapter.read_schema().unwrap();
1003
1004 assert_eq!(snapshot.tables.len(), 1);
1006 let user = &snapshot.tables[0];
1007 assert_eq!(user.name, "User");
1008
1009 assert_eq!(user.columns.len(), 4);
1011 assert_eq!(user.columns[0].name, "id");
1012 assert!(user.columns[0].primary_key);
1013 assert_eq!(user.columns[1].name, "email");
1014 assert_eq!(user.columns[1].column_type, "TEXT");
1015 assert!(user.columns[1].notnull);
1016 assert_eq!(user.columns[2].name, "displayName");
1017 assert_eq!(user.columns[3].name, "age");
1018 assert!(!user.columns[3].notnull); assert_eq!(user.indexes.len(), 1);
1022 assert_eq!(user.indexes[0].name, "User_by_email");
1023 assert_eq!(user.indexes[0].columns, vec!["email"]);
1024 assert!(user.indexes[0].unique);
1025 }
1026
1027 #[test]
1028 fn introspect_multiple_tables() {
1029 let adapter = SqliteAdapter::in_memory().unwrap();
1030
1031 let manifest = AppManifest {
1032 manifest_version: MANIFEST_VERSION,
1033 name: "test".into(),
1034 version: "0.1.0".into(),
1035 entities: vec![
1036 ManifestEntity {
1037 name: "Post".into(),
1038 fields: vec![ManifestField {
1039 name: "title".into(),
1040 field_type: "string".into(),
1041 optional: false,
1042 unique: false,
1043 crdt: None,
1044 }],
1045 indexes: vec![],
1046 relations: vec![],
1047 search: None,
1048 crdt: true,
1049 },
1050 ManifestEntity {
1051 name: "User".into(),
1052 fields: vec![ManifestField {
1053 name: "email".into(),
1054 field_type: "string".into(),
1055 optional: false,
1056 unique: true,
1057 crdt: None,
1058 }],
1059 indexes: vec![],
1060 relations: vec![],
1061 search: None,
1062 crdt: true,
1063 },
1064 ],
1065 routes: vec![],
1066 queries: vec![],
1067 actions: vec![],
1068 policies: vec![],
1069 auth: Default::default(),
1070 };
1071
1072 let plan = adapter.plan_schema(&manifest).unwrap();
1073 adapter.apply_schema(&plan).unwrap();
1074
1075 let snapshot = adapter.read_schema().unwrap();
1076
1077 assert_eq!(snapshot.tables.len(), 2);
1079 assert_eq!(snapshot.tables[0].name, "Post");
1080 assert_eq!(snapshot.tables[1].name, "User");
1081 }
1082
1083 #[test]
1084 fn introspect_after_add_field() {
1085 let adapter = SqliteAdapter::in_memory().unwrap();
1086 let manifest = test_manifest();
1087 let plan = adapter.plan_schema(&manifest).unwrap();
1088 adapter.apply_schema(&plan).unwrap();
1089
1090 let add_plan = SchemaPlan {
1092 operations: vec![SchemaOperation::AddField {
1093 entity: "User".into(),
1094 field: FieldSpec {
1095 name: "bio".into(),
1096 field_type: "string".into(),
1097 optional: true,
1098 unique: false,
1099 },
1100 }],
1101 };
1102 adapter.apply_schema(&add_plan).unwrap();
1103
1104 let snapshot = adapter.read_schema().unwrap();
1105 let user = &snapshot.tables[0];
1106
1107 assert_eq!(user.columns.len(), 5);
1109 assert!(user.columns.iter().any(|c| c.name == "bio"));
1110 }
1111
1112 #[test]
1113 fn introspect_snapshot_is_deterministic() {
1114 let adapter = SqliteAdapter::in_memory().unwrap();
1115 let manifest = test_manifest();
1116 let plan = adapter.plan_schema(&manifest).unwrap();
1117 adapter.apply_schema(&plan).unwrap();
1118
1119 let s1 = adapter.read_schema().unwrap();
1120 let s2 = adapter.read_schema().unwrap();
1121 assert_eq!(s1, s2);
1122 }
1123
1124 #[test]
1127 fn plan_from_empty_db_creates_everything() {
1128 let adapter = SqliteAdapter::in_memory().unwrap();
1129 let manifest = test_manifest();
1130
1131 let plan = adapter.plan_from_live(&manifest).unwrap();
1132
1133 assert!(plan.operations.iter().any(|op| matches!(
1135 op,
1136 SchemaOperation::CreateEntity { name, .. } if name == "User"
1137 )));
1138 assert!(plan.operations.iter().any(|op| matches!(
1139 op,
1140 SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1141 )));
1142 }
1143
1144 #[test]
1145 fn plan_from_fully_applied_db_is_noop() {
1146 let adapter = SqliteAdapter::in_memory().unwrap();
1147 let manifest = test_manifest();
1148
1149 let initial = adapter.plan_from_live(&manifest).unwrap();
1151 adapter.apply_schema(&initial).unwrap();
1152
1153 let plan = adapter.plan_from_live(&manifest).unwrap();
1155 assert!(plan.is_empty(), "expected noop, got: {:?}", plan.operations);
1156 }
1157
1158 #[test]
1159 fn plan_detects_missing_column() {
1160 let adapter = SqliteAdapter::in_memory().unwrap();
1161
1162 adapter
1164 .conn
1165 .execute(
1166 "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE)",
1167 [],
1168 )
1169 .unwrap();
1170
1171 let manifest = test_manifest();
1172 let plan = adapter.plan_from_live(&manifest).unwrap();
1173
1174 let add_fields: Vec<_> = plan
1176 .operations
1177 .iter()
1178 .filter(|op| matches!(op, SchemaOperation::AddField { .. }))
1179 .collect();
1180 assert_eq!(add_fields.len(), 2);
1181 }
1182
1183 #[test]
1184 fn plan_detects_missing_index() {
1185 let adapter = SqliteAdapter::in_memory().unwrap();
1186
1187 adapter
1189 .conn
1190 .execute(
1191 "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, \"displayName\" TEXT NOT NULL, age INTEGER)",
1192 [],
1193 )
1194 .unwrap();
1195
1196 let manifest = test_manifest();
1197 let plan = adapter.plan_from_live(&manifest).unwrap();
1198
1199 assert!(plan.operations.iter().any(|op| matches!(
1201 op,
1202 SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1203 )));
1204 assert!(!plan
1205 .operations
1206 .iter()
1207 .any(|op| matches!(op, SchemaOperation::CreateEntity { .. })));
1208 }
1209
1210 fn push_meta(baseline: &str) -> PushMetadata<'_> {
1213 PushMetadata {
1214 manifest_version: 1,
1215 app_version: "0.1.0",
1216 baseline,
1217 }
1218 }
1219
1220 fn history_count(adapter: &SqliteAdapter) -> i64 {
1221 adapter
1222 .conn
1223 .query_row(
1224 &format!("SELECT COUNT(*) FROM {}", quote_ident(HISTORY_TABLE)),
1225 [],
1226 |row| row.get(0),
1227 )
1228 .unwrap()
1229 }
1230
1231 #[test]
1232 fn history_table_created_on_apply() {
1233 let adapter = SqliteAdapter::in_memory().unwrap();
1234 let manifest = test_manifest();
1235 let plan = adapter.plan_from_live(&manifest).unwrap();
1236 adapter
1237 .apply_with_history(&plan, &push_meta("live_sqlite"))
1238 .unwrap();
1239
1240 let table_exists: i64 = adapter
1241 .conn
1242 .query_row(
1243 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1244 [HISTORY_TABLE],
1245 |row| row.get(0),
1246 )
1247 .unwrap();
1248 assert_eq!(table_exists, 1);
1249 }
1250
1251 #[test]
1252 fn history_row_inserted_on_apply() {
1253 let adapter = SqliteAdapter::in_memory().unwrap();
1254 let manifest = test_manifest();
1255 let plan = adapter.plan_from_live(&manifest).unwrap();
1256 adapter
1257 .apply_with_history(&plan, &push_meta("live_sqlite"))
1258 .unwrap();
1259
1260 assert_eq!(history_count(&adapter), 1);
1261
1262 let (mv, av, baseline, op_count): (i64, String, String, i64) = adapter
1264 .conn
1265 .query_row(
1266 &format!(
1267 "SELECT manifest_version, app_version, baseline, operation_count FROM {} LIMIT 1",
1268 quote_ident(HISTORY_TABLE)
1269 ),
1270 [],
1271 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1272 )
1273 .unwrap();
1274 assert_eq!(mv, 1);
1275 assert_eq!(av, "0.1.0");
1276 assert_eq!(baseline, "live_sqlite");
1277 assert_eq!(op_count, 2); }
1279
1280 #[test]
1281 fn noop_push_also_recorded() {
1282 let adapter = SqliteAdapter::in_memory().unwrap();
1283 let manifest = test_manifest();
1284
1285 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1287 adapter
1288 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1289 .unwrap();
1290
1291 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1293 assert!(plan2.is_empty());
1294 adapter
1295 .apply_with_history(&plan2, &push_meta("live_sqlite"))
1296 .unwrap();
1297
1298 assert_eq!(history_count(&adapter), 2);
1300
1301 let op_count: i64 = adapter
1303 .conn
1304 .query_row(
1305 &format!(
1306 "SELECT operation_count FROM {} ORDER BY id DESC LIMIT 1",
1307 quote_ident(HISTORY_TABLE)
1308 ),
1309 [],
1310 |row| row.get(0),
1311 )
1312 .unwrap();
1313 assert_eq!(op_count, 0);
1314 }
1315
1316 #[test]
1317 fn history_plan_json_is_valid() {
1318 let adapter = SqliteAdapter::in_memory().unwrap();
1319 let manifest = test_manifest();
1320 let plan = adapter.plan_from_live(&manifest).unwrap();
1321 adapter
1322 .apply_with_history(&plan, &push_meta("live_sqlite"))
1323 .unwrap();
1324
1325 let plan_json: String = adapter
1326 .conn
1327 .query_row(
1328 &format!(
1329 "SELECT plan_json FROM {} LIMIT 1",
1330 quote_ident(HISTORY_TABLE)
1331 ),
1332 [],
1333 |row| row.get(0),
1334 )
1335 .unwrap();
1336
1337 let parsed: serde_json::Value = serde_json::from_str(&plan_json).unwrap();
1339 assert!(parsed.get("operations").unwrap().is_array());
1340 }
1341
1342 #[test]
1343 fn history_table_excluded_from_introspection() {
1344 let adapter = SqliteAdapter::in_memory().unwrap();
1345 let manifest = test_manifest();
1346 let plan = adapter.plan_from_live(&manifest).unwrap();
1347 adapter
1348 .apply_with_history(&plan, &push_meta("live_sqlite"))
1349 .unwrap();
1350
1351 let snapshot = adapter.read_schema().unwrap();
1352 assert!(!snapshot.tables.iter().any(|t| t.name.starts_with("_pylon")));
1353 }
1354
1355 #[test]
1358 fn read_history_empty_db() {
1359 let adapter = SqliteAdapter::in_memory().unwrap();
1360 let entries = adapter.read_history(None).unwrap();
1361 assert!(entries.is_empty());
1362 }
1363
1364 #[test]
1365 fn read_history_after_one_push() {
1366 let adapter = SqliteAdapter::in_memory().unwrap();
1367 let manifest = test_manifest();
1368 let plan = adapter.plan_from_live(&manifest).unwrap();
1369 adapter
1370 .apply_with_history(&plan, &push_meta("live_sqlite"))
1371 .unwrap();
1372
1373 let entries = adapter.read_history(None).unwrap();
1374 assert_eq!(entries.len(), 1);
1375 assert_eq!(entries[0].manifest_version, 1);
1376 assert_eq!(entries[0].app_version, "0.1.0");
1377 assert_eq!(entries[0].baseline, "live_sqlite");
1378 assert_eq!(entries[0].operation_count, 2); }
1380
1381 #[test]
1382 fn read_history_after_noop_push() {
1383 let adapter = SqliteAdapter::in_memory().unwrap();
1384 let manifest = test_manifest();
1385
1386 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1387 adapter
1388 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1389 .unwrap();
1390
1391 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1392 adapter
1393 .apply_with_history(&plan2, &push_meta("live_sqlite"))
1394 .unwrap();
1395
1396 let entries = adapter.read_history(None).unwrap();
1397 assert_eq!(entries.len(), 2);
1398 assert_eq!(entries[0].operation_count, 0);
1400 assert_eq!(entries[1].operation_count, 2);
1401 }
1402
1403 #[test]
1404 fn read_history_newest_first() {
1405 let adapter = SqliteAdapter::in_memory().unwrap();
1406 let manifest = test_manifest();
1407
1408 let plan = adapter.plan_from_live(&manifest).unwrap();
1409 adapter
1410 .apply_with_history(
1411 &plan,
1412 &PushMetadata {
1413 manifest_version: 1,
1414 app_version: "0.1.0",
1415 baseline: "first",
1416 },
1417 )
1418 .unwrap();
1419
1420 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1422 adapter
1423 .apply_with_history(
1424 &plan2,
1425 &PushMetadata {
1426 manifest_version: 1,
1427 app_version: "0.2.0",
1428 baseline: "second",
1429 },
1430 )
1431 .unwrap();
1432
1433 let entries = adapter.read_history(None).unwrap();
1434 assert_eq!(entries.len(), 2);
1435 assert_eq!(entries[0].baseline, "second");
1436 assert_eq!(entries[1].baseline, "first");
1437 }
1438
1439 #[test]
1440 fn read_history_with_limit() {
1441 let adapter = SqliteAdapter::in_memory().unwrap();
1442 let manifest = test_manifest();
1443
1444 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1446 adapter
1447 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1448 .unwrap();
1449 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1450 adapter
1451 .apply_with_history(&plan2, &push_meta("live_sqlite"))
1452 .unwrap();
1453
1454 let all = adapter.read_history(None).unwrap();
1455 assert_eq!(all.len(), 2);
1456
1457 let limited = adapter.read_history(Some(1)).unwrap();
1458 assert_eq!(limited.len(), 1);
1459 }
1460
1461 #[test]
1462 fn read_history_entry_by_id() {
1463 let adapter = SqliteAdapter::in_memory().unwrap();
1464 let manifest = test_manifest();
1465
1466 let plan = adapter.plan_from_live(&manifest).unwrap();
1467 adapter
1468 .apply_with_history(&plan, &push_meta("live_sqlite"))
1469 .unwrap();
1470
1471 let entries = adapter.read_history(None).unwrap();
1472 let id = &entries[0].id;
1473
1474 let entry = adapter.read_history_entry(id).unwrap().unwrap();
1475 assert_eq!(&entry.id, id);
1476 assert_eq!(entry.operation_count, 2);
1477 }
1478
1479 #[test]
1480 fn read_history_entry_missing_id() {
1481 let adapter = SqliteAdapter::in_memory().unwrap();
1482 let result = adapter.read_history_entry("nonexistent").unwrap();
1483 assert!(result.is_none());
1484 }
1485
1486 #[test]
1487 fn history_entry_has_parsed_plan() {
1488 let adapter = SqliteAdapter::in_memory().unwrap();
1489 let manifest = test_manifest();
1490
1491 let plan = adapter.plan_from_live(&manifest).unwrap();
1492 adapter
1493 .apply_with_history(&plan, &push_meta("live_sqlite"))
1494 .unwrap();
1495
1496 let entries = adapter.read_history(None).unwrap();
1497 let entry = &entries[0];
1498
1499 assert!(entry.plan.is_some());
1501 let parsed_plan = entry.plan.as_ref().unwrap();
1502 assert!(!parsed_plan.operations.is_empty());
1503
1504 assert!(!entry.plan_json.is_empty());
1506 }
1507}