//! The declarative [`SyncSchema`] manifest for GoingsOn's synced tables. //! //! This is the single source of truth the engine uses to generate the changelog //! triggers, the initial snapshot, and the apply-side binding. It reproduces the //! behaviour of GoingsOn's original hand-written triggers exactly (proven at //! cutover against the retired `apply.rs::SYNCED_COLUMNS`); the column lists are //! the synced columns of each table, in FK-safe (parents-first) order; the //! engine derives the delete order by reversing it. //! //! Three tables need more than the common `t(name, cols)` default: //! - `email_accounts` syncs config only. Passwords and OAuth tokens never leave //! the device: they are excluded from the synced columns (so they never enter //! the changelog), `preserve_local` keeps them untouched on a conflict-update, //! and `insert_defaults` satisfies the `password` NOT NULL on a fresh remote //! insert with `''`. //! - `tasks` and `attachments` both carry `source_email_id`, a foreign key into //! the `emails` table, which is NOT synced (emails stay per-device). They are //! marked `references_unsynced` so FK enforcement is relaxed during a remote //! apply. //! //! Groups (M3): the project subtree (projects, tasks, milestones, events, //! subtasks, annotations, task_status_tokens, time_sessions, attachments) is //! `.group_scoped("group_id")` (built via [`tg`]). A non-null `group_id` routes a //! row to that group's shared changelog; the rest stay personal-only, so a //! config/credential table can never leak into a group scope. `group_id` is //! provenance only, never a synced column. use synckit_client::store::config_sync_table; use synckit_client::{ConflictStrategy, SyncSchema, SyncTable}; /// `email_accounts` synced columns: config only, never credentials. The secret /// columns (`password`, the three `oauth2_*`) are excluded here and handled by /// `preserve_local` + `insert_defaults` in [`goingson_schema`]. const EMAIL_ACCOUNT_SYNC_COLS: &[&str] = &[ "id", "user_id", "account_name", "email_address", "imap_server", "imap_port", "smtp_server", "smtp_port", "username", "use_tls", "created_at", "archive_folder_name", "auth_type", "jmap_session_url", "jmap_account_id", "sync_interval_minutes", "email_signature", ]; /// The common case: single `id` primary key, cleartext wire row-id, `Full` /// upsert, `Hard` delete, HLC conflict resolution. Most of GO's tables are one /// line each. fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable { SyncTable::full(name, cols) } /// A group-shareable table (M3): like [`t`] but marked `.group_scoped("group_id")` /// so a non-null `group_id` routes the row to that group's shared changelog /// (`NULL` = personal). `group_id` is local provenance only and is deliberately /// NOT in the synced column list; it never rides the wire payload; the receiving /// device stamps it from the scope being pulled. fn tg(name: &'static str, cols: &'static [&'static str]) -> SyncTable { SyncTable::full(name, cols).group_scoped("group_id") } /// GoingsOn's sync manifest: every table GO replicates, in FK (parents-first) /// order. pub(crate) fn goingson_schema() -> SyncSchema { SyncSchema::new(vec![ tg( "projects", &[ "id", "name", "description", "project_type", "status", "created_at", "user_id", ], ), t( "contacts", &[ "id", "user_id", "display_name", "nickname", "company", "title", "notes", "tags", "birthday", "timezone", "external_source", "external_id", "created_at", "updated_at", ], ), // Config only, never credentials. See the module docs. SyncTable::full("email_accounts", EMAIL_ACCOUNT_SYNC_COLS) .preserve_local(&[ "password", "oauth2_access_token", "oauth2_refresh_token", "oauth2_token_expires_at", ]) .insert_defaults(&[("password", "''")]), t( "sync_accounts", &[ "id", "user_id", "provider", "account_name", "email", "sync_calendars", "sync_contacts", "calendar_ids", "sync_interval_minutes", "enabled", "created_at", ], ), tg( "milestones", &[ "id", "user_id", "project_id", "name", "description", "position", "target_date", "status", "created_at", ], ), // Carries source_email_id -> the unsynced emails table. SyncTable::full( "tasks", &[ "id", "project_id", "description", "status", "priority", "due", "tags", "urgency", "recurrence", "recurrence_rule", "created_at", "user_id", "recurrence_parent_id", "source_email_id", "snoozed_until", "waiting_for_response", "waiting_since", "expected_response_date", "scheduled_start", "scheduled_duration", "is_focus", "focus_set_at", "contact_id", "milestone_id", "completed_at", "estimated_minutes", "actual_minutes", ], ) .references_unsynced() .group_scoped("group_id"), tg( "time_sessions", &[ "id", "task_id", "user_id", "started_at", "ended_at", "duration_minutes", "created_at", ], ), // Also carries source_email_id -> the unsynced emails table. SyncTable::full( "attachments", &[ "id", "user_id", "task_id", "project_id", "filename", "file_size", "mime_type", "blob_hash", "source_email_id", "created_at", ], ) .references_unsynced() .group_scoped("group_id"), tg( "events", &[ "id", "project_id", "title", "description", "start_time", "end_time", "location", "user_id", "linked_task_id", "recurrence", "recurrence_parent_id", "recurrence_rule", "contact_id", "block_type", "external_source", "external_id", "is_read_only", "snoozed_until", "reminder_offsets_seconds", ], ), tg("annotations", &["id", "task_id", "timestamp", "note"]), tg( "subtasks", &[ "id", "task_id", "text", "is_completed", "position", "created_at", "linked_task_id", ], ), tg( "task_status_tokens", &[ "id", "task_id", "kind", "reference", "state", "is_primary", "position", "created_at", ], ), t( "contact_emails", &["id", "contact_id", "address", "label", "is_primary"], ), t( "contact_phones", &["id", "contact_id", "number", "label", "is_primary"], ), t( "contact_social_handles", &["id", "contact_id", "platform", "handle", "url"], ), t( "contact_custom_fields", &["id", "contact_id", "label", "value", "url"], ), t( "daily_notes", &[ "id", "user_id", "note_date", "went_well", "could_improve", "is_reviewed", "reviewed_at", "created_at", "updated_at", ], ), // Independent roots (FK only to local `users`); order among them is // irrelevant. t( "saved_views", &[ "id", "user_id", "name", "view_type", "filters", "sort_by", "sort_order", "is_pinned", "position", "created_at", "updated_at", ], ), t( "weekly_reviews", &[ "id", "user_id", "week_start_date", "completed_at", "notes", "vacation_days", ], ), t( "monthly_goals", &[ "id", "user_id", "month", "text", "status", "position", "created_at", "updated_at", ], ), t( "monthly_reflections", &[ "id", "user_id", "month", "highlight_text", "change_text", "completed_at", ], ), // App settings (step 6): a key/value table filtered by posture. The // engine generates its export/import triggers gated on the // `config_key_policy` allowlist, seeded from the spec by // `seed_config_policy`. Personal, never group-scoped. config_sync_table(&crate::config_key::CONFIG), ]) // GO's current model; also the engine default. .conflict_strategy(ConflictStrategy::HybridLogicalClock) } #[cfg(test)] mod tests { use super::goingson_schema; /// The frozen table set (FK parents-first). Regenerating the manifest must not /// silently add, drop, or reorder a table, which changes sync behaviour. const EXPECTED_TABLES: &[&str] = &[ "projects", "contacts", "email_accounts", "sync_accounts", "milestones", "tasks", "time_sessions", "attachments", "events", "annotations", "subtasks", "task_status_tokens", "contact_emails", "contact_phones", "contact_social_handles", "contact_custom_fields", "daily_notes", "saved_views", "weekly_reviews", "monthly_goals", "monthly_reflections", // Step 6: app settings, key/value (not id-keyed). Always last. "user_config", ]; /// The project subtree that can be shared into a group (M3). Everything else /// is personal-only. Freezing this guards against a table silently gaining or /// losing group-scoping (which would change what leaves the device into a /// shared changelog). const GROUP_SCOPED_TABLES: &[&str] = &[ "projects", "tasks", "milestones", "events", "subtasks", "annotations", "task_status_tokens", "time_sessions", "attachments", ]; #[test] fn exactly_the_project_subtree_is_group_scoped() { for table in goingson_schema().tables() { let want = GROUP_SCOPED_TABLES.contains(&table.name()); let got = table.group_scope() == Some("group_id"); assert_eq!( got, want, "{} group-scoping mismatch (expected group_scoped = {want})", table.name() ); } } #[test] fn manifest_table_set_and_order_is_frozen() { let schema = goingson_schema(); let names: Vec<&str> = schema .tables() .iter() .map(synckit_client::SyncTable::name) .collect(); assert_eq!(names, EXPECTED_TABLES, "manifest table set/order changed"); } #[test] fn every_table_is_id_keyed_and_id_first() { for table in goingson_schema().tables() { // user_config is the one key/value table (key-keyed by design); every // domain table is id-keyed and emits id first. if table.name() == "user_config" { continue; } let cols = table.emitted_columns(); assert_eq!( cols.first(), Some(&"id"), "{} must emit id first", table.name() ); } } #[test] fn email_accounts_never_emits_credentials() { let schema = goingson_schema(); let email = schema .tables() .iter() .find(|t| t.name() == "email_accounts") .expect("email_accounts in manifest"); for secret in [ "password", "oauth2_access_token", "oauth2_refresh_token", "oauth2_token_expires_at", ] { assert!( !email.emitted_columns().contains(&secret), "email_accounts must never sync {secret}" ); } } /// FK dependencies: a child must not be declared before its parent, or the /// engine's parents-first upsert would violate referential integrity. #[test] fn parents_precede_children() { let order: Vec<&str> = goingson_schema() .tables() .iter() .map(synckit_client::SyncTable::name) .collect(); let pos = |name: &str| order.iter().position(|n| *n == name).unwrap(); for (parent, child) in [ ("projects", "tasks"), ("projects", "milestones"), ("tasks", "subtasks"), ("tasks", "annotations"), ("tasks", "task_status_tokens"), ("tasks", "time_sessions"), ("contacts", "contact_emails"), ("contacts", "contact_phones"), ] { assert!(pos(parent) < pos(child), "{parent} must precede {child}"); } } }