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