Skip to main content

max / goingson

Group-scope the project subtree (Groups p4 M3) Marks the shareable project subtree — projects, tasks, milestones, events, subtasks, annotations, task_status_tokens, time_sessions, attachments — with .group_scoped("group_id") in the manifest, so a non-null group_id routes a row to that group's shared changelog. group_id is local provenance only and is not a synced column (it never rides the wire payload); everything else stays personal-only, so a config/credential table can't leak into a group. Adds a guarded group migration: run_cutover now adds the group_id columns before generating its (now group-scoped) triggers, and run_group_migration brings an already-M2-migrated device up to M3 by adding the columns and regenerating the triggers. Both idempotent and transactional; existing rows get group_id = NULL (personal). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 22:13 UTC
Commit: ea65be98a7a35beb30d7b9342a5a84a7d72d3547
Parent: 47b904c
3 files changed, +167 insertions, -21 deletions
@@ -157,6 +157,13 @@ impl AppState {
157 157 warn!("SyncStore cutover failed (will retry next launch): {e}");
158 158 }
159 159
160 + // Groups p4 / M3: add the group_id provenance column to the shareable
161 + // tables and regenerate the (now group-scoped) triggers. No-op on a fresh
162 + // install (run_cutover already did it) and once applied. Non-fatal.
163 + if let Err(e) = crate::syncstore::run_group_migration(&db_path) {
164 + warn!("SyncStore group migration failed (will retry next launch): {e}");
165 + }
166 +
160 167 // Build the SyncStore over goingson.db (its own WAL rusqlite connection,
161 168 // coexisting with the sqlx pool) when a sync client is configured.
162 169 let sync_store = load_sync_client(&app_data_dir)
@@ -18,9 +18,12 @@
18 18 //! marked `references_unsynced` so FK enforcement is relaxed during a remote
19 19 //! apply.
20 20 //!
21 - //! Groups (M3) will add `.group_scoped("group_id")` to the shareable tables; the
22 - //! personal-only default here means a config/credential table can never leak into
23 - //! a group scope.
21 + //! Groups (M3): the project subtree — projects, tasks, milestones, events,
22 + //! subtasks, annotations, task_status_tokens, time_sessions, attachments — is
23 + //! `.group_scoped("group_id")` (built via [`tg`]). A non-null `group_id` routes a
24 + //! row to that group's shared changelog; the rest stay personal-only, so a
25 + //! config/credential table can never leak into a group scope. `group_id` is
26 + //! provenance only — never a synced column.
24 27
25 28 use synckit_client::{ConflictStrategy, SyncSchema, SyncTable};
26 29
@@ -54,11 +57,20 @@ fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable {
54 57 SyncTable::full(name, cols)
55 58 }
56 59
60 + /// A group-shareable table (M3): like [`t`] but marked `.group_scoped("group_id")`
61 + /// so a non-null `group_id` routes the row to that group's shared changelog
62 + /// (`NULL` = personal). `group_id` is local provenance only and is deliberately
63 + /// NOT in the synced column list — it never rides the wire payload; the receiving
64 + /// device stamps it from the scope being pulled.
65 + fn tg(name: &'static str, cols: &'static [&'static str]) -> SyncTable {
66 + SyncTable::full(name, cols).group_scoped("group_id")
67 + }
68 +
57 69 /// GoingsOn's sync manifest: every table GO replicates, in FK (parents-first)
58 70 /// order.
59 71 pub(crate) fn goingson_schema() -> SyncSchema {
60 72 SyncSchema::new(vec![
61 - t(
73 + tg(
62 74 "projects",
63 75 &[
64 76 "id",
@@ -114,7 +126,7 @@ pub(crate) fn goingson_schema() -> SyncSchema {
114 126 "created_at",
115 127 ],
116 128 ),
117 - t(
129 + tg(
118 130 "milestones",
119 131 &[
120 132 "id",
@@ -161,8 +173,9 @@ pub(crate) fn goingson_schema() -> SyncSchema {
161 173 "actual_minutes",
162 174 ],
163 175 )
164 - .references_unsynced(),
165 - t(
176 + .references_unsynced()
177 + .group_scoped("group_id"),
178 + tg(
166 179 "time_sessions",
167 180 &[
168 181 "id",
@@ -190,8 +203,9 @@ pub(crate) fn goingson_schema() -> SyncSchema {
190 203 "created_at",
191 204 ],
192 205 )
193 - .references_unsynced(),
194 - t(
206 + .references_unsynced()
207 + .group_scoped("group_id"),
208 + tg(
195 209 "events",
196 210 &[
197 211 "id",
@@ -215,8 +229,8 @@ pub(crate) fn goingson_schema() -> SyncSchema {
215 229 "reminder_offsets_seconds",
216 230 ],
217 231 ),
218 - t("annotations", &["id", "task_id", "timestamp", "note"]),
219 - t(
232 + tg("annotations", &["id", "task_id", "timestamp", "note"]),
233 + tg(
220 234 "subtasks",
221 235 &[
222 236 "id",
@@ -228,7 +242,7 @@ pub(crate) fn goingson_schema() -> SyncSchema {
228 242 "linked_task_id",
229 243 ],
230 244 ),
231 - t(
245 + tg(
232 246 "task_status_tokens",
233 247 &[
234 248 "id",
@@ -359,6 +373,36 @@ mod tests {
359 373 "monthly_reflections",
360 374 ];
361 375
376 + /// The project subtree that can be shared into a group (M3). Everything else
377 + /// is personal-only. Freezing this guards against a table silently gaining or
378 + /// losing group-scoping (which would change what leaves the device into a
379 + /// shared changelog).
380 + const GROUP_SCOPED_TABLES: &[&str] = &[
381 + "projects",
382 + "tasks",
383 + "milestones",
384 + "events",
385 + "subtasks",
386 + "annotations",
387 + "task_status_tokens",
388 + "time_sessions",
389 + "attachments",
390 + ];
391 +
392 + #[test]
393 + fn exactly_the_project_subtree_is_group_scoped() {
394 + for table in goingson_schema().tables() {
395 + let want = GROUP_SCOPED_TABLES.contains(&table.name());
396 + let got = table.group_scope() == Some("group_id");
397 + assert_eq!(
398 + got,
399 + want,
400 + "{} group-scoping mismatch (expected group_scoped = {want})",
401 + table.name()
402 + );
403 + }
404 + }
405 +
362 406 #[test]
363 407 fn manifest_table_set_and_order_is_frozen() {
364 408 let schema = goingson_schema();
@@ -89,7 +89,7 @@ pub fn spawn_scheduler(app: &AppHandle) {
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,6 +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 106 tx.execute_batch(&goingson_schema().migration_sql())
104 107 .map_err(str_err)?;
105 108 tx.execute_batch(
@@ -107,6 +110,8 @@ pub(crate) fn run_cutover(db_path: &Path) -> Result<(), String> {
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,22 +119,85 @@ pub(crate) fn run_cutover(db_path: &Path) -> Result<(), String> {
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,3 +218,30 @@ fn legacy_sync_triggers(conn: &Connection) -> Result<Vec<String>, String> {
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 + }