Skip to main content

max / goingson

14.8 KB · 488 lines History Blame Raw
1 //! The declarative [`SyncSchema`] manifest for GoingsOn's synced tables.
2 //!
3 //! This is the single source of truth the engine uses to generate the changelog
4 //! triggers, the initial snapshot, and the apply-side binding. It reproduces the
5 //! behaviour of GoingsOn's original hand-written triggers exactly (proven at
6 //! cutover against the retired `apply.rs::SYNCED_COLUMNS`); the column lists are
7 //! the synced columns of each table, in FK-safe (parents-first) order; the
8 //! engine derives the delete order by reversing it.
9 //!
10 //! Three tables need more than the common `t(name, cols)` default:
11 //! - `email_accounts` syncs config only. Passwords and OAuth tokens never leave
12 //! the device: they are excluded from the synced columns (so they never enter
13 //! the changelog), `preserve_local` keeps them untouched on a conflict-update,
14 //! and `insert_defaults` satisfies the `password` NOT NULL on a fresh remote
15 //! insert with `''`.
16 //! - `tasks` and `attachments` both carry `source_email_id`, a foreign key into
17 //! the `emails` table, which is NOT synced (emails stay per-device). They are
18 //! marked `references_unsynced` so FK enforcement is relaxed during a remote
19 //! apply.
20 //!
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.
27
28 use synckit_client::store::config_sync_table;
29 use synckit_client::{ConflictStrategy, SyncSchema, SyncTable};
30
31 /// `email_accounts` synced columns: config only, never credentials. The secret
32 /// columns (`password`, the three `oauth2_*`) are excluded here and handled by
33 /// `preserve_local` + `insert_defaults` in [`goingson_schema`].
34 const EMAIL_ACCOUNT_SYNC_COLS: &[&str] = &[
35 "id",
36 "user_id",
37 "account_name",
38 "email_address",
39 "imap_server",
40 "imap_port",
41 "smtp_server",
42 "smtp_port",
43 "username",
44 "use_tls",
45 "created_at",
46 "archive_folder_name",
47 "auth_type",
48 "jmap_session_url",
49 "jmap_account_id",
50 "sync_interval_minutes",
51 "email_signature",
52 ];
53
54 /// The common case: single `id` primary key, cleartext wire row-id, `Full`
55 /// upsert, `Hard` delete, HLC conflict resolution. Most of GO's tables are one
56 /// line each.
57 fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable {
58 SyncTable::full(name, cols)
59 }
60
61 /// A group-shareable table (M3): like [`t`] but marked `.group_scoped("group_id")`
62 /// so a non-null `group_id` routes the row to that group's shared changelog
63 /// (`NULL` = personal). `group_id` is local provenance only and is deliberately
64 /// NOT in the synced column list; it never rides the wire payload; the receiving
65 /// device stamps it from the scope being pulled.
66 fn tg(name: &'static str, cols: &'static [&'static str]) -> SyncTable {
67 SyncTable::full(name, cols).group_scoped("group_id")
68 }
69
70 /// GoingsOn's sync manifest: every table GO replicates, in FK (parents-first)
71 /// order.
72 pub(crate) fn goingson_schema() -> SyncSchema {
73 SyncSchema::new(vec![
74 tg(
75 "projects",
76 &[
77 "id",
78 "name",
79 "description",
80 "project_type",
81 "status",
82 "created_at",
83 "user_id",
84 ],
85 ),
86 t(
87 "contacts",
88 &[
89 "id",
90 "user_id",
91 "display_name",
92 "nickname",
93 "company",
94 "title",
95 "notes",
96 "tags",
97 "birthday",
98 "timezone",
99 "external_source",
100 "external_id",
101 "created_at",
102 "updated_at",
103 ],
104 ),
105 // Config only, never credentials. See the module docs.
106 SyncTable::full("email_accounts", EMAIL_ACCOUNT_SYNC_COLS)
107 .preserve_local(&[
108 "password",
109 "oauth2_access_token",
110 "oauth2_refresh_token",
111 "oauth2_token_expires_at",
112 ])
113 .insert_defaults(&[("password", "''")]),
114 t(
115 "sync_accounts",
116 &[
117 "id",
118 "user_id",
119 "provider",
120 "account_name",
121 "email",
122 "sync_calendars",
123 "sync_contacts",
124 "calendar_ids",
125 "sync_interval_minutes",
126 "enabled",
127 "created_at",
128 ],
129 ),
130 tg(
131 "milestones",
132 &[
133 "id",
134 "user_id",
135 "project_id",
136 "name",
137 "description",
138 "position",
139 "target_date",
140 "status",
141 "created_at",
142 ],
143 ),
144 // Carries source_email_id -> the unsynced emails table.
145 SyncTable::full(
146 "tasks",
147 &[
148 "id",
149 "project_id",
150 "description",
151 "status",
152 "priority",
153 "due",
154 "tags",
155 "urgency",
156 "recurrence",
157 "recurrence_rule",
158 "created_at",
159 "user_id",
160 "recurrence_parent_id",
161 "source_email_id",
162 "snoozed_until",
163 "waiting_for_response",
164 "waiting_since",
165 "expected_response_date",
166 "scheduled_start",
167 "scheduled_duration",
168 "is_focus",
169 "focus_set_at",
170 "contact_id",
171 "milestone_id",
172 "completed_at",
173 "estimated_minutes",
174 "actual_minutes",
175 ],
176 )
177 .references_unsynced()
178 .group_scoped("group_id"),
179 tg(
180 "time_sessions",
181 &[
182 "id",
183 "task_id",
184 "user_id",
185 "started_at",
186 "ended_at",
187 "duration_minutes",
188 "created_at",
189 ],
190 ),
191 // Also carries source_email_id -> the unsynced emails table.
192 SyncTable::full(
193 "attachments",
194 &[
195 "id",
196 "user_id",
197 "task_id",
198 "project_id",
199 "filename",
200 "file_size",
201 "mime_type",
202 "blob_hash",
203 "source_email_id",
204 "created_at",
205 ],
206 )
207 .references_unsynced()
208 .group_scoped("group_id"),
209 tg(
210 "events",
211 &[
212 "id",
213 "project_id",
214 "title",
215 "description",
216 "start_time",
217 "end_time",
218 "location",
219 "user_id",
220 "linked_task_id",
221 "recurrence",
222 "recurrence_parent_id",
223 "recurrence_rule",
224 "contact_id",
225 "block_type",
226 "external_source",
227 "external_id",
228 "is_read_only",
229 "snoozed_until",
230 "reminder_offsets_seconds",
231 ],
232 ),
233 tg("annotations", &["id", "task_id", "timestamp", "note"]),
234 tg(
235 "subtasks",
236 &[
237 "id",
238 "task_id",
239 "text",
240 "is_completed",
241 "position",
242 "created_at",
243 "linked_task_id",
244 ],
245 ),
246 tg(
247 "task_status_tokens",
248 &[
249 "id",
250 "task_id",
251 "kind",
252 "reference",
253 "state",
254 "is_primary",
255 "position",
256 "created_at",
257 ],
258 ),
259 t(
260 "contact_emails",
261 &["id", "contact_id", "address", "label", "is_primary"],
262 ),
263 t(
264 "contact_phones",
265 &["id", "contact_id", "number", "label", "is_primary"],
266 ),
267 t(
268 "contact_social_handles",
269 &["id", "contact_id", "platform", "handle", "url"],
270 ),
271 t(
272 "contact_custom_fields",
273 &["id", "contact_id", "label", "value", "url"],
274 ),
275 t(
276 "daily_notes",
277 &[
278 "id",
279 "user_id",
280 "note_date",
281 "went_well",
282 "could_improve",
283 "is_reviewed",
284 "reviewed_at",
285 "created_at",
286 "updated_at",
287 ],
288 ),
289 // Independent roots (FK only to local `users`); order among them is
290 // irrelevant.
291 t(
292 "saved_views",
293 &[
294 "id",
295 "user_id",
296 "name",
297 "view_type",
298 "filters",
299 "sort_by",
300 "sort_order",
301 "is_pinned",
302 "position",
303 "created_at",
304 "updated_at",
305 ],
306 ),
307 t(
308 "weekly_reviews",
309 &[
310 "id",
311 "user_id",
312 "week_start_date",
313 "completed_at",
314 "notes",
315 "vacation_days",
316 ],
317 ),
318 t(
319 "monthly_goals",
320 &[
321 "id",
322 "user_id",
323 "month",
324 "text",
325 "status",
326 "position",
327 "created_at",
328 "updated_at",
329 ],
330 ),
331 t(
332 "monthly_reflections",
333 &[
334 "id",
335 "user_id",
336 "month",
337 "highlight_text",
338 "change_text",
339 "completed_at",
340 ],
341 ),
342 // App settings (step 6): a key/value table filtered by posture. The
343 // engine generates its export/import triggers gated on the
344 // `config_key_policy` allowlist, seeded from the spec by
345 // `seed_config_policy`. Personal, never group-scoped.
346 config_sync_table(&crate::config_key::CONFIG),
347 ])
348 // GO's current model; also the engine default.
349 .conflict_strategy(ConflictStrategy::HybridLogicalClock)
350 }
351
352 #[cfg(test)]
353 mod tests {
354 use super::goingson_schema;
355
356 /// The frozen table set (FK parents-first). Regenerating the manifest must not
357 /// silently add, drop, or reorder a table, which changes sync behaviour.
358 const EXPECTED_TABLES: &[&str] = &[
359 "projects",
360 "contacts",
361 "email_accounts",
362 "sync_accounts",
363 "milestones",
364 "tasks",
365 "time_sessions",
366 "attachments",
367 "events",
368 "annotations",
369 "subtasks",
370 "task_status_tokens",
371 "contact_emails",
372 "contact_phones",
373 "contact_social_handles",
374 "contact_custom_fields",
375 "daily_notes",
376 "saved_views",
377 "weekly_reviews",
378 "monthly_goals",
379 "monthly_reflections",
380 // Step 6: app settings, key/value (not id-keyed). Always last.
381 "user_config",
382 ];
383
384 /// The project subtree that can be shared into a group (M3). Everything else
385 /// is personal-only. Freezing this guards against a table silently gaining or
386 /// losing group-scoping (which would change what leaves the device into a
387 /// shared changelog).
388 const GROUP_SCOPED_TABLES: &[&str] = &[
389 "projects",
390 "tasks",
391 "milestones",
392 "events",
393 "subtasks",
394 "annotations",
395 "task_status_tokens",
396 "time_sessions",
397 "attachments",
398 ];
399
400 #[test]
401 fn exactly_the_project_subtree_is_group_scoped() {
402 for table in goingson_schema().tables() {
403 let want = GROUP_SCOPED_TABLES.contains(&table.name());
404 let got = table.group_scope() == Some("group_id");
405 assert_eq!(
406 got,
407 want,
408 "{} group-scoping mismatch (expected group_scoped = {want})",
409 table.name()
410 );
411 }
412 }
413
414 #[test]
415 fn manifest_table_set_and_order_is_frozen() {
416 let schema = goingson_schema();
417 let names: Vec<&str> = schema
418 .tables()
419 .iter()
420 .map(synckit_client::SyncTable::name)
421 .collect();
422 assert_eq!(names, EXPECTED_TABLES, "manifest table set/order changed");
423 }
424
425 #[test]
426 fn every_table_is_id_keyed_and_id_first() {
427 for table in goingson_schema().tables() {
428 // user_config is the one key/value table (key-keyed by design); every
429 // domain table is id-keyed and emits id first.
430 if table.name() == "user_config" {
431 continue;
432 }
433 let cols = table.emitted_columns();
434 assert_eq!(
435 cols.first(),
436 Some(&"id"),
437 "{} must emit id first",
438 table.name()
439 );
440 }
441 }
442
443 #[test]
444 fn email_accounts_never_emits_credentials() {
445 let schema = goingson_schema();
446 let email = schema
447 .tables()
448 .iter()
449 .find(|t| t.name() == "email_accounts")
450 .expect("email_accounts in manifest");
451 for secret in [
452 "password",
453 "oauth2_access_token",
454 "oauth2_refresh_token",
455 "oauth2_token_expires_at",
456 ] {
457 assert!(
458 !email.emitted_columns().contains(&secret),
459 "email_accounts must never sync {secret}"
460 );
461 }
462 }
463
464 /// FK dependencies: a child must not be declared before its parent, or the
465 /// engine's parents-first upsert would violate referential integrity.
466 #[test]
467 fn parents_precede_children() {
468 let order: Vec<&str> = goingson_schema()
469 .tables()
470 .iter()
471 .map(synckit_client::SyncTable::name)
472 .collect();
473 let pos = |name: &str| order.iter().position(|n| *n == name).unwrap();
474 for (parent, child) in [
475 ("projects", "tasks"),
476 ("projects", "milestones"),
477 ("tasks", "subtasks"),
478 ("tasks", "annotations"),
479 ("tasks", "task_status_tokens"),
480 ("tasks", "time_sessions"),
481 ("contacts", "contact_emails"),
482 ("contacts", "contact_phones"),
483 ] {
484 assert!(pos(parent) < pos(child), "{parent} must precede {child}");
485 }
486 }
487 }
488