| 89 |
89 |
|
pub(crate) fn run_cutover(db_path: &Path) -> Result<(), String> {
|
| 90 |
90 |
|
let conn = DbSource::path(db_path).open().map_err(str_err)?;
|
| 91 |
91 |
|
|
| 92 |
|
- |
if already_migrated(&conn)? {
|
|
92 |
+ |
if flag_set(&conn, "syncstore_migrated")? {
|
| 93 |
93 |
|
return Ok(());
|
| 94 |
94 |
|
}
|
| 95 |
95 |
|
|
| 100 |
100 |
|
tx.execute_batch(&format!("DROP TRIGGER IF EXISTS \"{name}\";"))
|
| 101 |
101 |
|
.map_err(str_err)?;
|
| 102 |
102 |
|
}
|
|
103 |
+ |
// The manifest is group-scoped (M3), so its generated triggers read
|
|
104 |
+ |
// NEW.group_id — the column must exist before migration_sql runs.
|
|
105 |
+ |
ensure_group_columns(&tx)?;
|
| 103 |
106 |
|
tx.execute_batch(&goingson_schema().migration_sql())
|
| 104 |
107 |
|
.map_err(str_err)?;
|
| 105 |
108 |
|
tx.execute_batch(
|
| 107 |
110 |
|
UPDATE sync_state SET value = '0' WHERE key = 'pull_cursor';\n\
|
| 108 |
111 |
|
UPDATE sync_state SET value = '0' WHERE key = 'initial_snapshot_done';\n\
|
| 109 |
112 |
|
INSERT INTO sync_state (key, value) VALUES ('syncstore_migrated', '1')\n\
|
|
113 |
+ |
ON CONFLICT(key) DO UPDATE SET value = '1';\n\
|
|
114 |
+ |
INSERT INTO sync_state (key, value) VALUES ('syncstore_groups_migrated', '1')\n\
|
| 110 |
115 |
|
ON CONFLICT(key) DO UPDATE SET value = '1';",
|
| 111 |
116 |
|
)
|
| 112 |
117 |
|
.map_err(str_err)?;
|
| 114 |
119 |
|
Ok(())
|
| 115 |
120 |
|
}
|
| 116 |
121 |
|
|
| 117 |
|
- |
fn str_err<E: std::fmt::Display>(e: E) -> String {
|
| 118 |
|
- |
e.to_string()
|
|
122 |
+ |
/// The project-subtree tables that can be shared into a group (M3). Kept in sync
|
|
123 |
+ |
/// with the `.group_scoped("group_id")` markers in [`manifest`].
|
|
124 |
+ |
const GROUP_SCOPED_TABLES: &[&str] = &[
|
|
125 |
+ |
"projects",
|
|
126 |
+ |
"tasks",
|
|
127 |
+ |
"milestones",
|
|
128 |
+ |
"events",
|
|
129 |
+ |
"subtasks",
|
|
130 |
+ |
"annotations",
|
|
131 |
+ |
"task_status_tokens",
|
|
132 |
+ |
"time_sessions",
|
|
133 |
+ |
"attachments",
|
|
134 |
+ |
];
|
|
135 |
+ |
|
|
136 |
+ |
/// M3 group migration for a device that already ran the M2 cutover: add the
|
|
137 |
+ |
/// `group_id` column to the shareable tables and regenerate the engine triggers
|
|
138 |
+ |
/// (now group-scoped). Idempotent, guarded by `syncstore_groups_migrated`,
|
|
139 |
+ |
/// transactional. A fresh install does this inside [`run_cutover`] and sets the
|
|
140 |
+ |
/// flag, so this is a no-op there; only an M2-migrated device reaches the body.
|
|
141 |
+ |
/// Existing rows get `group_id = NULL` (personal), which is correct.
|
|
142 |
+ |
pub(crate) fn run_group_migration(db_path: &Path) -> Result<(), String> {
|
|
143 |
+ |
let conn = DbSource::path(db_path).open().map_err(str_err)?;
|
|
144 |
+ |
if flag_set(&conn, "syncstore_groups_migrated")? {
|
|
145 |
+ |
return Ok(());
|
|
146 |
+ |
}
|
|
147 |
+ |
let tx = conn.unchecked_transaction().map_err(str_err)?;
|
|
148 |
+ |
ensure_group_columns(&tx)?;
|
|
149 |
+ |
// Regenerate every trigger; the shareable tables' triggers now stamp the
|
|
150 |
+ |
// changelog scope from group_id. Each generated trigger has its own DROP.
|
|
151 |
+ |
tx.execute_batch(&goingson_schema().migration_sql())
|
|
152 |
+ |
.map_err(str_err)?;
|
|
153 |
+ |
tx.execute_batch(
|
|
154 |
+ |
"INSERT INTO sync_state (key, value) VALUES ('syncstore_groups_migrated', '1')\n\
|
|
155 |
+ |
ON CONFLICT(key) DO UPDATE SET value = '1';",
|
|
156 |
+ |
)
|
|
157 |
+ |
.map_err(str_err)?;
|
|
158 |
+ |
tx.commit().map_err(str_err)?;
|
|
159 |
+ |
Ok(())
|
| 119 |
160 |
|
}
|
| 120 |
161 |
|
|
| 121 |
|
- |
fn already_migrated(conn: &Connection) -> Result<bool, String> {
|
|
162 |
+ |
/// Add `group_id TEXT` (nullable) to each shareable table that lacks it. Runs
|
|
163 |
+ |
/// before any group-scoped `migration_sql`, so the generated triggers' reads of
|
|
164 |
+ |
/// `NEW.group_id` resolve.
|
|
165 |
+ |
fn ensure_group_columns(conn: &Connection) -> Result<(), String> {
|
|
166 |
+ |
for table in GROUP_SCOPED_TABLES {
|
|
167 |
+ |
if !column_exists(conn, table, "group_id")? {
|
|
168 |
+ |
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN group_id TEXT;"))
|
|
169 |
+ |
.map_err(str_err)?;
|
|
170 |
+ |
}
|
|
171 |
+ |
}
|
|
172 |
+ |
Ok(())
|
|
173 |
+ |
}
|
|
174 |
+ |
|
|
175 |
+ |
fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, String> {
|
|
176 |
+ |
let mut stmt = conn
|
|
177 |
+ |
.prepare(&format!("PRAGMA table_info({table})"))
|
|
178 |
+ |
.map_err(str_err)?;
|
|
179 |
+ |
let mut names = stmt
|
|
180 |
+ |
.query_map([], |r| r.get::<_, String>(1))
|
|
181 |
+ |
.map_err(str_err)?;
|
|
182 |
+ |
names.try_fold(false, |found, name| {
|
|
183 |
+ |
Ok(found || name.map_err(str_err)? == column)
|
|
184 |
+ |
})
|
|
185 |
+ |
}
|
|
186 |
+ |
|
|
187 |
+ |
fn flag_set(conn: &Connection, key: &str) -> Result<bool, String> {
|
| 122 |
188 |
|
let v: Option<String> = conn
|
| 123 |
|
- |
.query_row(
|
| 124 |
|
- |
"SELECT value FROM sync_state WHERE key = 'syncstore_migrated'",
|
| 125 |
|
- |
[],
|
| 126 |
|
- |
|r| r.get(0),
|
| 127 |
|
- |
)
|
|
189 |
+ |
.query_row("SELECT value FROM sync_state WHERE key = ?1", [key], |r| {
|
|
190 |
+ |
r.get(0)
|
|
191 |
+ |
})
|
| 128 |
192 |
|
.optional()
|
| 129 |
193 |
|
.map_err(str_err)?;
|
| 130 |
194 |
|
Ok(v.as_deref() == Some("1"))
|
| 131 |
195 |
|
}
|
| 132 |
196 |
|
|
|
197 |
+ |
fn str_err<E: std::fmt::Display>(e: E) -> String {
|
|
198 |
+ |
e.to_string()
|
|
199 |
+ |
}
|
|
200 |
+ |
|
| 133 |
201 |
|
/// GO's hand-written capture triggers use two naming conventions: `sync_trg_*`
|
| 134 |
202 |
|
/// (migration 030 and later) and `*_changelog` (migration 037's `sync_accounts`).
|
| 135 |
203 |
|
/// Both write to `sync_changelog` and must be dropped before the engine's own
|
| 150 |
218 |
|
.map_err(str_err)?;
|
| 151 |
219 |
|
names.collect::<rusqlite::Result<Vec<_>>>().map_err(str_err)
|
| 152 |
220 |
|
}
|
|
221 |
+ |
|
|
222 |
+ |
#[cfg(test)]
|
|
223 |
+ |
mod tests {
|
|
224 |
+ |
use super::{GROUP_SCOPED_TABLES, column_exists, ensure_group_columns};
|
|
225 |
+ |
use rusqlite::Connection;
|
|
226 |
+ |
|
|
227 |
+ |
#[test]
|
|
228 |
+ |
fn ensure_group_columns_adds_group_id_to_every_shareable_table_idempotently() {
|
|
229 |
+ |
let conn = Connection::open_in_memory().unwrap();
|
|
230 |
+ |
for table in GROUP_SCOPED_TABLES {
|
|
231 |
+ |
conn.execute_batch(&format!("CREATE TABLE {table} (id TEXT PRIMARY KEY);"))
|
|
232 |
+ |
.unwrap();
|
|
233 |
+ |
assert!(!column_exists(&conn, table, "group_id").unwrap());
|
|
234 |
+ |
}
|
|
235 |
+ |
|
|
236 |
+ |
ensure_group_columns(&conn).unwrap();
|
|
237 |
+ |
for table in GROUP_SCOPED_TABLES {
|
|
238 |
+ |
assert!(
|
|
239 |
+ |
column_exists(&conn, table, "group_id").unwrap(),
|
|
240 |
+ |
"{table} should have group_id after migration"
|
|
241 |
+ |
);
|
|
242 |
+ |
}
|
|
243 |
+ |
|
|
244 |
+ |
// Idempotent: a second run is a no-op (columns already present), not an error.
|
|
245 |
+ |
ensure_group_columns(&conn).unwrap();
|
|
246 |
+ |
}
|
|
247 |
+ |
}
|