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    // §7.4.2: the persisted marker is a canonical positive i32 and a replica
190    // refuses any requested version outside that range before it touches
191    // storage, so a client can never persist a marker it would reject on the
192    // next open.
193    if ir.version < 1 {
194        return Err(
195            "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
196                .to_owned(),
197        );
198    }
199    let mut tables = Vec::with_capacity(ir.tables.len());
200    let mut schema_object_names = BTreeSet::new();
201    for table in &ir.tables {
202        if !schema_object_names.insert(table.name.clone()) {
203            return Err(format!("duplicate table or schema object {:?}", table.name));
204        }
205    }
206    for table in &ir.tables {
207        // wire_columns carry the on-the-wire type (bytes for encrypted); the
208        // local `columns` carry the declared type so the local mirror is
209        // plaintext (§5.11).
210        let mut wire_columns = Vec::with_capacity(table.columns.len());
211        let mut columns = Vec::with_capacity(table.columns.len());
212        let mut encrypted_columns = Vec::new();
213        for (index, column) in table.columns.iter().enumerate() {
214            let wire_ty = parse_column_type(&column.column_type)?;
215            wire_columns.push(Column {
216                name: column.name.clone(),
217                ty: wire_ty,
218                nullable: column.nullable,
219            });
220            if column.encrypted {
221                let declared_name = column.declared_type.clone().ok_or_else(|| {
222                    format!(
223                        "table {:?}: encrypted column {:?} has no declaredType (§5.11)",
224                        table.name, column.name
225                    )
226                })?;
227                let declared_ty = parse_column_type(&declared_name)?;
228                columns.push(Column {
229                    name: column.name.clone(),
230                    ty: declared_ty,
231                    nullable: column.nullable,
232                });
233                encrypted_columns.push(EncryptedColumn {
234                    index,
235                    declared_type: declared_name,
236                });
237            } else {
238                columns.push(Column {
239                    name: column.name.clone(),
240                    ty: wire_ty,
241                    nullable: column.nullable,
242                });
243            }
244        }
245        let pk_index = columns
246            .iter()
247            .position(|c| c.name == table.primary_key)
248            .ok_or_else(|| {
249                format!(
250                    "table {:?}: primary key {:?} is not a column",
251                    table.name, table.primary_key
252                )
253            })?;
254        // §2.4 primary-key eligibility: a primary key MUST render to a
255        // `rowId` string every implementation produces identically and local
256        // storage resolves without deferring to a SQLite build.
257        if !matches!(
258            columns[pk_index].ty,
259            ColumnType::String | ColumnType::Integer | ColumnType::Boolean | ColumnType::Json
260        ) {
261            return Err(format!(
262                "table {:?}: primary key {:?} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json",
263                table.name, table.primary_key
264            ));
265        }
266        let mut scope_variables = Vec::with_capacity(table.scopes.len());
267        for scope in &table.scopes {
268            let variable = parse_pattern_variable(&scope.pattern)?;
269            let column = scope.column.clone().unwrap_or_else(|| variable.clone());
270            if !columns.iter().any(|c| {
271                c.name == column && matches!(c.ty, ColumnType::String | ColumnType::Integer)
272            }) {
273                return Err(format!(
274                    "table {:?}: scope column {column:?} must be a string or integer column (§3.1)",
275                    table.name
276                ));
277            }
278            let open = scope.pattern.find('{').expect("validated scope pattern");
279            let prefix = scope.pattern[..open]
280                .strip_suffix(':')
281                .unwrap_or(&scope.pattern[..open])
282                .to_owned();
283            scope_variables.push(ScopeVariable {
284                variable,
285                column,
286                prefix,
287            });
288        }
289        let mut indexes = Vec::with_capacity(table.indexes.len());
290        for index in &table.indexes {
291            if !schema_object_names.insert(index.name.clone()) {
292                return Err(format!(
293                    "table {:?}: index {:?} conflicts with another schema object",
294                    table.name, index.name
295                ));
296            }
297            for col in &index.columns {
298                if !columns.iter().any(|c| &c.name == col) {
299                    return Err(format!(
300                        "table {:?}: index {:?} names unknown column {col:?}",
301                        table.name, index.name
302                    ));
303                }
304            }
305            indexes.push(IndexSchema {
306                name: index.name.clone(),
307                columns: index.columns.clone(),
308                unique: index.unique,
309            });
310        }
311        let mut fts_indexes = Vec::with_capacity(table.fts_indexes.len());
312        for index in &table.fts_indexes {
313            if !schema_object_names.insert(index.name.clone()) {
314                return Err(format!(
315                    "table {:?}: FTS projection {:?} conflicts with another schema object",
316                    table.name, index.name
317                ));
318            }
319            if index.columns.is_empty() || index.columns.len() > 32 {
320                return Err(format!(
321                    "table {:?}: FTS projection {:?} needs between 1 and 32 columns",
322                    table.name, index.name
323                ));
324            }
325            let mut seen_columns = BTreeSet::new();
326            for column_name in &index.columns {
327                if !seen_columns.insert(column_name) {
328                    return Err(format!(
329                        "table {:?}: FTS projection {:?} repeats column {:?}",
330                        table.name, index.name, column_name
331                    ));
332                }
333                let Some((_column_index, column)) = columns
334                    .iter()
335                    .enumerate()
336                    .find(|(_, candidate)| &candidate.name == column_name)
337                else {
338                    return Err(format!(
339                        "table {:?}: FTS projection {:?} names unknown column {:?}",
340                        table.name, index.name, column_name
341                    ));
342                };
343                if !matches!(column.ty, ColumnType::String) {
344                    return Err(format!(
345                        "table {:?}: FTS projection {:?} column {:?} must have string type",
346                        table.name, index.name, column_name
347                    ));
348                }
349            }
350            if !matches!(
351                index.tokenize.as_str(),
352                "unicode61"
353                    | "unicode61 remove_diacritics 0"
354                    | "unicode61 remove_diacritics 1"
355                    | "unicode61 remove_diacritics 2"
356                    | "porter unicode61"
357                    | "trigram"
358            ) {
359                return Err(format!(
360                    "table {:?}: FTS projection {:?} tokenizer {:?} is not allowlisted",
361                    table.name, index.name, index.tokenize
362                ));
363            }
364            fts_indexes.push(FtsIndexSchema {
365                name: index.name.clone(),
366                columns: index.columns.clone(),
367                tokenize: index.tokenize.clone(),
368            });
369        }
370        tables.push(TableSchema {
371            name: table.name.clone(),
372            columns,
373            wire_columns,
374            primary_key: table.primary_key.clone(),
375            pk_index,
376            scope_variables,
377            indexes,
378            fts_indexes,
379            encrypted_columns,
380        });
381    }
382    Ok(ClientSchema {
383        version: ir.version,
384        tables,
385    })
386}
387
388pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
389    // §7.4.2: validate the requested version before the typed IR decode, so a
390    // missing, non-integer, or out-of-i32-range version is a request error
391    // (matching the TS core) instead of a generic parse failure. `SchemaIr`
392    // accepts the server IR spelling `schemaVersion` as an alias; giving both
393    // spellings stays a duplicate-field error from the decode below.
394    let version = json
395        .get("version")
396        .or_else(|| json.get("schemaVersion"))
397        .and_then(serde_json::Value::as_i64);
398    if !version.is_some_and(|version| (1..=i32::MAX as i64).contains(&version)) {
399        return Err(
400            "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
401                .to_owned(),
402        );
403    }
404    let ir: SchemaIr =
405        serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
406    compile_schema(&ir)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use serde_json::json;
413
414    fn schema_with_fts(column: serde_json::Value, tokenize: &str) -> serde_json::Value {
415        json!({
416            "version": 1,
417            "tables": [{
418                "name": "docs",
419                "primaryKey": "id",
420                "columns": [
421                    { "name": "id", "type": "string", "nullable": false },
422                    { "name": "org_id", "type": "string", "nullable": false },
423                    column
424                ],
425                "scopes": [{ "pattern": "org:{org_id}" }],
426                "ftsIndexes": [{
427                    "name": "docs_fts",
428                    "columns": ["body"],
429                    "tokenize": tokenize
430                }]
431            }]
432        })
433    }
434
435    #[test]
436    fn accepts_the_schema_version_alias_and_refuses_conflicting_spellings() {
437        let schema = json!({
438            "schemaVersion": 2,
439            "tables": [{
440                "name": "tasks",
441                "primaryKey": "id",
442                "columns": [{ "name": "id", "type": "string", "nullable": false }],
443                "scopes": []
444            }]
445        });
446        let compiled = parse_schema_json(&schema).expect("the server IR alias compiles");
447        assert_eq!(compiled.version, 2);
448        // Both spellings name the same field: the duplicate is a decode error,
449        // never a silent choice between the two values.
450        let mut both = schema.clone();
451        both["version"] = json!(3);
452        let error = parse_schema_json(&both).expect_err("duplicate version spellings");
453        assert!(error.starts_with("bad schema IR:"), "{error}");
454        // The alias is subject to the same range check.
455        let mut out_of_range = schema.clone();
456        out_of_range["schemaVersion"] = json!(0);
457        let error = parse_schema_json(&out_of_range).expect_err("out-of-range alias");
458        assert!(error.starts_with("sync.invalid_request:"), "{error}");
459    }
460
461    #[test]
462    fn compiles_an_allowlisted_string_fts_projection() {
463        let schema = parse_schema_json(&schema_with_fts(
464            json!({ "name": "body", "type": "string", "nullable": false }),
465            "unicode61 remove_diacritics 2",
466        ))
467        .expect("valid FTS schema");
468        assert_eq!(schema.tables[0].fts_indexes[0].name, "docs_fts");
469    }
470
471    #[test]
472    fn accepts_encrypted_declared_strings_and_rejects_other_fts_definitions() {
473        let encrypted = schema_with_fts(
474            json!({
475                "name": "body", "type": "bytes", "nullable": false,
476                "encrypted": true, "declaredType": "string"
477            }),
478            "unicode61",
479        );
480        parse_schema_json(&encrypted).expect("local plaintext FTS accepts encrypted strings");
481
482        let non_string = schema_with_fts(
483            json!({ "name": "body", "type": "integer", "nullable": false }),
484            "unicode61",
485        );
486        assert!(parse_schema_json(&non_string)
487            .expect_err("non-string FTS must fail")
488            .contains("must have string type"));
489
490        let tokenizer = schema_with_fts(
491            json!({ "name": "body", "type": "string", "nullable": false }),
492            "custom",
493        );
494        assert!(parse_schema_json(&tokenizer)
495            .expect_err("custom tokenizer must fail")
496            .contains("not allowlisted"));
497    }
498}