1use rusqlite::Connection;
2
3pub const CURRENT_SCHEMA_VERSION: i64 = 2;
4
5pub fn apply_migrations(conn: &Connection) -> rusqlite::Result<()> {
6 conn.execute_batch(
7 "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
8 )?;
9 let current: i64 = conn
10 .query_row(
11 "SELECT CAST(value AS INTEGER) FROM meta WHERE key='schema_version'",
12 [],
13 |r| r.get(0),
14 )
15 .unwrap_or(0);
16
17 if current < 1 {
18 migrate_v1(conn)?;
19 }
20 if current < 2 {
21 migrate_v2(conn)?;
22 }
23 Ok(())
24}
25
26fn migrate_v1(conn: &Connection) -> rusqlite::Result<()> {
27 conn.execute_batch(
28 r#"
29 CREATE TABLE clips (
30 id TEXT PRIMARY KEY,
31 source TEXT NOT NULL,
32 source_key TEXT,
33 content_type TEXT NOT NULL,
34 content BLOB,
35 media_path TEXT,
36 byte_size INTEGER NOT NULL DEFAULT 0,
37 created_at INTEGER NOT NULL,
38 pinned INTEGER NOT NULL DEFAULT 0,
39 pinned_at INTEGER
40 );
41 CREATE INDEX clips_created_idx ON clips(created_at DESC);
42 CREATE INDEX clips_source_idx ON clips(source, created_at DESC);
43 CREATE INDEX clips_pinned_idx ON clips(pinned) WHERE pinned = 1;
44
45 CREATE VIRTUAL TABLE clips_fts USING fts5(
46 content, content='clips', content_rowid='rowid'
47 );
48
49 CREATE TRIGGER clips_ai AFTER INSERT ON clips BEGIN
50 INSERT INTO clips_fts(rowid, content) VALUES (new.rowid, COALESCE(new.content, ''));
51 END;
52 CREATE TRIGGER clips_ad AFTER DELETE ON clips BEGIN
53 INSERT INTO clips_fts(clips_fts, rowid, content) VALUES('delete', old.rowid, COALESCE(old.content, ''));
54 END;
55 CREATE TRIGGER clips_au AFTER UPDATE ON clips BEGIN
56 INSERT INTO clips_fts(clips_fts, rowid, content) VALUES('delete', old.rowid, COALESCE(old.content, ''));
57 INSERT INTO clips_fts(rowid, content) VALUES (new.rowid, COALESCE(new.content, ''));
58 END;
59
60 CREATE TABLE devices (
61 id TEXT PRIMARY KEY,
62 hostname TEXT NOT NULL,
63 nickname TEXT,
64 source_key TEXT,
65 machine_id TEXT,
66 public_key TEXT,
67 paired_at INTEGER,
68 last_push_at INTEGER,
69 online INTEGER NOT NULL DEFAULT 0,
70 refreshed_at INTEGER NOT NULL
71 );
72
73 CREATE TABLE retention_prefs (device_id TEXT PRIMARY KEY, days INTEGER NOT NULL);
74 CREATE TABLE alert_prefs (source TEXT PRIMARY KEY, enabled INTEGER NOT NULL);
75
76 INSERT INTO meta(key, value) VALUES('schema_version', '1');
77 "#,
78 )?;
79 Ok(())
80}
81
82fn migrate_v2(conn: &Connection) -> rusqlite::Result<()> {
83 conn.execute_batch(
84 r#"
85 ALTER TABLE clips ADD COLUMN synced INTEGER NOT NULL DEFAULT 1;
86 CREATE INDEX clips_unsynced_idx ON clips(synced, created_at)
87 WHERE synced = 0;
88 UPDATE meta SET value = '2' WHERE key = 'schema_version';
89 "#,
90 )?;
91 Ok(())
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use rusqlite::Connection;
98
99 #[test]
100 fn v1_to_v2_adds_synced_column() {
101 let conn = Connection::open_in_memory().unwrap();
102 conn.execute_batch("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);")
104 .unwrap();
105 migrate_v1(&conn).unwrap();
107
108 conn.execute(
110 "INSERT INTO clips (id, source, content_type, created_at) VALUES ('old','s','text',0)",
111 [],
112 )
113 .unwrap();
114
115 let err = conn.execute(
117 "INSERT INTO clips (id, source, content_type, created_at, synced) VALUES ('x','s','text',0,1)",
118 [],
119 );
120 assert!(err.is_err(), "synced column should not exist in v1 schema");
121
122 apply_migrations(&conn).unwrap();
124
125 let old_synced: i64 = conn
127 .query_row("SELECT synced FROM clips WHERE id='old'", [], |r| r.get(0))
128 .unwrap();
129 assert_eq!(
130 old_synced, 1,
131 "pre-existing rows must get synced=1 after migration"
132 );
133
134 conn.execute(
136 "INSERT INTO clips (id, source, content_type, created_at, synced) VALUES ('x','s','text',0,0)",
137 [],
138 )
139 .expect("synced column should exist after v2 migration");
140 let n: i64 = conn
141 .query_row("SELECT synced FROM clips WHERE id='x'", [], |r| r.get(0))
142 .unwrap();
143 assert_eq!(n, 0);
144 }
145
146 #[test]
147 fn fresh_db_has_synced_column_with_default_true() {
148 let conn = Connection::open_in_memory().unwrap();
149 apply_migrations(&conn).unwrap();
150 conn.execute(
151 "INSERT INTO clips (id, source, content_type, created_at) VALUES ('y','s','text',0)",
152 [],
153 )
154 .unwrap();
155 let synced: i64 = conn
156 .query_row("SELECT synced FROM clips WHERE id='y'", [], |r| r.get(0))
157 .unwrap();
158 assert_eq!(synced, 1, "new rows must default to synced=1");
159 }
160}