Skip to main content

syncular_client/
schema.rs

1//! Client schema IR (SPEC.md §2.4) — the same JSON shape the conformance
2//! fixture uses (`DriverSchema`): tables with typed columns, a primary key,
3//! and §3.1 scope patterns (`'prefix:{variable}'`, column defaults to the
4//! variable name).
5
6use serde::Deserialize;
7use ssp2::segment::{Column, ColumnType};
8
9#[derive(Debug, Clone, Deserialize)]
10pub struct SchemaIr {
11    pub version: i32,
12    pub tables: Vec<TableIr>,
13}
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct TableIr {
18    pub name: String,
19    pub columns: Vec<ColumnIr>,
20    pub primary_key: String,
21    pub scopes: Vec<ScopePatternIr>,
22    /// Local secondary indexes; absent in the IR for index-free tables
23    /// (typegen omits the key), so default to empty on deserialize.
24    #[serde(default)]
25    pub indexes: Vec<IndexIr>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ColumnIr {
30    pub name: String,
31    #[serde(rename = "type")]
32    pub column_type: String,
33    pub nullable: bool,
34    /// §5.11: this column is encrypted end-to-end. When set, `column_type` is
35    /// `bytes` (the wire type) and `declared_type` is the app type.
36    #[serde(default)]
37    pub encrypted: bool,
38    /// §5.11: the app-side type of an encrypted column (`declaredType` in JSON).
39    #[serde(default, rename = "declaredType")]
40    pub declared_type: Option<String>,
41}
42
43/// One local secondary index (the CREATE INDEX migration subset, §2.4).
44#[derive(Debug, Clone, Deserialize)]
45pub struct IndexIr {
46    pub name: String,
47    pub columns: Vec<String>,
48    pub unique: bool,
49}
50
51/// One compiled local secondary index — created on the base + visible tables.
52#[derive(Debug, Clone)]
53pub struct IndexSchema {
54    pub name: String,
55    pub columns: Vec<String>,
56    pub unique: bool,
57}
58
59#[derive(Debug, Clone, Deserialize)]
60pub struct ScopePatternIr {
61    pub pattern: String,
62    #[serde(default)]
63    pub column: Option<String>,
64}
65
66/// One declared scope variable, mapped to its local column (§3.1, §3.3).
67#[derive(Debug, Clone)]
68pub struct ScopeVariable {
69    pub variable: String,
70    pub column: String,
71    pub prefix: String,
72}
73
74/// §5.11: one encrypted column — its positional index and its app-side
75/// declared type name (`string`, `integer`, …). The wire `Column.ty` is
76/// `bytes`; this carries the pre-flip type for the encrypt/decrypt seam.
77#[derive(Debug, Clone)]
78pub struct EncryptedColumn {
79    pub index: usize,
80    pub declared_type: String,
81}
82
83#[derive(Debug, Clone)]
84pub struct TableSchema {
85    pub name: String,
86    /// LOCAL columns (declaration order). For an encrypted column (§5.11) the
87    /// `ty` is the DECLARED type — the local mirror stores plaintext, so
88    /// read-back and DDL use the real type. Identical to `wire_columns` when
89    /// the table has no encrypted columns.
90    pub columns: Vec<Column>,
91    /// WIRE columns (§2.4 positional codec): identical to `columns` except an
92    /// encrypted column's `ty` is `bytes` (the ciphertext envelope rides the
93    /// bytes machinery). Used by `encode_row_json`/`decode_row_bytes` and the
94    /// §5.2 segment column-table validation.
95    pub wire_columns: Vec<Column>,
96    pub primary_key: String,
97    pub pk_index: usize,
98    pub scope_variables: Vec<ScopeVariable>,
99    /// Local secondary indexes, in declaration order (empty when none).
100    pub indexes: Vec<IndexSchema>,
101    /// §5.11: encrypted columns (index + declared type). Empty ⇒ no E2EE.
102    pub encrypted_columns: Vec<EncryptedColumn>,
103}
104
105impl TableSchema {
106    /// §5.11: true when any column is encrypted (skip the seam entirely else).
107    pub fn has_encrypted_columns(&self) -> bool {
108        !self.encrypted_columns.is_empty()
109    }
110}
111
112impl TableSchema {
113    /// §3.3 purge mapping: the generated local scope column for a variable,
114    /// or `None` (the fail-closed case).
115    pub fn scope_column(&self, variable: &str) -> Option<&str> {
116        self.scope_variables
117            .iter()
118            .find(|s| s.variable == variable)
119            .map(|s| s.column.as_str())
120    }
121}
122
123#[derive(Debug, Clone)]
124pub struct ClientSchema {
125    pub version: i32,
126    pub tables: Vec<TableSchema>,
127}
128
129impl ClientSchema {
130    pub fn table(&self, name: &str) -> Option<&TableSchema> {
131        self.tables.iter().find(|t| t.name == name)
132    }
133}
134
135fn parse_column_type(name: &str) -> Result<ColumnType, String> {
136    match name {
137        "string" => Ok(ColumnType::String),
138        "integer" => Ok(ColumnType::Integer),
139        "float" => Ok(ColumnType::Float),
140        "boolean" => Ok(ColumnType::Boolean),
141        "json" => Ok(ColumnType::Json),
142        "bytes" => Ok(ColumnType::Bytes),
143        "blob_ref" => Ok(ColumnType::BlobRef),
144        "crdt" => Ok(ColumnType::Crdt),
145        other => Err(format!("unknown column type {other:?}")),
146    }
147}
148
149/// Extract `{variable}` from a `'prefix:{variable}'` pattern (§3.1).
150fn parse_pattern_variable(pattern: &str) -> Result<String, String> {
151    let open = pattern
152        .find('{')
153        .ok_or_else(|| format!("scope pattern {pattern:?} has no {{variable}}"))?;
154    let close = pattern
155        .rfind('}')
156        .filter(|end| *end > open)
157        .ok_or_else(|| format!("scope pattern {pattern:?} has no closing brace"))?;
158    let variable = &pattern[open + 1..close];
159    if variable.is_empty() {
160        return Err(format!("scope pattern {pattern:?} has an empty variable"));
161    }
162    Ok(variable.to_owned())
163}
164
165pub fn compile_schema(ir: &SchemaIr) -> Result<ClientSchema, String> {
166    let mut tables = Vec::with_capacity(ir.tables.len());
167    for table in &ir.tables {
168        // wire_columns carry the on-the-wire type (bytes for encrypted); the
169        // local `columns` carry the declared type so the local mirror is
170        // plaintext (§5.11).
171        let mut wire_columns = Vec::with_capacity(table.columns.len());
172        let mut columns = Vec::with_capacity(table.columns.len());
173        let mut encrypted_columns = Vec::new();
174        for (index, column) in table.columns.iter().enumerate() {
175            let wire_ty = parse_column_type(&column.column_type)?;
176            wire_columns.push(Column {
177                name: column.name.clone(),
178                ty: wire_ty,
179                nullable: column.nullable,
180            });
181            if column.encrypted {
182                let declared_name = column.declared_type.clone().ok_or_else(|| {
183                    format!(
184                        "table {:?}: encrypted column {:?} has no declaredType (§5.11)",
185                        table.name, column.name
186                    )
187                })?;
188                let declared_ty = parse_column_type(&declared_name)?;
189                columns.push(Column {
190                    name: column.name.clone(),
191                    ty: declared_ty,
192                    nullable: column.nullable,
193                });
194                encrypted_columns.push(EncryptedColumn {
195                    index,
196                    declared_type: declared_name,
197                });
198            } else {
199                columns.push(Column {
200                    name: column.name.clone(),
201                    ty: wire_ty,
202                    nullable: column.nullable,
203                });
204            }
205        }
206        let pk_index = columns
207            .iter()
208            .position(|c| c.name == table.primary_key)
209            .ok_or_else(|| {
210                format!(
211                    "table {:?}: primary key {:?} is not a column",
212                    table.name, table.primary_key
213                )
214            })?;
215        let mut scope_variables = Vec::with_capacity(table.scopes.len());
216        for scope in &table.scopes {
217            let variable = parse_pattern_variable(&scope.pattern)?;
218            let column = scope.column.clone().unwrap_or_else(|| variable.clone());
219            let open = scope.pattern.find('{').expect("validated scope pattern");
220            let prefix = scope.pattern[..open]
221                .strip_suffix(':')
222                .unwrap_or(&scope.pattern[..open])
223                .to_owned();
224            scope_variables.push(ScopeVariable {
225                variable,
226                column,
227                prefix,
228            });
229        }
230        let mut indexes = Vec::with_capacity(table.indexes.len());
231        for index in &table.indexes {
232            for col in &index.columns {
233                if !columns.iter().any(|c| &c.name == col) {
234                    return Err(format!(
235                        "table {:?}: index {:?} names unknown column {col:?}",
236                        table.name, index.name
237                    ));
238                }
239            }
240            indexes.push(IndexSchema {
241                name: index.name.clone(),
242                columns: index.columns.clone(),
243                unique: index.unique,
244            });
245        }
246        tables.push(TableSchema {
247            name: table.name.clone(),
248            columns,
249            wire_columns,
250            primary_key: table.primary_key.clone(),
251            pk_index,
252            scope_variables,
253            indexes,
254            encrypted_columns,
255        });
256    }
257    Ok(ClientSchema {
258        version: ir.version,
259        tables,
260    })
261}
262
263pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
264    let ir: SchemaIr =
265        serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
266    compile_schema(&ir)
267}