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.
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        // §2.4 primary-key eligibility: a primary key MUST render to a
245        // `rowId` string every implementation produces identically and local
246        // storage resolves without deferring to a SQLite build.
247        if !matches!(
248            columns[pk_index].ty,
249            ColumnType::String | ColumnType::Integer | ColumnType::Boolean | ColumnType::Json
250        ) {
251            return Err(format!(
252                "table {:?}: primary key {:?} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json",
253                table.name, table.primary_key
254            ));
255        }
256        let mut scope_variables = Vec::with_capacity(table.scopes.len());
257        for scope in &table.scopes {
258            let variable = parse_pattern_variable(&scope.pattern)?;
259            let column = scope.column.clone().unwrap_or_else(|| variable.clone());
260            let open = scope.pattern.find('{').expect("validated scope pattern");
261            let prefix = scope.pattern[..open]
262                .strip_suffix(':')
263                .unwrap_or(&scope.pattern[..open])
264                .to_owned();
265            scope_variables.push(ScopeVariable {
266                variable,
267                column,
268                prefix,
269            });
270        }
271        let mut indexes = Vec::with_capacity(table.indexes.len());
272        for index in &table.indexes {
273            if !schema_object_names.insert(index.name.clone()) {
274                return Err(format!(
275                    "table {:?}: index {:?} conflicts with another schema object",
276                    table.name, index.name
277                ));
278            }
279            for col in &index.columns {
280                if !columns.iter().any(|c| &c.name == col) {
281                    return Err(format!(
282                        "table {:?}: index {:?} names unknown column {col:?}",
283                        table.name, index.name
284                    ));
285                }
286            }
287            indexes.push(IndexSchema {
288                name: index.name.clone(),
289                columns: index.columns.clone(),
290                unique: index.unique,
291            });
292        }
293        let mut fts_indexes = Vec::with_capacity(table.fts_indexes.len());
294        for index in &table.fts_indexes {
295            if !schema_object_names.insert(index.name.clone()) {
296                return Err(format!(
297                    "table {:?}: FTS projection {:?} conflicts with another schema object",
298                    table.name, index.name
299                ));
300            }
301            if index.columns.is_empty() || index.columns.len() > 32 {
302                return Err(format!(
303                    "table {:?}: FTS projection {:?} needs between 1 and 32 columns",
304                    table.name, index.name
305                ));
306            }
307            let mut seen_columns = BTreeSet::new();
308            for column_name in &index.columns {
309                if !seen_columns.insert(column_name) {
310                    return Err(format!(
311                        "table {:?}: FTS projection {:?} repeats column {:?}",
312                        table.name, index.name, column_name
313                    ));
314                }
315                let Some((_column_index, column)) = columns
316                    .iter()
317                    .enumerate()
318                    .find(|(_, candidate)| &candidate.name == column_name)
319                else {
320                    return Err(format!(
321                        "table {:?}: FTS projection {:?} names unknown column {:?}",
322                        table.name, index.name, column_name
323                    ));
324                };
325                if !matches!(column.ty, ColumnType::String) {
326                    return Err(format!(
327                        "table {:?}: FTS projection {:?} column {:?} must have string type",
328                        table.name, index.name, column_name
329                    ));
330                }
331            }
332            if !matches!(
333                index.tokenize.as_str(),
334                "unicode61"
335                    | "unicode61 remove_diacritics 0"
336                    | "unicode61 remove_diacritics 1"
337                    | "unicode61 remove_diacritics 2"
338                    | "porter unicode61"
339                    | "trigram"
340            ) {
341                return Err(format!(
342                    "table {:?}: FTS projection {:?} tokenizer {:?} is not allowlisted",
343                    table.name, index.name, index.tokenize
344                ));
345            }
346            fts_indexes.push(FtsIndexSchema {
347                name: index.name.clone(),
348                columns: index.columns.clone(),
349                tokenize: index.tokenize.clone(),
350            });
351        }
352        tables.push(TableSchema {
353            name: table.name.clone(),
354            columns,
355            wire_columns,
356            primary_key: table.primary_key.clone(),
357            pk_index,
358            scope_variables,
359            indexes,
360            fts_indexes,
361            encrypted_columns,
362        });
363    }
364    Ok(ClientSchema {
365        version: ir.version,
366        tables,
367    })
368}
369
370pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
371    let ir: SchemaIr =
372        serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
373    compile_schema(&ir)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use serde_json::json;
380
381    fn schema_with_fts(column: serde_json::Value, tokenize: &str) -> serde_json::Value {
382        json!({
383            "version": 1,
384            "tables": [{
385                "name": "docs",
386                "primaryKey": "id",
387                "columns": [
388                    { "name": "id", "type": "string", "nullable": false },
389                    { "name": "org_id", "type": "string", "nullable": false },
390                    column
391                ],
392                "scopes": [{ "pattern": "org:{org_id}" }],
393                "ftsIndexes": [{
394                    "name": "docs_fts",
395                    "columns": ["body"],
396                    "tokenize": tokenize
397                }]
398            }]
399        })
400    }
401
402    #[test]
403    fn compiles_an_allowlisted_string_fts_projection() {
404        let schema = parse_schema_json(&schema_with_fts(
405            json!({ "name": "body", "type": "string", "nullable": false }),
406            "unicode61 remove_diacritics 2",
407        ))
408        .expect("valid FTS schema");
409        assert_eq!(schema.tables[0].fts_indexes[0].name, "docs_fts");
410    }
411
412    #[test]
413    fn accepts_encrypted_declared_strings_and_rejects_other_fts_definitions() {
414        let encrypted = schema_with_fts(
415            json!({
416                "name": "body", "type": "bytes", "nullable": false,
417                "encrypted": true, "declaredType": "string"
418            }),
419            "unicode61",
420        );
421        parse_schema_json(&encrypted).expect("local plaintext FTS accepts encrypted strings");
422
423        let non_string = schema_with_fts(
424            json!({ "name": "body", "type": "integer", "nullable": false }),
425            "unicode61",
426        );
427        assert!(parse_schema_json(&non_string)
428            .expect_err("non-string FTS must fail")
429            .contains("must have string type"));
430
431        let tokenizer = schema_with_fts(
432            json!({ "name": "body", "type": "string", "nullable": false }),
433            "custom",
434        );
435        assert!(parse_schema_json(&tokenizer)
436            .expect_err("custom tokenizer must fail")
437            .contains("not allowlisted"));
438    }
439}