| 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 |
+ |
// The manifest is group-scoped (M3): its generated triggers read
|
|
104 |
+ |
// NEW.group_id. The column comes from migration 059 (which runs before this
|
|
105 |
+ |
// at app init), so it is guaranteed present here.
|
| 106 |
106 |
|
tx.execute_batch(&goingson_schema().migration_sql())
|
| 107 |
107 |
|
.map_err(str_err)?;
|
| 108 |
108 |
|
tx.execute_batch(
|
| 119 |
119 |
|
Ok(())
|
| 120 |
120 |
|
}
|
| 121 |
121 |
|
|
| 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.
|
|
122 |
+ |
/// M3 group migration for a device that already ran the M2 cutover: regenerate
|
|
123 |
+ |
/// the engine triggers so the shareable tables become group-scoped (they stamp
|
|
124 |
+ |
/// the changelog scope from `group_id`). Idempotent, guarded by
|
|
125 |
+ |
/// `syncstore_groups_migrated`, transactional. The `group_id` columns come from
|
|
126 |
+ |
/// migration 059; a fresh install already generated group-scoped triggers inside
|
|
127 |
+ |
/// [`run_cutover`] and set the flag, so this is a no-op there — only an
|
|
128 |
+ |
/// M2-migrated device (personal-only triggers) reaches the body.
|
| 142 |
129 |
|
pub(crate) fn run_group_migration(db_path: &Path) -> Result<(), String> {
|
| 143 |
130 |
|
let conn = DbSource::path(db_path).open().map_err(str_err)?;
|
| 144 |
131 |
|
if flag_set(&conn, "syncstore_groups_migrated")? {
|
| 145 |
132 |
|
return Ok(());
|
| 146 |
133 |
|
}
|
| 147 |
134 |
|
let tx = conn.unchecked_transaction().map_err(str_err)?;
|
| 148 |
|
- |
ensure_group_columns(&tx)?;
|
| 149 |
135 |
|
// Regenerate every trigger; the shareable tables' triggers now stamp the
|
| 150 |
136 |
|
// changelog scope from group_id. Each generated trigger has its own DROP.
|
| 151 |
137 |
|
tx.execute_batch(&goingson_schema().migration_sql())
|
| 159 |
145 |
|
Ok(())
|
| 160 |
146 |
|
}
|
| 161 |
147 |
|
|
| 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 |
148 |
|
fn flag_set(conn: &Connection, key: &str) -> Result<bool, String> {
|
| 188 |
149 |
|
let v: Option<String> = conn
|
| 189 |
150 |
|
.query_row("SELECT value FROM sync_state WHERE key = ?1", [key], |r| {
|
| 218 |
179 |
|
.map_err(str_err)?;
|
| 219 |
180 |
|
names.collect::<rusqlite::Result<Vec<_>>>().map_err(str_err)
|
| 220 |
181 |
|
}
|
| 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 |
|
- |
}
|