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 std::collections::BTreeSet;
7
8use serde::Deserialize;
9use ssp2::segment::{Column, ColumnType};
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct SchemaIr {
13    pub version: i32,
14    pub tables: Vec<TableIr>,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct TableIr {
20    pub name: String,
21    pub columns: Vec<ColumnIr>,
22    pub primary_key: String,
23    pub scopes: Vec<ScopePatternIr>,
24    /// Local secondary indexes; absent in the IR for index-free tables
25    /// (typegen omits the key), so default to empty on deserialize.
26    #[serde(default)]
27    pub indexes: Vec<IndexIr>,
28    /// Client-local FTS5 projections; absent for tables without local search.
29    #[serde(default, rename = "ftsIndexes")]
30    pub fts_indexes: Vec<FtsIndexIr>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct ColumnIr {
35    pub name: String,
36    #[serde(rename = "type")]
37    pub column_type: String,
38    pub nullable: bool,
39    /// §5.11: this column is encrypted end-to-end. When set, `column_type` is
40    /// `bytes` (the wire type) and `declared_type` is the app type.
41    #[serde(default)]
42    pub encrypted: bool,
43    /// §5.11: the app-side type of an encrypted column (`declaredType` in JSON).
44    #[serde(default, rename = "declaredType")]
45    pub declared_type: Option<String>,
46}
47
48/// One local secondary index (the CREATE INDEX migration subset, §2.4).
49#[derive(Debug, Clone, Deserialize)]
50pub struct IndexIr {
51    pub name: String,
52    pub columns: Vec<String>,
53    pub unique: bool,
54}
55
56/// One client-local contentful FTS5 projection (RFC 0005).
57#[derive(Debug, Clone, Deserialize)]
58pub struct FtsIndexIr {
59    pub name: String,
60    pub columns: Vec<String>,
61    pub tokenize: String,
62}
63
64/// One compiled local secondary index — created on the base + visible tables.
65#[derive(Debug, Clone)]
66pub struct IndexSchema {
67    pub name: String,
68    pub columns: Vec<String>,
69    pub unique: bool,
70}
71
72#[derive(Debug, Clone)]
73pub struct FtsIndexSchema {
74    pub name: String,
75    pub columns: Vec<String>,
76    pub tokenize: String,
77}
78
79#[derive(Debug, Clone, Deserialize)]
80pub struct ScopePatternIr {
81    pub pattern: String,
82    #[serde(default)]
83    pub column: Option<String>,
84}
85
86/// One declared scope variable, mapped to its local column (§3.1, §3.3).
87#[derive(Debug, Clone)]
88pub struct ScopeVariable {
89    pub variable: String,
90    pub column: String,
91    pub prefix: String,
92}
93
94/// §5.11: one encrypted column — its positional index and its app-side
95/// declared type name (`string`, `integer`, …). The wire `Column.ty` is
96/// `bytes`; this carries the pre-flip type for the encrypt/decrypt seam.
97#[derive(Debug, Clone)]
98pub struct EncryptedColumn {
99    pub index: usize,
100    pub declared_type: String,
101}
102
103#[derive(Debug, Clone)]
104pub struct TableSchema {
105    pub name: String,
106    /// LOCAL columns (declaration order). For an encrypted column (§5.11) the
107    /// `ty` is the DECLARED type — the local mirror stores plaintext, so
108    /// read-back and DDL use the real type. Identical to `wire_columns` when
109    /// the table has no encrypted columns.
110    pub columns: Vec<Column>,
111    /// WIRE columns (§2.4 positional codec): identical to `columns` except an
112    /// encrypted column's `ty` is `bytes` (the ciphertext envelope rides the
113    /// bytes machinery). Used by `encode_row_json`/`decode_row_bytes` and the
114    /// §5.2 segment column-table validation.
115    pub wire_columns: Vec<Column>,
116    pub primary_key: String,
117    pub pk_index: usize,
118    pub scope_variables: Vec<ScopeVariable>,
119    /// Local secondary indexes, in declaration order (empty when none).
120    pub indexes: Vec<IndexSchema>,
121    /// Client-local FTS5 projections, in declaration order.
122    pub fts_indexes: Vec<FtsIndexSchema>,
123    /// §5.11: encrypted columns (index + declared type). Empty ⇒ no E2EE.
124    pub encrypted_columns: Vec<EncryptedColumn>,
125}
126
127impl TableSchema {
128    /// §5.11: true when any column is encrypted (skip the seam entirely else).
129    pub fn has_encrypted_columns(&self) -> bool {
130        !self.encrypted_columns.is_empty()
131    }
132}
133
134impl TableSchema {
135    /// §3.3 purge mapping: the generated local scope column for a variable,
136    /// or `None` (the fail-closed case).
137    pub fn scope_column(&self, variable: &str) -> Option<&str> {
138        self.scope_variables
139            .iter()
140            .find(|s| s.variable == variable)
141            .map(|s| s.column.as_str())
142    }
143}
144
145#[derive(Debug, Clone)]
146pub struct ClientSchema {
147    pub version: i32,
148    pub tables: Vec<TableSchema>,
149}
150
151impl ClientSchema {
152    pub fn table(&self, name: &str) -> Option<&TableSchema> {
153        self.tables.iter().find(|t| t.name == name)
154    }
155}
156
157fn parse_column_type(name: &str) -> Result<ColumnType, String> {
158    match name {
159        "string" => Ok(ColumnType::String),
160        "integer" => Ok(ColumnType::Integer),
161        "float" => Ok(ColumnType::Float),
162        "boolean" => Ok(ColumnType::Boolean),
163        "json" => Ok(ColumnType::Json),
164        "bytes" => Ok(ColumnType::Bytes),
165        "blob_ref" => Ok(ColumnType::BlobRef),
166        "crdt" => Ok(ColumnType::Crdt),
167        other => Err(format!("unknown column type {other:?}")),
168    }
169}
170
171/// Extract `{variable}` from a `'prefix:{variable}'` pattern (§3.1).
172fn parse_pattern_variable(pattern: &str) -> Result<String, String> {
173    let open = pattern
174        .find('{')
175        .ok_or_else(|| format!("scope pattern {pattern:?} has no {{variable}}"))?;
176    let close = pattern
177        .rfind('}')
178        .filter(|end| *end > open)
179        .ok_or_else(|| format!("scope pattern {pattern:?} has no closing brace"))?;
180    let variable = &pattern[open + 1..close];
181    if variable.is_empty() {
182        return Err(format!("scope pattern {pattern:?} has an empty variable"));
183    }
184    Ok(variable.to_owned())
185}
186
187pub fn compile_schema(ir: &SchemaIr) -> Result<ClientSchema, String> {
188    let mut tables = Vec::with_capacity(ir.tables.len());
189    let mut schema_object_names = BTreeSet::new();
190    for table in &ir.tables {
191        if !schema_object_names.insert(table.name.clone()) {
192            return Err(format!("duplicate table or schema object {:?}", table.name));
193        }
194    }
195    for table in &ir.tables {
196        // wire_columns carry the on-the-wire type (bytes for encrypted); the
197        // local `columns` carry the declared type so the local mirror is
198        // plaintext (§5.11).
199        let mut wire_columns = Vec::with_capacity(table.columns.len());
200        let mut columns = Vec::with_capacity(table.columns.len());
201        let mut encrypted_columns = Vec::new();
202        for (index, column) in table.columns.iter().enumerate() {
203            let wire_ty = parse_column_type(&column.column_type)?;
204            wire_columns.push(Column {
205                name: column.name.clone(),
206                ty: wire_ty,
207                nullable: column.nullable,
208            });
209            if column.encrypted {
210                let declared_name = column.declared_type.clone().ok_or_else(|| {
211                    format!(
212                        "table {:?}: encrypted column {:?} has no declaredType (§5.11)",
213                        table.name, column.name
214                    )
215                })?;
216                let declared_ty = parse_column_type(&declared_name)?;
217                columns.push(Column {
218                    name: column.name.clone(),
219                    ty: declared_ty,
220                    nullable: column.nullable,
221                });
222                encrypted_columns.push(EncryptedColumn {
223                    index,
224                    declared_type: declared_name,
225                });
226            } else {
227                columns.push(Column {
228                    name: column.name.clone(),
229                    ty: wire_ty,
230                    nullable: column.nullable,
231                });
232            }
233        }
234        let pk_index = columns
235            .iter()
236            .position(|c| c.name == table.primary_key)
237            .ok_or_else(|| {
238                format!(
239                    "table {:?}: primary key {:?} is not a column",
240                    table.name, table.primary_key
241                )
242            })?;
243        let mut scope_variables = Vec::with_capacity(table.scopes.len());
244        for scope in &table.scopes {
245            let variable = parse_pattern_variable(&scope.pattern)?;
246            let column = scope.column.clone().unwrap_or_else(|| variable.clone());
247            let open = scope.pattern.find('{').expect("validated scope pattern");
248            let prefix = scope.pattern[..open]
249                .strip_suffix(':')
250                .unwrap_or(&scope.pattern[..open])
251                .to_owned();
252            scope_variables.push(ScopeVariable {
253                variable,
254                column,
255                prefix,
256            });
257        }
258        let mut indexes = Vec::with_capacity(table.indexes.len());
259        for index in &table.indexes {
260            if !schema_object_names.insert(index.name.clone()) {
261                return Err(format!(
262                    "table {:?}: index {:?} conflicts with another schema object",
263                    table.name, index.name
264                ));
265            }
266            for col in &index.columns {
267                if !columns.iter().any(|c| &c.name == col) {
268                    return Err(format!(
269                        "table {:?}: index {:?} names unknown column {col:?}",
270                        table.name, index.name
271                    ));
272                }
273            }
274            indexes.push(IndexSchema {
275                name: index.name.clone(),
276                columns: index.columns.clone(),
277                unique: index.unique,
278            });
279        }
280        let mut fts_indexes = Vec::with_capacity(table.fts_indexes.len());
281        for index in &table.fts_indexes {
282            if !schema_object_names.insert(index.name.clone()) {
283                return Err(format!(
284                    "table {:?}: FTS projection {:?} conflicts with another schema object",
285                    table.name, index.name
286                ));
287            }
288            if index.columns.is_empty() || index.columns.len() > 32 {
289                return Err(format!(
290                    "table {:?}: FTS projection {:?} needs between 1 and 32 columns",
291                    table.name, index.name
292                ));
293            }
294            let mut seen_columns = BTreeSet::new();
295            for column_name in &index.columns {
296                if !seen_columns.insert(column_name) {
297                    return Err(format!(
298                        "table {:?}: FTS projection {:?} repeats column {:?}",
299                        table.name, index.name, column_name
300                    ));
301                }
302                let Some((column_index, column)) = columns
303                    .iter()
304                    .enumerate()
305                    .find(|(_, candidate)| &candidate.name == column_name)
306                else {
307                    return Err(format!(
308                        "table {:?}: FTS projection {:?} names unknown column {:?}",
309                        table.name, index.name, column_name
310                    ));
311                };
312                if !matches!(column.ty, ColumnType::String) {
313                    return Err(format!(
314                        "table {:?}: FTS projection {:?} column {:?} must have string type",
315                        table.name, index.name, column_name
316                    ));
317                }
318                if encrypted_columns
319                    .iter()
320                    .any(|encrypted| encrypted.index == column_index)
321                {
322                    return Err(format!(
323                        "table {:?}: FTS projection {:?} cannot index encrypted column {:?}",
324                        table.name, index.name, column_name
325                    ));
326                }
327            }
328            if !matches!(
329                index.tokenize.as_str(),
330                "unicode61"
331                    | "unicode61 remove_diacritics 0"
332                    | "unicode61 remove_diacritics 1"
333                    | "unicode61 remove_diacritics 2"
334                    | "porter unicode61"
335                    | "trigram"
336            ) {
337                return Err(format!(
338                    "table {:?}: FTS projection {:?} tokenizer {:?} is not allowlisted",
339                    table.name, index.name, index.tokenize
340                ));
341            }
342            fts_indexes.push(FtsIndexSchema {
343                name: index.name.clone(),
344                columns: index.columns.clone(),
345                tokenize: index.tokenize.clone(),
346            });
347        }
348        tables.push(TableSchema {
349            name: table.name.clone(),
350            columns,
351            wire_columns,
352            primary_key: table.primary_key.clone(),
353            pk_index,
354            scope_variables,
355            indexes,
356            fts_indexes,
357            encrypted_columns,
358        });
359    }
360    Ok(ClientSchema {
361        version: ir.version,
362        tables,
363    })
364}
365
366pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
367    let ir: SchemaIr =
368        serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
369    compile_schema(&ir)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use serde_json::json;
376
377    fn schema_with_fts(column: serde_json::Value, tokenize: &str) -> serde_json::Value {
378        json!({
379            "version": 1,
380            "tables": [{
381                "name": "docs",
382                "primaryKey": "id",
383                "columns": [
384                    { "name": "id", "type": "string", "nullable": false },
385                    { "name": "org_id", "type": "string", "nullable": false },
386                    column
387                ],
388                "scopes": [{ "pattern": "org:{org_id}" }],
389                "ftsIndexes": [{
390                    "name": "docs_fts",
391                    "columns": ["body"],
392                    "tokenize": tokenize
393                }]
394            }]
395        })
396    }
397
398    #[test]
399    fn compiles_an_allowlisted_string_fts_projection() {
400        let schema = parse_schema_json(&schema_with_fts(
401            json!({ "name": "body", "type": "string", "nullable": false }),
402            "unicode61 remove_diacritics 2",
403        ))
404        .expect("valid FTS schema");
405        assert_eq!(schema.tables[0].fts_indexes[0].name, "docs_fts");
406    }
407
408    #[test]
409    fn rejects_encrypted_non_string_and_unknown_tokenizer_fts_definitions() {
410        let encrypted = schema_with_fts(
411            json!({
412                "name": "body", "type": "bytes", "nullable": false,
413                "encrypted": true, "declaredType": "string"
414            }),
415            "unicode61",
416        );
417        assert!(parse_schema_json(&encrypted)
418            .expect_err("encrypted FTS must fail")
419            .contains("cannot index encrypted"));
420
421        let non_string = schema_with_fts(
422            json!({ "name": "body", "type": "integer", "nullable": false }),
423            "unicode61",
424        );
425        assert!(parse_schema_json(&non_string)
426            .expect_err("non-string FTS must fail")
427            .contains("must have string type"));
428
429        let tokenizer = schema_with_fts(
430            json!({ "name": "body", "type": "string", "nullable": false }),
431            "custom",
432        );
433        assert!(parse_schema_json(&tokenizer)
434            .expect_err("custom tokenizer must fail")
435            .contains("not allowlisted"));
436    }
437}