Skip to main content

max / synckit

10.5 KB · 272 lines History Blame Raw
1 //! Declarative sync schema: the manifest an app hands `SyncStore` so the engine
2 //! can own the changelog, triggers, and apply logic.
3 //!
4 //! An app describes *what* it syncs, tables, columns, primary keys, and the
5 //! handful of per-table policies that genuinely vary, and the engine owns
6 //! *how*. One `columns` list per table drives the generated trigger, the initial
7 //! snapshot, and the apply-side binding, so the historical
8 //! trigger/snapshot/whitelist triplication (and its silent-drift bug class)
9 //! cannot recur.
10 //!
11 //! See `docs/architecture.md` ("Proposed: the `SyncStore` helper") for the full
12 //! design and worked GoingsOn / audiofiles / Balanced Breakfast manifests.
13
14 /// How the engine resolves concurrent edits to the same row.
15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 pub enum ConflictStrategy {
17 /// Stamp a hybrid logical clock per edit, gate clean changes against
18 /// committed clocks, and resolve true conflicts with `resolve_lww`.
19 /// Deterministic multi-device convergence. The default.
20 HybridLogicalClock,
21 /// Trust the server's sequence order; last delivered wins. Simplest;
22 /// adequate when concurrent same-row edits are rare/uninteresting.
23 ServerOrder,
24 }
25
26 /// How a table's rows are upserted when a remote change arrives.
27 #[derive(Debug, Clone, PartialEq, Eq)]
28 pub enum SyncMode {
29 /// Upsert every whitelisted column (`INSERT ... ON CONFLICT DO UPDATE`, or
30 /// `INSERT OR IGNORE` when every column is part of the primary key).
31 Full,
32 /// Only ever `UPDATE` the `set` columns where the PK matches, never insert,
33 /// never replace. The export side emits a single UPDATE trigger, gated on one
34 /// of the `set` columns actually changing. (Balanced Breakfast's `feed_items`:
35 /// sync just `is_read`/`is_starred`, never the article body.)
36 PartialUpdate { set: &'static [&'static str] },
37 }
38
39 /// How a table handles a remote delete.
40 #[derive(Debug, Clone, PartialEq, Eq)]
41 pub enum DeleteMode {
42 /// `DELETE FROM table WHERE pk = ...`.
43 Hard,
44 /// Drop remote deletes on the floor (Balanced Breakfast's `feed_items`,
45 /// content re-fetches from the source). No DELETE trigger is generated and
46 /// the apply path ignores deletes for this table.
47 Ignore,
48 /// Apply a remote delete as `UPDATE table SET <column> = now()` instead of a
49 /// hard `DELETE`, so a local `ON DELETE CASCADE` can't wipe organizational
50 /// state the remote delete never intended to touch (audiofiles' `samples`).
51 /// The export DELETE trigger is still generated normally, a *local* delete
52 /// must still propagate; only the *apply* of a remote delete tombstones.
53 Tombstone { column: &'static str },
54 }
55
56 /// How a row's wire identifier (`sync_changelog.row_id`) is derived.
57 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
58 pub enum RowIdScheme {
59 /// The wire `row_id` is the primary key itself (cast to text). Use for
60 /// opaque-integer or closed-config keys that carry no user content.
61 PrimaryKey,
62 /// The wire `row_id` is a salted hash of the primary key; the cleartext PK
63 /// travels inside the (encrypted) `data` payload so the pull side can
64 /// reconstruct the WHERE clause without the opaque hash. Use for
65 /// content-bearing keys (content hashes, tag strings). Obliges the engine to
66 /// own a per-vault `row_id_salt` and register the `hash_row_id` SQL function.
67 Hashed,
68 }
69
70 /// A single syncable table and its per-table policy.
71 ///
72 /// Build the common case with [`SyncTable::full`] and layer policy with the
73 /// chained setters; only a minority of tables need any setter at all.
74 #[derive(Debug, Clone)]
75 pub struct SyncTable {
76 pub(crate) name: &'static str,
77 pub(crate) columns: &'static [&'static str],
78 pub(crate) pk: &'static [&'static str],
79 pub(crate) row_id: RowIdScheme,
80 pub(crate) mode: SyncMode,
81 pub(crate) deletes: DeleteMode,
82 pub(crate) preserve_local: &'static [&'static str],
83 pub(crate) insert_defaults: &'static [(&'static str, &'static str)],
84 pub(crate) references_unsynced: bool,
85 pub(crate) exclude_where: Option<&'static str>,
86 pub(crate) group_scope: Option<&'static str>,
87 }
88
89 impl SyncTable {
90 /// The table's name.
91 pub fn name(&self) -> &'static str {
92 self.name
93 }
94
95 /// The local provenance column that routes this table's rows to a group
96 /// scope, or `None` if the table is personal-only. Public so a consumer can
97 /// assert exactly which tables are group-scoped (GoingsOn's M3 check).
98 pub fn group_scope(&self) -> Option<&'static str> {
99 self.group_scope
100 }
101
102 /// A table with the common defaults: single `id` primary key, cleartext
103 /// wire row-id, `Full` upsert, `Hard` delete, no preserved columns, no insert
104 /// defaults, closed referential integrity, no row exclusion.
105 pub fn full(name: &'static str, columns: &'static [&'static str]) -> Self {
106 Self {
107 name,
108 columns,
109 pk: &["id"],
110 row_id: RowIdScheme::PrimaryKey,
111 mode: SyncMode::Full,
112 deletes: DeleteMode::Hard,
113 preserve_local: &[],
114 insert_defaults: &[],
115 references_unsynced: false,
116 exclude_where: None,
117 group_scope: None,
118 }
119 }
120
121 /// Override the primary-key column(s). One element is a single PK; more is a
122 /// composite key (concatenated with `':'` for the wire row-id).
123 #[must_use]
124 pub fn pk(mut self, pk: &'static [&'static str]) -> Self {
125 self.pk = pk;
126 self
127 }
128
129 /// Hash the wire row-id and carry the cleartext PK in the payload
130 /// ([`RowIdScheme::Hashed`]).
131 #[must_use]
132 pub fn hashed(mut self) -> Self {
133 self.row_id = RowIdScheme::Hashed;
134 self
135 }
136
137 /// Apply remote deletes as a tombstone write to `column`
138 /// ([`DeleteMode::Tombstone`]).
139 #[must_use]
140 pub fn tombstone(mut self, column: &'static str) -> Self {
141 self.deletes = DeleteMode::Tombstone { column };
142 self
143 }
144
145 /// Ignore remote deletes entirely ([`DeleteMode::Ignore`]).
146 #[must_use]
147 pub fn ignore_deletes(mut self) -> Self {
148 self.deletes = DeleteMode::Ignore;
149 self
150 }
151
152 /// Sync only the `set` columns via UPDATE ([`SyncMode::PartialUpdate`]).
153 #[must_use]
154 pub fn partial_update(mut self, set: &'static [&'static str]) -> Self {
155 self.mode = SyncMode::PartialUpdate { set };
156 self
157 }
158
159 /// Columns to leave untouched on a conflict-update (per-device secrets).
160 #[must_use]
161 pub fn preserve_local(mut self, cols: &'static [&'static str]) -> Self {
162 self.preserve_local = cols;
163 self
164 }
165
166 /// Values injected only on first INSERT (e.g. to satisfy a NOT NULL on a
167 /// preserved secret column).
168 #[must_use]
169 pub fn insert_defaults(mut self, defaults: &'static [(&'static str, &'static str)]) -> Self {
170 self.insert_defaults = defaults;
171 self
172 }
173
174 /// This table carries a foreign key into an *unsynced* table; relax FK
175 /// enforcement while applying it.
176 #[must_use]
177 pub fn references_unsynced(mut self) -> Self {
178 self.references_unsynced = true;
179 self
180 }
181
182 /// A SQL boolean predicate that must hold for a row to sync, applied
183 /// symmetrically on export (the generated trigger's `WHEN`) and import (the
184 /// apply guard). Reference the row through the `{row}` token, the generator
185 /// substitutes `NEW`/`OLD` per trigger. Example:
186 /// `"{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\' AND {row}.key <> 'loose_files'"`.
187 #[must_use]
188 pub fn exclude_where(mut self, predicate: &'static str) -> Self {
189 self.exclude_where = Some(predicate);
190 self
191 }
192
193 /// Declare that this table's rows carry a `group_id` provenance column and may
194 /// belong to group scopes (SyncKit Groups). `col` is the local column name
195 /// (`NULL` = personal, a group id = that group's shared changelog). The trigger
196 /// reads it to route a change to the right scope; the apply side stamps it from
197 /// the scope being pulled. The value is local provenance only, it never rides
198 /// in the encrypted wire payload. Un-annotated tables are personal-only, so a
199 /// config/credential table can never leak into a group. See the wiki design
200 /// note `synckit-multiscope-design`.
201 #[must_use]
202 pub fn group_scoped(mut self, col: &'static str) -> Self {
203 self.group_scope = Some(col);
204 self
205 }
206
207 /// The columns emitted into `sync_changelog.data` for an INSERT/UPDATE:
208 /// every whitelisted column for `Full`, or the primary key plus the `set`
209 /// columns (deduped, PK first) for `PartialUpdate`. Public so a consumer can
210 /// diff a declared manifest against its own legacy column whitelist (the
211 /// GoingsOn M1 migration check).
212 pub fn emitted_columns(&self) -> Vec<&'static str> {
213 match &self.mode {
214 SyncMode::Full => self.columns.to_vec(),
215 SyncMode::PartialUpdate { set } => {
216 let mut cols: Vec<&'static str> = self.pk.to_vec();
217 for c in *set {
218 if !cols.contains(c) {
219 cols.push(c);
220 }
221 }
222 cols
223 }
224 }
225 }
226 }
227
228 /// An ordered set of syncable tables plus the conflict strategy.
229 ///
230 /// Declaration order is the foreign-key order: parents first. The engine upserts
231 /// in this order and deletes in the exact reverse, so there is no separate,
232 /// hand-maintained delete order to keep in sync.
233 #[derive(Debug, Clone)]
234 pub struct SyncSchema {
235 pub(crate) tables: Vec<SyncTable>,
236 pub(crate) conflict: ConflictStrategy,
237 }
238
239 impl SyncSchema {
240 /// A schema from tables in foreign-key (parents-first) order. Defaults to
241 /// [`ConflictStrategy::HybridLogicalClock`].
242 pub fn new(tables: Vec<SyncTable>) -> Self {
243 Self {
244 tables,
245 conflict: ConflictStrategy::HybridLogicalClock,
246 }
247 }
248
249 /// Override the conflict strategy.
250 #[must_use]
251 pub fn conflict_strategy(mut self, conflict: ConflictStrategy) -> Self {
252 self.conflict = conflict;
253 self
254 }
255
256 /// The conflict strategy.
257 pub fn conflict(&self) -> ConflictStrategy {
258 self.conflict
259 }
260
261 /// The tables, in declared (parents-first) order.
262 pub fn tables(&self) -> &[SyncTable] {
263 &self.tables
264 }
265
266 /// Whether any table hashes its wire row-id (so the engine must provision a
267 /// `row_id_salt` and register `hash_row_id`).
268 pub(crate) fn any_hashed(&self) -> bool {
269 self.tables.iter().any(|t| t.row_id == RowIdScheme::Hashed)
270 }
271 }
272