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::AddIndex {
491 entity,
492 name,
493 fields,
494 unique,
495 } => {
496 let sql = create_index_sql(entity, name, fields, *unique);
497 self.conn.execute(&sql, []).map_err(|e| StorageError {
498 code: "SQLITE_EXEC_FAILED".into(),
499 message: format!("Failed to create index {entity}.{name}: {e}"),
500 })?;
501 }
502 SchemaOperation::CreateSearchIndex { entity, config } => {
503 self.conn
507 .execute(crate::search::create_facet_table_sql(), [])
508 .map_err(|e| StorageError {
509 code: "SQLITE_EXEC_FAILED".into(),
510 message: format!("create _facet_bitmap failed: {e}"),
511 })?;
512 if let Some(sql) = crate::search::create_fts_table_sql(entity, config) {
515 self.conn.execute(&sql, []).map_err(|e| StorageError {
516 code: "SQLITE_EXEC_FAILED".into(),
517 message: format!("create _fts_{entity} failed: {e}"),
518 })?;
519 }
520 for field in &config.sortable {
532 let idx_sql = format!(
533 "CREATE INDEX IF NOT EXISTS \"{entity}_sort_{field}\" \
534 ON \"{entity}\" (\"{field}\")"
535 );
536 self.conn.execute(&idx_sql, []).map_err(|e| StorageError {
537 code: "SQLITE_EXEC_FAILED".into(),
538 message: format!("create sort index {entity}.{field} failed: {e}"),
539 })?;
540 }
541 }
542 SchemaOperation::RemoveSearchIndex { entity } => {
543 self.conn
544 .execute(&format!("DROP TABLE IF EXISTS \"_fts_{entity}\""), [])
545 .map_err(|e| StorageError {
546 code: "SQLITE_EXEC_FAILED".into(),
547 message: format!("drop _fts_{entity} failed: {e}"),
548 })?;
549 self.conn
550 .execute("DELETE FROM \"_facet_bitmap\" WHERE entity = ?1", [entity])
551 .map_err(|e| StorageError {
552 code: "SQLITE_EXEC_FAILED".into(),
553 message: format!("clear facet bitmaps for {entity} failed: {e}"),
554 })?;
555 }
556 SchemaOperation::Noop => {}
557 other => {
558 return Err(StorageError {
559 code: "SQLITE_OP_UNSUPPORTED".into(),
560 message: format!("Operation not supported by SQLite adapter: {other:?}"),
561 });
562 }
563 }
564 }
565 Ok(())
566 }
567}
568
569fn generate_push_id() -> String {
570 use std::time::{SystemTime, UNIX_EPOCH};
571 let ts = SystemTime::now()
572 .duration_since(UNIX_EPOCH)
573 .unwrap_or_default();
574 format!("{}.{:09}", ts.as_secs(), ts.subsec_nanos())
575}
576
577fn now_iso8601() -> String {
578 use std::time::{SystemTime, UNIX_EPOCH};
579 let ts = SystemTime::now()
580 .duration_since(UNIX_EPOCH)
581 .unwrap_or_default()
582 .as_secs();
583 let secs_per_day: u64 = 86400;
585 let days = ts / secs_per_day;
586 let rem = ts % secs_per_day;
587 let hours = rem / 3600;
588 let mins = (rem % 3600) / 60;
589 let secs = rem % 60;
590 let (year, month, day) = epoch_days_to_date(days);
592 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{mins:02}:{secs:02}Z")
593}
594
595fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
596 let z = days + 719468;
598 let era = z / 146097;
599 let doe = z - era * 146097;
600 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
601 let y = yoe + era * 400;
602 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
603 let mp = (5 * doy + 2) / 153;
604 let d = doy - (153 * mp + 2) / 5 + 1;
605 let m = if mp < 10 { mp + 3 } else { mp - 9 };
606 let y = if m <= 2 { y + 1 } else { y };
607 (y, m, d)
608}
609
610impl SqliteAdapter {
615 pub fn read_schema(&self) -> Result<SchemaSnapshot, StorageError> {
618 let mut stmt = self
620 .conn
621 .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_pylon_%' ORDER BY name")
622 .map_err(sqlite_err)?;
623
624 let table_names: Vec<String> = stmt
625 .query_map([], |row| row.get(0))
626 .map_err(sqlite_err)?
627 .collect::<Result<Vec<String>, _>>()
628 .map_err(sqlite_err)?;
629
630 let mut tables = Vec::new();
631 for table_name in &table_names {
632 let columns = self.read_columns(table_name)?;
633 let indexes = self.read_indexes(table_name)?;
634 tables.push(TableSnapshot {
635 name: table_name.clone(),
636 columns,
637 indexes,
638 });
639 }
640
641 Ok(SchemaSnapshot { tables })
642 }
643
644 fn read_columns(&self, table: &str) -> Result<Vec<ColumnSnapshot>, StorageError> {
645 let mut stmt = self
646 .conn
647 .prepare(&format!("PRAGMA table_info({})", quote_ident(table)))
648 .map_err(sqlite_err)?;
649
650 let columns: Vec<ColumnSnapshot> = stmt
651 .query_map([], |row| {
652 Ok(ColumnSnapshot {
653 name: row.get(1)?,
654 column_type: row.get(2)?,
655 notnull: row.get::<_, i32>(3)? != 0,
656 primary_key: row.get::<_, i32>(5)? != 0,
657 })
658 })
659 .map_err(sqlite_err)?
660 .collect::<Result<Vec<_>, _>>()
661 .map_err(sqlite_err)?;
662
663 Ok(columns)
664 }
665
666 fn read_indexes(&self, table: &str) -> Result<Vec<IndexSnapshot>, StorageError> {
667 let mut stmt = self
668 .conn
669 .prepare(&format!("PRAGMA index_list({})", quote_ident(table)))
670 .map_err(sqlite_err)?;
671
672 let index_meta: Vec<(String, bool)> = stmt
674 .query_map([], |row| {
675 let name: String = row.get(1)?;
676 let unique: bool = row.get::<_, i32>(2)? != 0;
677 Ok((name, unique))
678 })
679 .map_err(sqlite_err)?
680 .collect::<Result<Vec<_>, _>>()
681 .map_err(sqlite_err)?;
682
683 let ordered: BTreeMap<String, bool> = index_meta.into_iter().collect();
685
686 let mut indexes = Vec::new();
687 for (name, unique) in &ordered {
688 if name.starts_with("sqlite_autoindex_") {
690 continue;
691 }
692
693 let mut col_stmt = self
694 .conn
695 .prepare(&format!("PRAGMA index_info({})", quote_ident(name)))
696 .map_err(sqlite_err)?;
697
698 let columns: Vec<String> = col_stmt
699 .query_map([], |row| row.get(2))
700 .map_err(sqlite_err)?
701 .collect::<Result<Vec<String>, _>>()
702 .map_err(sqlite_err)?;
703
704 indexes.push(IndexSnapshot {
705 name: name.clone(),
706 columns,
707 unique: *unique,
708 });
709 }
710
711 Ok(indexes)
712 }
713}
714
715fn sqlite_err(e: rusqlite::Error) -> StorageError {
716 StorageError {
717 code: "SQLITE_QUERY_FAILED".into(),
718 message: format!("SQLite query failed: {e}"),
719 }
720}
721
722#[cfg(test)]
727mod tests {
728 use super::*;
729 use pylon_kernel::*;
730
731 fn test_manifest() -> AppManifest {
732 AppManifest {
733 manifest_version: MANIFEST_VERSION,
734 name: "test".into(),
735 version: "0.1.0".into(),
736 entities: vec![ManifestEntity {
737 name: "User".into(),
738 fields: vec![
739 ManifestField {
740 name: "email".into(),
741 field_type: "string".into(),
742 optional: false,
743 unique: true,
744 crdt: None,
745 },
746 ManifestField {
747 name: "displayName".into(),
748 field_type: "string".into(),
749 optional: false,
750 unique: false,
751 crdt: None,
752 },
753 ManifestField {
754 name: "age".into(),
755 field_type: "int".into(),
756 optional: true,
757 unique: false,
758 crdt: None,
759 },
760 ],
761 indexes: vec![ManifestIndex {
762 name: "by_email".into(),
763 fields: vec!["email".into()],
764 unique: true,
765 }],
766 relations: vec![],
767 search: None,
768 crdt: true,
769 }],
770 routes: vec![],
771 queries: vec![],
772 actions: vec![],
773 policies: vec![],
774 }
775 }
776
777 #[test]
778 fn create_table_sql_basic() {
779 let fields = vec![
780 FieldSpec {
781 name: "email".into(),
782 field_type: "string".into(),
783 optional: false,
784 unique: true,
785 },
786 FieldSpec {
787 name: "age".into(),
788 field_type: "int".into(),
789 optional: true,
790 unique: false,
791 },
792 ];
793 let sql = create_table_sql("User", &fields);
794 assert_eq!(
795 sql,
796 "CREATE TABLE IF NOT EXISTS \"User\" (id TEXT PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL UNIQUE, \"age\" INTEGER)"
797 );
798 }
799
800 #[test]
801 fn create_index_sql_basic() {
802 let sql = create_index_sql("User", "by_email", &["email".into()], true);
803 assert_eq!(
804 sql,
805 "CREATE UNIQUE INDEX IF NOT EXISTS \"User_by_email\" ON \"User\" (\"email\")"
806 );
807 }
808
809 #[test]
810 fn create_index_sql_non_unique() {
811 let sql = create_index_sql("Todo", "by_user", &["userId".into()], false);
812 assert_eq!(
813 sql,
814 "CREATE INDEX IF NOT EXISTS \"Todo_by_user\" ON \"Todo\" (\"userId\")"
815 );
816 }
817
818 #[test]
819 fn add_column_sql_basic() {
820 let field = FieldSpec {
821 name: "bio".into(),
822 field_type: "string".into(),
823 optional: true,
824 unique: false,
825 };
826 let sql = add_column_sql("User", &field);
827 assert_eq!(sql, "ALTER TABLE \"User\" ADD COLUMN \"bio\" TEXT");
828 }
829
830 #[test]
831 fn quote_ident_escapes_double_quotes() {
832 assert_eq!(quote_ident("normal"), "\"normal\"");
833 assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
834 assert_eq!(
835 quote_ident("Robert'); DROP TABLE Students;--"),
836 "\"Robert'); DROP TABLE Students;--\""
837 );
838 }
839
840 #[test]
841 fn sqlite_adapter_creates_table() {
842 let adapter = SqliteAdapter::in_memory().unwrap();
843 let manifest = test_manifest();
844 let plan = adapter.plan_schema(&manifest).unwrap();
845 adapter.apply_schema(&plan).unwrap();
846
847 let table_count: i64 = adapter
849 .conn
850 .query_row(
851 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User'",
852 [],
853 |row| row.get(0),
854 )
855 .unwrap();
856 assert_eq!(table_count, 1);
857 }
858
859 #[test]
860 fn sqlite_adapter_creates_index() {
861 let adapter = SqliteAdapter::in_memory().unwrap();
862 let manifest = test_manifest();
863 let plan = adapter.plan_schema(&manifest).unwrap();
864 adapter.apply_schema(&plan).unwrap();
865
866 let index_count: i64 = adapter
868 .conn
869 .query_row(
870 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='User_by_email'",
871 [],
872 |row| row.get(0),
873 )
874 .unwrap();
875 assert_eq!(index_count, 1);
876 }
877
878 #[test]
879 fn sqlite_adapter_add_field() {
880 let adapter = SqliteAdapter::in_memory().unwrap();
881
882 let manifest = test_manifest();
884 let plan = adapter.plan_schema(&manifest).unwrap();
885 adapter.apply_schema(&plan).unwrap();
886
887 let add_plan = SchemaPlan {
889 operations: vec![SchemaOperation::AddField {
890 entity: "User".into(),
891 field: FieldSpec {
892 name: "bio".into(),
893 field_type: "string".into(),
894 optional: true,
895 unique: false,
896 },
897 }],
898 };
899 adapter.apply_schema(&add_plan).unwrap();
900
901 let has_bio: bool = adapter
903 .conn
904 .prepare("PRAGMA table_info(\"User\")")
905 .unwrap()
906 .query_map([], |row| {
907 let name: String = row.get(1)?;
908 Ok(name)
909 })
910 .unwrap()
911 .any(|r| r.unwrap() == "bio");
912 assert!(has_bio);
913 }
914
915 #[test]
916 fn sqlite_adapter_rejects_remove_entity() {
917 let adapter = SqliteAdapter::in_memory().unwrap();
918 let plan = SchemaPlan {
919 operations: vec![SchemaOperation::RemoveEntity {
920 name: "User".into(),
921 }],
922 };
923 let result = adapter.apply_schema(&plan);
924 assert!(result.is_err());
925 assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
926 }
927
928 #[test]
929 fn sqlite_adapter_rejects_remove_field() {
930 let adapter = SqliteAdapter::in_memory().unwrap();
931 let plan = SchemaPlan {
932 operations: vec![SchemaOperation::RemoveField {
933 entity: "User".into(),
934 field_name: "email".into(),
935 }],
936 };
937 let result = adapter.apply_schema(&plan);
938 assert!(result.is_err());
939 assert_eq!(result.unwrap_err().code, "SQLITE_OP_UNSUPPORTED");
940 }
941
942 #[test]
943 fn sqlite_adapter_column_types() {
944 assert_eq!(sqlite_column_type("string"), "TEXT");
945 assert_eq!(sqlite_column_type("int"), "INTEGER");
946 assert_eq!(sqlite_column_type("float"), "REAL");
947 assert_eq!(sqlite_column_type("bool"), "INTEGER");
948 assert_eq!(sqlite_column_type("datetime"), "TEXT");
949 assert_eq!(sqlite_column_type("richtext"), "TEXT");
950 assert_eq!(sqlite_column_type("id(User)"), "TEXT");
951 }
952
953 #[test]
956 fn introspect_empty_db() {
957 let adapter = SqliteAdapter::in_memory().unwrap();
958 let snapshot = adapter.read_schema().unwrap();
959 assert!(snapshot.tables.is_empty());
960 }
961
962 #[test]
963 fn introspect_after_apply() {
964 let adapter = SqliteAdapter::in_memory().unwrap();
965 let manifest = test_manifest();
966 let plan = adapter.plan_schema(&manifest).unwrap();
967 adapter.apply_schema(&plan).unwrap();
968
969 let snapshot = adapter.read_schema().unwrap();
970
971 assert_eq!(snapshot.tables.len(), 1);
973 let user = &snapshot.tables[0];
974 assert_eq!(user.name, "User");
975
976 assert_eq!(user.columns.len(), 4);
978 assert_eq!(user.columns[0].name, "id");
979 assert!(user.columns[0].primary_key);
980 assert_eq!(user.columns[1].name, "email");
981 assert_eq!(user.columns[1].column_type, "TEXT");
982 assert!(user.columns[1].notnull);
983 assert_eq!(user.columns[2].name, "displayName");
984 assert_eq!(user.columns[3].name, "age");
985 assert!(!user.columns[3].notnull); assert_eq!(user.indexes.len(), 1);
989 assert_eq!(user.indexes[0].name, "User_by_email");
990 assert_eq!(user.indexes[0].columns, vec!["email"]);
991 assert!(user.indexes[0].unique);
992 }
993
994 #[test]
995 fn introspect_multiple_tables() {
996 let adapter = SqliteAdapter::in_memory().unwrap();
997
998 let manifest = AppManifest {
999 manifest_version: MANIFEST_VERSION,
1000 name: "test".into(),
1001 version: "0.1.0".into(),
1002 entities: vec![
1003 ManifestEntity {
1004 name: "Post".into(),
1005 fields: vec![ManifestField {
1006 name: "title".into(),
1007 field_type: "string".into(),
1008 optional: false,
1009 unique: false,
1010 crdt: None,
1011 }],
1012 indexes: vec![],
1013 relations: vec![],
1014 search: None,
1015 crdt: true,
1016 },
1017 ManifestEntity {
1018 name: "User".into(),
1019 fields: vec![ManifestField {
1020 name: "email".into(),
1021 field_type: "string".into(),
1022 optional: false,
1023 unique: true,
1024 crdt: None,
1025 }],
1026 indexes: vec![],
1027 relations: vec![],
1028 search: None,
1029 crdt: true,
1030 },
1031 ],
1032 routes: vec![],
1033 queries: vec![],
1034 actions: vec![],
1035 policies: vec![],
1036 };
1037
1038 let plan = adapter.plan_schema(&manifest).unwrap();
1039 adapter.apply_schema(&plan).unwrap();
1040
1041 let snapshot = adapter.read_schema().unwrap();
1042
1043 assert_eq!(snapshot.tables.len(), 2);
1045 assert_eq!(snapshot.tables[0].name, "Post");
1046 assert_eq!(snapshot.tables[1].name, "User");
1047 }
1048
1049 #[test]
1050 fn introspect_after_add_field() {
1051 let adapter = SqliteAdapter::in_memory().unwrap();
1052 let manifest = test_manifest();
1053 let plan = adapter.plan_schema(&manifest).unwrap();
1054 adapter.apply_schema(&plan).unwrap();
1055
1056 let add_plan = SchemaPlan {
1058 operations: vec![SchemaOperation::AddField {
1059 entity: "User".into(),
1060 field: FieldSpec {
1061 name: "bio".into(),
1062 field_type: "string".into(),
1063 optional: true,
1064 unique: false,
1065 },
1066 }],
1067 };
1068 adapter.apply_schema(&add_plan).unwrap();
1069
1070 let snapshot = adapter.read_schema().unwrap();
1071 let user = &snapshot.tables[0];
1072
1073 assert_eq!(user.columns.len(), 5);
1075 assert!(user.columns.iter().any(|c| c.name == "bio"));
1076 }
1077
1078 #[test]
1079 fn introspect_snapshot_is_deterministic() {
1080 let adapter = SqliteAdapter::in_memory().unwrap();
1081 let manifest = test_manifest();
1082 let plan = adapter.plan_schema(&manifest).unwrap();
1083 adapter.apply_schema(&plan).unwrap();
1084
1085 let s1 = adapter.read_schema().unwrap();
1086 let s2 = adapter.read_schema().unwrap();
1087 assert_eq!(s1, s2);
1088 }
1089
1090 #[test]
1093 fn plan_from_empty_db_creates_everything() {
1094 let adapter = SqliteAdapter::in_memory().unwrap();
1095 let manifest = test_manifest();
1096
1097 let plan = adapter.plan_from_live(&manifest).unwrap();
1098
1099 assert!(plan.operations.iter().any(|op| matches!(
1101 op,
1102 SchemaOperation::CreateEntity { name, .. } if name == "User"
1103 )));
1104 assert!(plan.operations.iter().any(|op| matches!(
1105 op,
1106 SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1107 )));
1108 }
1109
1110 #[test]
1111 fn plan_from_fully_applied_db_is_noop() {
1112 let adapter = SqliteAdapter::in_memory().unwrap();
1113 let manifest = test_manifest();
1114
1115 let initial = adapter.plan_from_live(&manifest).unwrap();
1117 adapter.apply_schema(&initial).unwrap();
1118
1119 let plan = adapter.plan_from_live(&manifest).unwrap();
1121 assert!(plan.is_empty(), "expected noop, got: {:?}", plan.operations);
1122 }
1123
1124 #[test]
1125 fn plan_detects_missing_column() {
1126 let adapter = SqliteAdapter::in_memory().unwrap();
1127
1128 adapter
1130 .conn
1131 .execute(
1132 "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE)",
1133 [],
1134 )
1135 .unwrap();
1136
1137 let manifest = test_manifest();
1138 let plan = adapter.plan_from_live(&manifest).unwrap();
1139
1140 let add_fields: Vec<_> = plan
1142 .operations
1143 .iter()
1144 .filter(|op| matches!(op, SchemaOperation::AddField { .. }))
1145 .collect();
1146 assert_eq!(add_fields.len(), 2);
1147 }
1148
1149 #[test]
1150 fn plan_detects_missing_index() {
1151 let adapter = SqliteAdapter::in_memory().unwrap();
1152
1153 adapter
1155 .conn
1156 .execute(
1157 "CREATE TABLE \"User\" (id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, \"displayName\" TEXT NOT NULL, age INTEGER)",
1158 [],
1159 )
1160 .unwrap();
1161
1162 let manifest = test_manifest();
1163 let plan = adapter.plan_from_live(&manifest).unwrap();
1164
1165 assert!(plan.operations.iter().any(|op| matches!(
1167 op,
1168 SchemaOperation::AddIndex { entity, name, .. } if entity == "User" && name == "by_email"
1169 )));
1170 assert!(!plan
1171 .operations
1172 .iter()
1173 .any(|op| matches!(op, SchemaOperation::CreateEntity { .. })));
1174 }
1175
1176 fn push_meta(baseline: &str) -> PushMetadata<'_> {
1179 PushMetadata {
1180 manifest_version: 1,
1181 app_version: "0.1.0",
1182 baseline,
1183 }
1184 }
1185
1186 fn history_count(adapter: &SqliteAdapter) -> i64 {
1187 adapter
1188 .conn
1189 .query_row(
1190 &format!("SELECT COUNT(*) FROM {}", quote_ident(HISTORY_TABLE)),
1191 [],
1192 |row| row.get(0),
1193 )
1194 .unwrap()
1195 }
1196
1197 #[test]
1198 fn history_table_created_on_apply() {
1199 let adapter = SqliteAdapter::in_memory().unwrap();
1200 let manifest = test_manifest();
1201 let plan = adapter.plan_from_live(&manifest).unwrap();
1202 adapter
1203 .apply_with_history(&plan, &push_meta("live_sqlite"))
1204 .unwrap();
1205
1206 let table_exists: i64 = adapter
1207 .conn
1208 .query_row(
1209 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
1210 [HISTORY_TABLE],
1211 |row| row.get(0),
1212 )
1213 .unwrap();
1214 assert_eq!(table_exists, 1);
1215 }
1216
1217 #[test]
1218 fn history_row_inserted_on_apply() {
1219 let adapter = SqliteAdapter::in_memory().unwrap();
1220 let manifest = test_manifest();
1221 let plan = adapter.plan_from_live(&manifest).unwrap();
1222 adapter
1223 .apply_with_history(&plan, &push_meta("live_sqlite"))
1224 .unwrap();
1225
1226 assert_eq!(history_count(&adapter), 1);
1227
1228 let (mv, av, baseline, op_count): (i64, String, String, i64) = adapter
1230 .conn
1231 .query_row(
1232 &format!(
1233 "SELECT manifest_version, app_version, baseline, operation_count FROM {} LIMIT 1",
1234 quote_ident(HISTORY_TABLE)
1235 ),
1236 [],
1237 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1238 )
1239 .unwrap();
1240 assert_eq!(mv, 1);
1241 assert_eq!(av, "0.1.0");
1242 assert_eq!(baseline, "live_sqlite");
1243 assert_eq!(op_count, 2); }
1245
1246 #[test]
1247 fn noop_push_also_recorded() {
1248 let adapter = SqliteAdapter::in_memory().unwrap();
1249 let manifest = test_manifest();
1250
1251 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1253 adapter
1254 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1255 .unwrap();
1256
1257 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1259 assert!(plan2.is_empty());
1260 adapter
1261 .apply_with_history(&plan2, &push_meta("live_sqlite"))
1262 .unwrap();
1263
1264 assert_eq!(history_count(&adapter), 2);
1266
1267 let op_count: i64 = adapter
1269 .conn
1270 .query_row(
1271 &format!(
1272 "SELECT operation_count FROM {} ORDER BY id DESC LIMIT 1",
1273 quote_ident(HISTORY_TABLE)
1274 ),
1275 [],
1276 |row| row.get(0),
1277 )
1278 .unwrap();
1279 assert_eq!(op_count, 0);
1280 }
1281
1282 #[test]
1283 fn history_plan_json_is_valid() {
1284 let adapter = SqliteAdapter::in_memory().unwrap();
1285 let manifest = test_manifest();
1286 let plan = adapter.plan_from_live(&manifest).unwrap();
1287 adapter
1288 .apply_with_history(&plan, &push_meta("live_sqlite"))
1289 .unwrap();
1290
1291 let plan_json: String = adapter
1292 .conn
1293 .query_row(
1294 &format!(
1295 "SELECT plan_json FROM {} LIMIT 1",
1296 quote_ident(HISTORY_TABLE)
1297 ),
1298 [],
1299 |row| row.get(0),
1300 )
1301 .unwrap();
1302
1303 let parsed: serde_json::Value = serde_json::from_str(&plan_json).unwrap();
1305 assert!(parsed.get("operations").unwrap().is_array());
1306 }
1307
1308 #[test]
1309 fn history_table_excluded_from_introspection() {
1310 let adapter = SqliteAdapter::in_memory().unwrap();
1311 let manifest = test_manifest();
1312 let plan = adapter.plan_from_live(&manifest).unwrap();
1313 adapter
1314 .apply_with_history(&plan, &push_meta("live_sqlite"))
1315 .unwrap();
1316
1317 let snapshot = adapter.read_schema().unwrap();
1318 assert!(!snapshot.tables.iter().any(|t| t.name.starts_with("_pylon")));
1319 }
1320
1321 #[test]
1324 fn read_history_empty_db() {
1325 let adapter = SqliteAdapter::in_memory().unwrap();
1326 let entries = adapter.read_history(None).unwrap();
1327 assert!(entries.is_empty());
1328 }
1329
1330 #[test]
1331 fn read_history_after_one_push() {
1332 let adapter = SqliteAdapter::in_memory().unwrap();
1333 let manifest = test_manifest();
1334 let plan = adapter.plan_from_live(&manifest).unwrap();
1335 adapter
1336 .apply_with_history(&plan, &push_meta("live_sqlite"))
1337 .unwrap();
1338
1339 let entries = adapter.read_history(None).unwrap();
1340 assert_eq!(entries.len(), 1);
1341 assert_eq!(entries[0].manifest_version, 1);
1342 assert_eq!(entries[0].app_version, "0.1.0");
1343 assert_eq!(entries[0].baseline, "live_sqlite");
1344 assert_eq!(entries[0].operation_count, 2); }
1346
1347 #[test]
1348 fn read_history_after_noop_push() {
1349 let adapter = SqliteAdapter::in_memory().unwrap();
1350 let manifest = test_manifest();
1351
1352 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1353 adapter
1354 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1355 .unwrap();
1356
1357 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1358 adapter
1359 .apply_with_history(&plan2, &push_meta("live_sqlite"))
1360 .unwrap();
1361
1362 let entries = adapter.read_history(None).unwrap();
1363 assert_eq!(entries.len(), 2);
1364 assert_eq!(entries[0].operation_count, 0);
1366 assert_eq!(entries[1].operation_count, 2);
1367 }
1368
1369 #[test]
1370 fn read_history_newest_first() {
1371 let adapter = SqliteAdapter::in_memory().unwrap();
1372 let manifest = test_manifest();
1373
1374 let plan = adapter.plan_from_live(&manifest).unwrap();
1375 adapter
1376 .apply_with_history(
1377 &plan,
1378 &PushMetadata {
1379 manifest_version: 1,
1380 app_version: "0.1.0",
1381 baseline: "first",
1382 },
1383 )
1384 .unwrap();
1385
1386 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1388 adapter
1389 .apply_with_history(
1390 &plan2,
1391 &PushMetadata {
1392 manifest_version: 1,
1393 app_version: "0.2.0",
1394 baseline: "second",
1395 },
1396 )
1397 .unwrap();
1398
1399 let entries = adapter.read_history(None).unwrap();
1400 assert_eq!(entries.len(), 2);
1401 assert_eq!(entries[0].baseline, "second");
1402 assert_eq!(entries[1].baseline, "first");
1403 }
1404
1405 #[test]
1406 fn read_history_with_limit() {
1407 let adapter = SqliteAdapter::in_memory().unwrap();
1408 let manifest = test_manifest();
1409
1410 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1412 adapter
1413 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1414 .unwrap();
1415 let plan2 = adapter.plan_from_live(&manifest).unwrap();
1416 adapter
1417 .apply_with_history(&plan2, &push_meta("live_sqlite"))
1418 .unwrap();
1419
1420 let all = adapter.read_history(None).unwrap();
1421 assert_eq!(all.len(), 2);
1422
1423 let limited = adapter.read_history(Some(1)).unwrap();
1424 assert_eq!(limited.len(), 1);
1425 }
1426
1427 #[test]
1428 fn read_history_entry_by_id() {
1429 let adapter = SqliteAdapter::in_memory().unwrap();
1430 let manifest = test_manifest();
1431
1432 let plan = adapter.plan_from_live(&manifest).unwrap();
1433 adapter
1434 .apply_with_history(&plan, &push_meta("live_sqlite"))
1435 .unwrap();
1436
1437 let entries = adapter.read_history(None).unwrap();
1438 let id = &entries[0].id;
1439
1440 let entry = adapter.read_history_entry(id).unwrap().unwrap();
1441 assert_eq!(&entry.id, id);
1442 assert_eq!(entry.operation_count, 2);
1443 }
1444
1445 #[test]
1446 fn read_history_entry_missing_id() {
1447 let adapter = SqliteAdapter::in_memory().unwrap();
1448 let result = adapter.read_history_entry("nonexistent").unwrap();
1449 assert!(result.is_none());
1450 }
1451
1452 #[test]
1453 fn history_entry_has_parsed_plan() {
1454 let adapter = SqliteAdapter::in_memory().unwrap();
1455 let manifest = test_manifest();
1456
1457 let plan = adapter.plan_from_live(&manifest).unwrap();
1458 adapter
1459 .apply_with_history(&plan, &push_meta("live_sqlite"))
1460 .unwrap();
1461
1462 let entries = adapter.read_history(None).unwrap();
1463 let entry = &entries[0];
1464
1465 assert!(entry.plan.is_some());
1467 let parsed_plan = entry.plan.as_ref().unwrap();
1468 assert!(!parsed_plan.operations.is_empty());
1469
1470 assert!(!entry.plan_json.is_empty());
1472 }
1473}