coven_protocol/synced_schema.rs
1//! Synced-table declarations and the shared identifier-quoting helper.
2//!
3//! [`SyncedTable`] is how a host declares which tables participate in changeset
4//! sync and what `(table, id)` means for each one. The set is no longer a
5//! process-global: the host passes it to
6//! `CovenBuilder::synced_tables`, and coven owns it for the lifetime of
7//! the connection and hands it to each journaled write's capture session, the
8//! gate, and apply.
9
10/// How `(table, id)` names one logical row across every device.
11///
12/// Equality always means one row, including equality between two valid UUIDs.
13/// The mode controls which ids may be introduced; it does not change merge
14/// equality. Changing a primary key removes the old identity and introduces the
15/// new one.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum RowIdentity {
18 /// Rows created independently use canonical lowercase hyphenated RFC UUID
19 /// version 4 or 7 ids.
20 IndependentUuid,
21 /// Application-assigned keys intentionally name the same logical row on
22 /// every device.
23 SharedKey,
24}
25
26impl RowIdentity {
27 pub fn validate(self, table: &str, value: &str) -> Result<(), RowIdentityError> {
28 if self == Self::SharedKey {
29 return Ok(());
30 }
31
32 let valid = uuid::Uuid::parse_str(value).is_ok_and(|parsed| {
33 parsed.get_variant() == uuid::Variant::RFC4122
34 && matches!(
35 parsed.get_version(),
36 Some(uuid::Version::Random | uuid::Version::SortRand)
37 )
38 && parsed.hyphenated().to_string() == value
39 });
40 if valid {
41 Ok(())
42 } else {
43 Err(RowIdentityError::InvalidIndependentUuid {
44 table: table.to_string(),
45 value: value.to_string(),
46 })
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52pub enum RowIdentityError {
53 #[error(
54 "synced table {table:?} id {value:?} is invalid for IndependentUuid; expected a canonical lowercase hyphenated RFC UUID version 4 or 7"
55 )]
56 InvalidIndependentUuid { table: String, value: String },
57 #[error("synced table {table:?} changeset has no {side} primary-key value")]
58 MissingPrimaryKey { table: String, side: &'static str },
59 #[error("synced table {table:?} changeset has a non-TEXT {side} primary-key value")]
60 NonTextPrimaryKey { table: String, side: &'static str },
61 #[error("synced table {table:?} changeset primary key is not UTF-8: {source}")]
62 NonUtf8PrimaryKey {
63 table: String,
64 #[source]
65 source: std::str::Utf8Error,
66 },
67}
68
69impl RowIdentityError {
70 pub fn table(&self) -> &str {
71 match self {
72 Self::InvalidIndependentUuid { table, .. }
73 | Self::MissingPrimaryKey { table, .. }
74 | Self::NonTextPrimaryKey { table, .. }
75 | Self::NonUtf8PrimaryKey { table, .. } => table,
76 }
77 }
78}
79
80/// A table that participates in changeset sync, declared at startup by the host
81/// and passed to `CovenBuilder::synced_tables`.
82///
83/// A plain [`SyncedTable::new`] table syncs unconditionally — every row goes to
84/// peers. [`SyncedTable::remote_root`] keeps that whole-table row sync and also
85/// makes the row a blob-locality root whose blobs are always Remote.
86/// [`SyncedTable::gated_by`] makes it a *gated root*: a boolean column whose
87/// truth decides, per row, whether that row (and its declared FK-descendants) is
88/// shared. A gated-false root and its subtree stay local; flipping the gate true
89/// re-emits the whole now-visible subtree to peers, and flipping it false again
90/// retracts that subtree from peers (emitting deletes for the rows leaving the
91/// shared set) while the rows stay local.
92///
93/// [`SyncedTable::gated_by_descendants`] is the upward complement: an
94/// always-shared *ancestor* that should sync only while at least one gated
95/// descendant survives. Without it, an album whose only releases are gated out
96/// would still sync its own row and land on peers as an orphan with zero
97/// children. A gated-by-descendants ancestor is cut exactly when its gated
98/// subtree is empty, and the keep composes recursively up the foreign-key chain
99/// (an artist syncs iff a surviving album references it, which syncs iff a
100/// surviving release does). The keep-children are *inferred* from the
101/// foreign-key graph, not declared — listing them by hand would restate the
102/// schema and drift the moment a new foreign key is added.
103///
104/// A table is *either* a remote root, a gated root, a gated-by-descendants
105/// ancestor, or plain — never two of these. See the database's `Gates` for the gating
106/// mechanics.
107/// Orthogonally, any table may *carry a blob* ([`SyncedTable::carries_blob`]):
108/// blob-bearing-ness is a property of the row's columns, not of its gate role.
109/// A table may also be marked an *asset* ([`SyncedTable::asset`]): a decoration
110/// (a cover, an artist image) that rides its foreign-key subject's gate but never
111/// keeps that subject alive. Asset-ness is likewise independent of the gate role.
112///
113/// Each table must have an `id` text primary key at column 0 and an
114/// `_updated_at TEXT NOT NULL` column (the HLC/LWW timestamp). Tables not in the
115/// set the host declares on the builder are local-only and never synced — that is
116/// also the mechanism for keeping device-local state (per-device pin/cache
117/// columns, local paths) out of sync: put it in a table you don't declare. An
118/// empty set is rejected when sync starts.
119///
120/// The required [`RowIdentity`] defines which ids may name rows. Use
121/// [`RowIdentity::IndependentUuid`] for independently created rows and
122/// [`RowIdentity::SharedKey`] only when equal application keys intentionally
123/// name and merge as the same row.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct SyncedTable {
126 name: String,
127 row_identity: RowIdentity,
128 role: GateRole,
129 audience_parent_column: Option<String>,
130 blob: Option<BlobDecl>,
131 /// Whether this table is an asset of its FK subject: it rides the subject's
132 /// gate as an inherited child but is excluded from the subject's
133 /// `gated_by_descendants` keep computation, so an asset row never keeps an
134 /// otherwise-empty ancestor alive. Orthogonal to [`GateRole`] and the blob.
135 asset: bool,
136}
137
138/// How a synced table relates to the gate. Orthogonal to whether it carries a
139/// blob.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum GateRole {
142 /// Every row syncs unconditionally.
143 Plain,
144 /// Every row syncs unconditionally, and blobs on the row or its descendants
145 /// are Remote by construction.
146 RemoteRoot,
147 /// A gated root: a row syncs iff its boolean `gate_column` is true, and the
148 /// gate flows down declared foreign keys to descendant rows.
149 GatedRoot { gate_column: String },
150 /// An audience root: NULL is Store, `local` is device-local, and every
151 /// other value is a canonical committed circle id.
152 ScopedRoot { audience_column: String },
153 /// An always-shared ancestor kept alive by its gated subtree: a row syncs
154 /// iff at least one foreign-key descendant table holds a surviving (kept)
155 /// row referencing it. A *marker* only; the keep-children are inferred from
156 /// the live foreign-key graph at gate-build time, never listed here.
157 GatedByDescendants,
158}
159
160impl SyncedTable {
161 /// An ungated synced table: every row syncs under the required identity
162 /// mode.
163 pub fn new(name: impl Into<String>, row_identity: RowIdentity) -> Self {
164 SyncedTable {
165 name: name.into(),
166 row_identity,
167 role: GateRole::Plain,
168 audience_parent_column: None,
169 blob: None,
170 asset: false,
171 }
172 }
173
174 /// Make this a gated root: rows sync iff the boolean `column` is true.
175 pub fn gated_by(mut self, column: impl Into<String>) -> Self {
176 self.role = GateRole::GatedRoot {
177 gate_column: column.into(),
178 };
179 self
180 }
181
182 /// Make this an audience root whose TEXT column selects Store, Local, or
183 /// one committed circle for the row and its foreign-key descendants. A
184 /// store with an audience root requires `HomeStorage::Opaque`.
185 pub fn scoped_by(mut self, column: impl Into<String>) -> Self {
186 self.role = GateRole::ScopedRoot {
187 audience_column: column.into(),
188 };
189 self
190 }
191
192 /// Make this plain table inherit its audience through the foreign key whose
193 /// child column is `column`. Every descendant of an audience root must
194 /// select this relationship explicitly; coven never guesses among the
195 /// table's foreign keys.
196 pub fn inherits_audience_through(mut self, column: impl Into<String>) -> Self {
197 self.audience_parent_column = Some(column.into());
198 self
199 }
200
201 /// Make this a remote root: every row syncs, and blobs on the row or its
202 /// foreign-key descendants are always Remote. There is no Local state for
203 /// `CovenHandle::make_remote` or `CovenHandle::make_local`
204 /// to transition.
205 pub fn remote_root(mut self) -> Self {
206 self.role = GateRole::RemoteRoot;
207 self
208 }
209
210 /// Make this an always-shared ancestor kept alive by its gated subtree: a
211 /// row syncs iff a surviving (kept) descendant row references it. The
212 /// keep-children are inferred from the foreign-key graph at gate-build time,
213 /// so there is nothing to pass here.
214 pub fn gated_by_descendants(mut self) -> Self {
215 self.role = GateRole::GatedByDescendants;
216 self
217 }
218
219 /// Declare that rows of this table carry a blob, located by the columns in
220 /// `decl`. coven derives the blob set itself from these columns + the live
221 /// schema (see the database's `BlobDecls`); it never calls back to the
222 /// host to discover blobs. Independent of the gate role.
223 pub fn carries_blob(mut self, decl: BlobDecl) -> Self {
224 self.blob = Some(decl);
225 self
226 }
227
228 /// Mark this table an *asset* of its FK subject: a host-provided decoration
229 /// (a cover, an artist image) that rides its subject's gate but never grants
230 /// keep. The asset still inherits the gate as a child of its subject — it
231 /// syncs exactly when the subject is kept — but the gate excludes it from the
232 /// subject's `gated_by_descendants` keep computation, so an asset row alone
233 /// never keeps an otherwise-empty ancestor alive (and the asset-rides-subject
234 /// vs. subject-kept-by-children relation can never form a cycle). Independent
235 /// of the gate role; declare it on an FK child of the subject.
236 pub fn asset(mut self) -> Self {
237 self.asset = true;
238 self
239 }
240
241 /// The table name.
242 pub fn name(&self) -> &str {
243 &self.name
244 }
245
246 /// How this table's ids name logical rows across devices.
247 pub fn row_identity(&self) -> RowIdentity {
248 self.row_identity
249 }
250
251 /// The gate column name, if this table is a gated root.
252 pub fn gate_column(&self) -> Option<&str> {
253 match &self.role {
254 GateRole::GatedRoot { gate_column } => Some(gate_column),
255 GateRole::Plain
256 | GateRole::RemoteRoot
257 | GateRole::ScopedRoot { .. }
258 | GateRole::GatedByDescendants => None,
259 }
260 }
261
262 /// The audience column name, if this table is a scoped root.
263 pub fn audience_column(&self) -> Option<&str> {
264 match &self.role {
265 GateRole::ScopedRoot { audience_column } => Some(audience_column),
266 GateRole::Plain
267 | GateRole::RemoteRoot
268 | GateRole::GatedRoot { .. }
269 | GateRole::GatedByDescendants => None,
270 }
271 }
272
273 /// The complete sync role included in the signed routing contract.
274 pub fn gate_role(&self) -> &GateRole {
275 &self.role
276 }
277
278 /// The child foreign-key column that selects this table's audience parent.
279 pub fn audience_parent_column(&self) -> Option<&str> {
280 self.audience_parent_column.as_deref()
281 }
282
283 /// Whether this is a remote root: rows sync unconditionally, and blob
284 /// locality for the row and descendants is always Remote.
285 pub fn is_remote_root(&self) -> bool {
286 matches!(self.role, GateRole::RemoteRoot)
287 }
288
289 /// Whether this is a gated-by-descendants ancestor (kept alive by its gated
290 /// subtree rather than by a column of its own).
291 pub fn is_gated_by_descendants(&self) -> bool {
292 matches!(self.role, GateRole::GatedByDescendants)
293 }
294
295 /// This table's blob declaration, if it carries one.
296 pub fn blob(&self) -> Option<&BlobDecl> {
297 self.blob.as_ref()
298 }
299
300 /// Whether this table is an asset of its FK subject (rides the subject's gate
301 /// but never grants keep). See [`SyncedTable::asset`].
302 pub fn is_asset(&self) -> bool {
303 self.asset
304 }
305}
306
307/// Where a blob-bearing table's blob columns live, declared by the host so coven
308/// can derive every blob a row references without a runtime callback. Resolved
309/// against the live schema into the database's `BlobDecls` each cycle.
310///
311/// A blob declares two orthogonal properties: [`provenance`](BlobDecl::provenance)
312/// (its Local story) and [`fill`](BlobDecl::fill) (its Remote story).
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct BlobDecl {
315 /// The column holding the blob id. Defaults to the primary key (`id`, column
316 /// 0), which is the blob id for most tables.
317 pub id_column: String,
318 /// The column holding the blob's plaintext length in bytes.
319 pub size_column: String,
320 /// The column holding the blob's content hash — the lowercase-hex SHA-256 of
321 /// its plaintext, computed at import (see [`crate::blob::content_hash`]). The
322 /// row carries it in a signed changeset, so it is signed by the row's author;
323 /// on download coven hashes the decrypted plaintext and requires equality with
324 /// this value, so the bytes are pinned by the author, not by where they were
325 /// found. Defaults to `hash`.
326 pub hash_column: String,
327 /// Cloud namespace for the blob, e.g. `"images"` or `"audio"`.
328 pub namespace: String,
329 /// The column holding the consumer's readable cloud-relative path, used as the
330 /// object key under the plain (browsable) blob-path scheme. `None` means the
331 /// blob is keyed only by its hashed id (the default obfuscated scheme).
332 pub cloud_path_column: Option<String>,
333 /// How the blob is scoped for encryption (see [`crate::blob::BlobScope`]).
334 pub scope: crate::blob::BlobScope,
335 /// The blob's **Local story**: [`crate::blob::Provenance::UserProvided`] (the
336 /// user's file at a path) or [`crate::blob::Provenance::HostProvided`] (coven's
337 /// own copy in the local store).
338 pub provenance: crate::blob::Provenance,
339 /// The blob's **Remote story**: [`crate::blob::CacheFill::CacheEager`] (fetched
340 /// into the cache on every pull) or [`crate::blob::CacheFill::CacheLazy`]
341 /// (fetched into the cache on first read).
342 pub fill: crate::blob::CacheFill,
343 /// The blob's **replacement story**: whether this row may be repointed at a
344 /// different blob ([`crate::blob::BlobReplacement`]). Decides what coven requires of
345 /// the blob's cloud key so that a cloud object is never rewritten with different
346 /// bytes. Defaults to [`crate::blob::BlobReplacement::Replaceable`].
347 pub replacement: crate::blob::BlobReplacement,
348}
349
350impl BlobDecl {
351 /// A blob declaration in `namespace` with the given `provenance` (its Local
352 /// story) and cache `fill` (its Remote story), the blob id taken from the
353 /// primary key (`id`), no readable cloud path, master-scoped, and
354 /// [`Replaceable`](crate::blob::BlobReplacement::Replaceable). Refine with the
355 /// `with_*` builders.
356 pub fn new(
357 namespace: impl Into<String>,
358 provenance: crate::blob::Provenance,
359 fill: crate::blob::CacheFill,
360 ) -> Self {
361 BlobDecl {
362 id_column: "id".to_string(),
363 size_column: "size".to_string(),
364 hash_column: "hash".to_string(),
365 namespace: namespace.into(),
366 cloud_path_column: None,
367 scope: crate::blob::BlobScope::Master,
368 provenance,
369 fill,
370 replacement: crate::blob::BlobReplacement::Replaceable,
371 }
372 }
373
374 /// Declare that this table's row is never repointed at a different blob
375 /// ([`crate::blob::BlobReplacement::WriteOnce`]), which frees its readable
376 /// `cloud_path` to be a stable, fully human-readable name. coven refuses a
377 /// repointing. Read that variant's docs before reaching for this: it is a weaker
378 /// contract than the default, and it asks the consumer to guarantee the part coven
379 /// cannot see — that a path is never reused by a different blob.
380 pub fn write_once(mut self) -> Self {
381 self.replacement = crate::blob::BlobReplacement::WriteOnce;
382 self
383 }
384
385 /// Take the blob id from `column` instead of the primary key.
386 pub fn with_id_column(mut self, column: impl Into<String>) -> Self {
387 self.id_column = column.into();
388 self
389 }
390
391 /// Take the plaintext byte length from `column` instead of `size`.
392 pub fn with_size_column(mut self, column: impl Into<String>) -> Self {
393 self.size_column = column.into();
394 self
395 }
396
397 /// Key the blob at the readable cloud path in `column` (the plain scheme).
398 pub fn with_cloud_path_column(mut self, column: impl Into<String>) -> Self {
399 self.cloud_path_column = Some(column.into());
400 self
401 }
402
403 /// Scope the blob's encryption (defaults to [`crate::blob::BlobScope::Master`]).
404 pub fn with_scope(mut self, scope: crate::blob::BlobScope) -> Self {
405 self.scope = scope;
406 self
407 }
408}
409
410#[cfg(test)]
411mod row_identity_tests {
412 use super::*;
413
414 #[test]
415 fn independent_uuid_accepts_only_canonical_rfc_uuid_v4_or_v7() {
416 for valid in [
417 "f47ac10b-58cc-4372-a567-0e02b2c3d479",
418 "01890a5d-ac96-774b-bcce-b302099c3f74",
419 ] {
420 RowIdentity::IndependentUuid
421 .validate("things", valid)
422 .unwrap_or_else(|error| panic!("{valid} must be accepted: {error}"));
423 }
424
425 for invalid in [
426 "F47AC10B-58CC-4372-A567-0E02B2C3D479",
427 "f47ac10b58cc4372a5670e02b2c3d479",
428 "{f47ac10b-58cc-4372-a567-0e02b2c3d479}",
429 "urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479",
430 "00000000-0000-0000-0000-000000000000",
431 "f47ac10b-58cc-1372-a567-0e02b2c3d479",
432 "f47ac10b-58cc-2372-a567-0e02b2c3d479",
433 "f47ac10b-58cc-3372-a567-0e02b2c3d479",
434 "f47ac10b-58cc-5372-a567-0e02b2c3d479",
435 "f47ac10b-58cc-6372-a567-0e02b2c3d479",
436 "f47ac10b-58cc-8372-a567-0e02b2c3d479",
437 "f47ac10b-58cc-4372-0567-0e02b2c3d479",
438 "not-a-uuid",
439 ] {
440 assert!(
441 RowIdentity::IndependentUuid
442 .validate("things", invalid)
443 .is_err(),
444 "{invalid} must be rejected",
445 );
446 }
447
448 RowIdentity::SharedKey
449 .validate("settings", "preferences")
450 .expect("shared keys accept application-assigned ids");
451 }
452}