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 Ok(Self { conn })
126 }
127
128 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 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 self.plan_from_live(target)
151 }
152
153 fn apply_schema(&self, plan: &SchemaPlan) -> Result<(), StorageError> {
154 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 tracing::warn!("[sqlite] ROLLBACK after apply error failed: {rb}");
176 }
177 Err(e)
178 }
179 }
180 }
181}
182
183const HISTORY_TABLE: &str = "_pylon_schema_history";
188
189#[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
203pub struct PushMetadata<'a> {
205 pub manifest_version: u32,
206 pub app_version: &'a str,
207 pub baseline: &'a str,
208}
209
210impl SqliteAdapter {
211 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 pub fn apply_with_history(
238 &self,
239 plan: &SchemaPlan,
240 meta: &PushMetadata<'_>,
241 ) -> Result<(), StorageError> {
242 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 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 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 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 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 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 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
491impl SqliteAdapter {
496 pub fn read_schema(&self) -> Result<SchemaSnapshot, StorageError> {
499 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 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 let ordered: BTreeMap<String, bool> = index_meta.into_iter().collect();
566
567 let mut indexes = Vec::new();
568 for (name, unique) in &ordered {
569 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#[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 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 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 let manifest = test_manifest();
760 let plan = adapter.plan_schema(&manifest).unwrap();
761 adapter.apply_schema(&plan).unwrap();
762
763 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 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 #[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 assert_eq!(snapshot.tables.len(), 1);
849 let user = &snapshot.tables[0];
850 assert_eq!(user.name, "User");
851
852 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); 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 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 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 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 #[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 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 let initial = adapter.plan_from_live(&manifest).unwrap();
987 adapter.apply_schema(&initial).unwrap();
988
989 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 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 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 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 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 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 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); }
1115
1116 #[test]
1117 fn noop_push_also_recorded() {
1118 let adapter = SqliteAdapter::in_memory().unwrap();
1119 let manifest = test_manifest();
1120
1121 let plan1 = adapter.plan_from_live(&manifest).unwrap();
1123 adapter
1124 .apply_with_history(&plan1, &push_meta("live_sqlite"))
1125 .unwrap();
1126
1127 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 assert_eq!(history_count(&adapter), 2);
1136
1137 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 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 #[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); }
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 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 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 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 assert!(entry.plan.is_some());
1337 let parsed_plan = entry.plan.as_ref().unwrap();
1338 assert!(!parsed_plan.operations.is_empty());
1339
1340 assert!(!entry.plan_json.is_empty());
1342 }
1343}