//! Declarative sync schema: the manifest an app hands `SyncStore` so the engine //! can own the changelog, triggers, and apply logic. //! //! An app describes *what* it syncs, tables, columns, primary keys, and the //! handful of per-table policies that genuinely vary, and the engine owns //! *how*. One `columns` list per table drives the generated trigger, the initial //! snapshot, and the apply-side binding, so the historical //! trigger/snapshot/whitelist triplication (and its silent-drift bug class) //! cannot recur. //! //! See `docs/architecture.md` ("Proposed: the `SyncStore` helper") for the full //! design and worked GoingsOn / audiofiles / Balanced Breakfast manifests. /// How the engine resolves concurrent edits to the same row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConflictStrategy { /// Stamp a hybrid logical clock per edit, gate clean changes against /// committed clocks, and resolve true conflicts with `resolve_lww`. /// Deterministic multi-device convergence. The default. HybridLogicalClock, /// Trust the server's sequence order; last delivered wins. Simplest; /// adequate when concurrent same-row edits are rare/uninteresting. ServerOrder, } /// How a table's rows are upserted when a remote change arrives. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SyncMode { /// Upsert every whitelisted column (`INSERT ... ON CONFLICT DO UPDATE`, or /// `INSERT OR IGNORE` when every column is part of the primary key). Full, /// Only ever `UPDATE` the `set` columns where the PK matches, never insert, /// never replace. The export side emits a single UPDATE trigger, gated on one /// of the `set` columns actually changing. (Balanced Breakfast's `feed_items`: /// sync just `is_read`/`is_starred`, never the article body.) PartialUpdate { /// Columns this table syncs; a remote change only ever `UPDATE`s these. set: &'static [&'static str], }, } /// How a table handles a remote delete. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DeleteMode { /// `DELETE FROM table WHERE pk = ...`. Hard, /// Drop remote deletes on the floor (Balanced Breakfast's `feed_items`, /// content re-fetches from the source). No DELETE trigger is generated and /// the apply path ignores deletes for this table. Ignore, /// Apply a remote delete as `UPDATE table SET = now()` instead of a /// hard `DELETE`, so a local `ON DELETE CASCADE` can't wipe organizational /// state the remote delete never intended to touch (audiofiles' `samples`). /// The export DELETE trigger is still generated normally, a *local* delete /// must still propagate; only the *apply* of a remote delete tombstones. Tombstone { /// Column set to `now()` in place of a hard delete when applying a /// remote delete. column: &'static str, }, } /// How a row's wire identifier (`sync_changelog.row_id`) is derived. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RowIdScheme { /// The wire `row_id` is the primary key itself (cast to text). Use for /// opaque-integer or closed-config keys that carry no user content. PrimaryKey, /// The wire `row_id` is a salted hash of the primary key; the cleartext PK /// travels inside the (encrypted) `data` payload so the pull side can /// reconstruct the WHERE clause without the opaque hash. Use for /// content-bearing keys (content hashes, tag strings). Obliges the engine to /// own a per-vault `row_id_salt` and register the `hash_row_id` SQL function. Hashed, } /// A single syncable table and its per-table policy. /// /// Build the common case with [`SyncTable::full`] and layer policy with the /// chained setters; only a minority of tables need any setter at all. #[derive(Debug, Clone)] pub struct SyncTable { pub(crate) name: &'static str, pub(crate) columns: &'static [&'static str], pub(crate) pk: &'static [&'static str], pub(crate) row_id: RowIdScheme, pub(crate) mode: SyncMode, pub(crate) deletes: DeleteMode, pub(crate) preserve_local: &'static [&'static str], pub(crate) insert_defaults: &'static [(&'static str, &'static str)], pub(crate) references_unsynced: bool, pub(crate) exclude_where: Option<&'static str>, pub(crate) group_scope: Option<&'static str>, } impl SyncTable { /// The table's name. pub fn name(&self) -> &'static str { self.name } /// The local provenance column that routes this table's rows to a group /// scope, or `None` if the table is personal-only. Public so a consumer can /// assert exactly which tables are group-scoped (GoingsOn's M3 check). pub fn group_scope(&self) -> Option<&'static str> { self.group_scope } /// A table with the common defaults: single `id` primary key, cleartext /// wire row-id, `Full` upsert, `Hard` delete, no preserved columns, no insert /// defaults, closed referential integrity, no row exclusion. pub fn full(name: &'static str, columns: &'static [&'static str]) -> Self { Self { name, columns, pk: &["id"], row_id: RowIdScheme::PrimaryKey, mode: SyncMode::Full, deletes: DeleteMode::Hard, preserve_local: &[], insert_defaults: &[], references_unsynced: false, exclude_where: None, group_scope: None, } } /// Override the primary-key column(s). One element is a single PK; more is a /// composite key (concatenated with `':'` for the wire row-id). #[must_use] pub fn pk(mut self, pk: &'static [&'static str]) -> Self { self.pk = pk; self } /// Hash the wire row-id and carry the cleartext PK in the payload /// ([`RowIdScheme::Hashed`]). #[must_use] pub fn hashed(mut self) -> Self { self.row_id = RowIdScheme::Hashed; self } /// Apply remote deletes as a tombstone write to `column` /// ([`DeleteMode::Tombstone`]). #[must_use] pub fn tombstone(mut self, column: &'static str) -> Self { self.deletes = DeleteMode::Tombstone { column }; self } /// Ignore remote deletes entirely ([`DeleteMode::Ignore`]). #[must_use] pub fn ignore_deletes(mut self) -> Self { self.deletes = DeleteMode::Ignore; self } /// Sync only the `set` columns via UPDATE ([`SyncMode::PartialUpdate`]). #[must_use] pub fn partial_update(mut self, set: &'static [&'static str]) -> Self { self.mode = SyncMode::PartialUpdate { set }; self } /// Columns to leave untouched on a conflict-update (per-device secrets). #[must_use] pub fn preserve_local(mut self, cols: &'static [&'static str]) -> Self { self.preserve_local = cols; self } /// Values injected only on first INSERT (e.g. to satisfy a NOT NULL on a /// preserved secret column). #[must_use] pub fn insert_defaults(mut self, defaults: &'static [(&'static str, &'static str)]) -> Self { self.insert_defaults = defaults; self } /// This table carries a foreign key into an *unsynced* table; relax FK /// enforcement while applying it. #[must_use] pub fn references_unsynced(mut self) -> Self { self.references_unsynced = true; self } /// A SQL boolean predicate that must hold for a row to sync, applied /// symmetrically on export (the generated trigger's `WHEN`) and import (the /// apply guard). Reference the row through the `{row}` token, the generator /// substitutes `NEW`/`OLD` per trigger. Example: /// `"{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\' AND {row}.key <> 'loose_files'"`. #[must_use] pub fn exclude_where(mut self, predicate: &'static str) -> Self { self.exclude_where = Some(predicate); self } /// Declare that this table's rows carry a `group_id` provenance column and may /// belong to group scopes (SyncKit Groups). `col` is the local column name /// (`NULL` = personal, a group id = that group's shared changelog). The trigger /// reads it to route a change to the right scope; the apply side stamps it from /// the scope being pulled. The value is local provenance only, it never rides /// in the encrypted wire payload. Un-annotated tables are personal-only, so a /// config/credential table can never leak into a group. See the wiki design /// note `synckit-multiscope-design`. #[must_use] pub fn group_scoped(mut self, col: &'static str) -> Self { self.group_scope = Some(col); self } /// The columns emitted into `sync_changelog.data` for an INSERT/UPDATE: /// every whitelisted column for `Full`, or the primary key plus the `set` /// columns (deduped, PK first) for `PartialUpdate`. Public so a consumer can /// diff a declared manifest against its own legacy column whitelist (the /// GoingsOn M1 migration check). pub fn emitted_columns(&self) -> Vec<&'static str> { match &self.mode { SyncMode::Full => self.columns.to_vec(), SyncMode::PartialUpdate { set } => { let mut cols: Vec<&'static str> = self.pk.to_vec(); for c in *set { if !cols.contains(c) { cols.push(c); } } cols } } } } /// An ordered set of syncable tables plus the conflict strategy. /// /// Declaration order is the foreign-key order: parents first. The engine upserts /// in this order and deletes in the exact reverse, so there is no separate, /// hand-maintained delete order to keep in sync. #[derive(Debug, Clone)] pub struct SyncSchema { pub(crate) tables: Vec, pub(crate) conflict: ConflictStrategy, } impl SyncSchema { /// A schema from tables in foreign-key (parents-first) order. Defaults to /// [`ConflictStrategy::HybridLogicalClock`]. pub fn new(tables: Vec) -> Self { Self { tables, conflict: ConflictStrategy::HybridLogicalClock, } } /// Override the conflict strategy. #[must_use] pub fn conflict_strategy(mut self, conflict: ConflictStrategy) -> Self { self.conflict = conflict; self } /// The conflict strategy. pub fn conflict(&self) -> ConflictStrategy { self.conflict } /// The tables, in declared (parents-first) order. pub fn tables(&self) -> &[SyncTable] { &self.tables } /// Whether any table hashes its wire row-id (so the engine must provision a /// `row_id_salt` and register `hash_row_id`). pub(crate) fn any_hashed(&self) -> bool { self.tables.iter().any(|t| t.row_id == RowIdScheme::Hashed) } }