Skip to main content

max / goingson

Surface project group scope + move group_id columns to a migration (Groups p4 M4) - Project gains group_id: Option<String> (personal = None), read through the repo and flowing to the frontend via ProjectResponse, so the UI can badge shared projects and confirm a share took effect. - The group_id columns move from the runtime syncstore step into migration 059, the canonical schema. They must exist before the engine's group-scoped triggers are generated and before the project repo selects group_id; a migration keeps the db-sqlite tests and the real schema in agreement (the runtime ALTER did not). The syncstore step now only regenerates triggers; run_group_migration still upgrades an M2-migrated device's personal triggers to group-scoped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 22:40 UTC
Commit: 003e4cc33dde974f39bf1f7cc78d1fce11c60ebc
Parent: 3424e60
5 files changed, +41 insertions, -79 deletions
@@ -158,6 +158,11 @@ pub struct Project {
158 158 pub status: ProjectStatus,
159 159 /// When the project was created.
160 160 pub created_at: DateTime<Utc>,
161 + /// SyncKit group scope: `None` = personal, otherwise the id of the group this
162 + /// project (and its subtree) is shared into. Local provenance, set by
163 + /// share/unshare; not a directly editable field.
164 + #[serde(default)]
165 + pub group_id: Option<String>,
161 166 }
162 167
163 168 // ============ Project DTOs ============
@@ -22,6 +22,7 @@ struct ProjectRow {
22 22 pub project_type: String,
23 23 pub status: String,
24 24 pub created_at: String,
25 + pub group_id: Option<String>,
25 26 }
26 27
27 28 impl TryFrom<ProjectRow> for Project {
@@ -35,6 +36,7 @@ impl TryFrom<ProjectRow> for Project {
35 36 project_type: ProjectType::from_str_or_default(&row.project_type),
36 37 status: ProjectStatus::from_str_or_default(&row.status),
37 38 created_at: parse_datetime(&row.created_at)?,
39 + group_id: row.group_id,
38 40 })
39 41 }
40 42 }
@@ -61,7 +63,7 @@ impl ProjectRepository for SqliteProjectRepository {
61 63 async fn list_all(&self, user_id: UserId) -> Result<Vec<Project>> {
62 64 let rows = sqlx::query_as::<_, ProjectRow>(
63 65 r#"
64 - SELECT id, name, description, project_type, status, created_at
66 + SELECT id, name, description, project_type, status, created_at, group_id
65 67 FROM projects
66 68 WHERE user_id = ?
67 69 ORDER BY created_at DESC
@@ -79,7 +81,7 @@ impl ProjectRepository for SqliteProjectRepository {
79 81 async fn get_by_id(&self, id: ProjectId, user_id: UserId) -> Result<Option<Project>> {
80 82 let row = sqlx::query_as::<_, ProjectRow>(
81 83 r#"
82 - SELECT id, name, description, project_type, status, created_at
84 + SELECT id, name, description, project_type, status, created_at, group_id
83 85 FROM projects
84 86 WHERE id = ? AND user_id = ?
85 87 "#,
@@ -189,7 +191,7 @@ impl ProjectRepository for SqliteProjectRepository {
189 191 async fn find_by_name(&self, user_id: UserId, name: &str) -> Result<Option<Project>> {
190 192 let row = sqlx::query_as::<_, ProjectRow>(
191 193 r#"
192 - SELECT id, name, description, project_type, status, created_at
194 + SELECT id, name, description, project_type, status, created_at, group_id
193 195 FROM projects
194 196 WHERE user_id = ? AND LOWER(name) = LOWER(?)
195 197 "#,
@@ -0,0 +1,20 @@
1 + -- SyncKit Groups (p4 / M3): the group_id provenance column on the shareable
2 + -- project subtree. A non-null group_id routes a row to that group's shared
3 + -- changelog; NULL = personal. This is the static column DDL; the sync triggers
4 + -- that read group_id are generated from the SyncSchema manifest at app init
5 + -- (syncstore::run_cutover / run_group_migration), not here.
6 + --
7 + -- These columns must exist before the engine's group-scoped triggers are
8 + -- generated, and before the project repo selects group_id — so they live in a
9 + -- normal migration (part of the canonical schema) rather than only in the
10 + -- runtime step, which also kept the db-sqlite tests in sync.
11 +
12 + ALTER TABLE projects ADD COLUMN group_id TEXT;
13 + ALTER TABLE tasks ADD COLUMN group_id TEXT;
14 + ALTER TABLE milestones ADD COLUMN group_id TEXT;
15 + ALTER TABLE events ADD COLUMN group_id TEXT;
16 + ALTER TABLE subtasks ADD COLUMN group_id TEXT;
17 + ALTER TABLE annotations ADD COLUMN group_id TEXT;
18 + ALTER TABLE task_status_tokens ADD COLUMN group_id TEXT;
19 + ALTER TABLE time_sessions ADD COLUMN group_id TEXT;
20 + ALTER TABLE attachments ADD COLUMN group_id TEXT;
@@ -59,3 +59,4 @@
59 59 056 30e200908dd4f6d2ebdf5b8f1419207d9c9af430f99df471b61cfd3c9659851a0f8a9b9596aeb682c6567af76bd39ff6
60 60 057 f43f541166c9beb552e22bc5170fcb7bb95db580a6f82fc5dca88e74edeb433ff22e0bd72ddf3913800075a53d360978
61 61 058 8c800b52c85af7f107d7706f4d5c3dfcc3a44f5877a606da23bb3e1df0fa4812f34f2fc78999b16bcab9a6491ff78512
62 + 059 a493b5976c6e2af49213f9d2e2ec1fb052d88e150b98a4dcaea34539472bb92ebc06ed2d66fb69738e522f208efeedd9
@@ -100,9 +100,9 @@ pub(crate) fn run_cutover(db_path: &Path) -> Result<(), String> {
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,33 +119,19 @@ pub(crate) fn run_cutover(db_path: &Path) -> Result<(), String> {
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,31 +145,6 @@ pub(crate) fn run_group_migration(db_path: &Path) -> Result<(), String> {
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,30 +179,3 @@ fn legacy_sync_triggers(conn: &Connection) -> Result<Vec<String>, String> {
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 - }