Skip to main content

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}