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            let open = scope.pattern.find('{').expect("validated scope pattern");
271            let prefix = scope.pattern[..open]
272                .strip_suffix(':')
273                .unwrap_or(&scope.pattern[..open])
274                .to_owned();
275            scope_variables.push(ScopeVariable {
276                variable,
277                column,
278                prefix,
279            });
280        }
281        let mut indexes = Vec::with_capacity(table.indexes.len());
282        for index in &table.indexes {
283            if !schema_object_names.insert(index.name.clone()) {
284                return Err(format!(
285                    "table {:?}: index {:?} conflicts with another schema object",
286                    table.name, index.name
287                ));
288            }
289            for col in &index.columns {
290                if !columns.iter().any(|c| &c.name == col) {
291                    return Err(format!(
292                        "table {:?}: index {:?} names unknown column {col:?}",
293                        table.name, index.name
294                    ));
295                }
296            }
297            indexes.push(IndexSchema {
298                name: index.name.clone(),
299                columns: index.columns.clone(),
300                unique: index.unique,
301            });
302        }
303        let mut fts_indexes = Vec::with_capacity(table.fts_indexes.len());
304        for index in &table.fts_indexes {
305            if !schema_object_names.insert(index.name.clone()) {
306                return Err(format!(
307                    "table {:?}: FTS projection {:?} conflicts with another schema object",
308                    table.name, index.name
309                ));
310            }
311            if index.columns.is_empty() || index.columns.len() > 32 {
312                return Err(format!(
313                    "table {:?}: FTS projection {:?} needs between 1 and 32 columns",
314                    table.name, index.name
315                ));
316            }
317            let mut seen_columns = BTreeSet::new();
318            for column_name in &index.columns {
319                if !seen_columns.insert(column_name) {
320                    return Err(format!(
321                        "table {:?}: FTS projection {:?} repeats column {:?}",
322                        table.name, index.name, column_name
323                    ));
324                }
325                let Some((_column_index, column)) = columns
326                    .iter()
327                    .enumerate()
328                    .find(|(_, candidate)| &candidate.name == column_name)
329                else {
330                    return Err(format!(
331                        "table {:?}: FTS projection {:?} names unknown column {:?}",
332                        table.name, index.name, column_name
333                    ));
334                };
335                if !matches!(column.ty, ColumnType::String) {
336                    return Err(format!(
337                        "table {:?}: FTS projection {:?} column {:?} must have string type",
338                        table.name, index.name, column_name
339                    ));
340                }
341            }
342            if !matches!(
343                index.tokenize.as_str(),
344                "unicode61"
345                    | "unicode61 remove_diacritics 0"
346                    | "unicode61 remove_diacritics 1"
347                    | "unicode61 remove_diacritics 2"
348                    | "porter unicode61"
349                    | "trigram"
350            ) {
351                return Err(format!(
352                    "table {:?}: FTS projection {:?} tokenizer {:?} is not allowlisted",
353                    table.name, index.name, index.tokenize
354                ));
355            }
356            fts_indexes.push(FtsIndexSchema {
357                name: index.name.clone(),
358                columns: index.columns.clone(),
359                tokenize: index.tokenize.clone(),
360            });
361        }
362        tables.push(TableSchema {
363            name: table.name.clone(),
364            columns,
365            wire_columns,
366            primary_key: table.primary_key.clone(),
367            pk_index,
368            scope_variables,
369            indexes,
370            fts_indexes,
371            encrypted_columns,
372        });
373    }
374    Ok(ClientSchema {
375        version: ir.version,
376        tables,
377    })
378}
379
380pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
381    // §7.4.2: validate the requested version before the typed IR decode, so a
382    // missing, non-integer, or out-of-i32-range version is a request error
383    // (matching the TS core) instead of a generic parse failure. `SchemaIr`
384    // accepts the server IR spelling `schemaVersion` as an alias; giving both
385    // spellings stays a duplicate-field error from the decode below.
386    let version = json
387        .get("version")
388        .or_else(|| json.get("schemaVersion"))
389        .and_then(serde_json::Value::as_i64);
390    if !version.is_some_and(|version| (1..=i32::MAX as i64).contains(&version)) {
391        return Err(
392            "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
393                .to_owned(),
394        );
395    }
396    let ir: SchemaIr =
397        serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
398    compile_schema(&ir)
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use serde_json::json;
405
406    fn schema_with_fts(column: serde_json::Value, tokenize: &str) -> serde_json::Value {
407        json!({
408            "version": 1,
409            "tables": [{
410                "name": "docs",
411                "primaryKey": "id",
412                "columns": [
413                    { "name": "id", "type": "string", "nullable": false },
414                    { "name": "org_id", "type": "string", "nullable": false },
415                    column
416                ],
417                "scopes": [{ "pattern": "org:{org_id}" }],
418                "ftsIndexes": [{
419                    "name": "docs_fts",
420                    "columns": ["body"],
421                    "tokenize": tokenize
422                }]
423            }]
424        })
425    }
426
427    #[test]
428    fn accepts_the_schema_version_alias_and_refuses_conflicting_spellings() {
429        let schema = json!({
430            "schemaVersion": 2,
431            "tables": [{
432                "name": "tasks",
433                "primaryKey": "id",
434                "columns": [{ "name": "id", "type": "string", "nullable": false }],
435                "scopes": []
436            }]
437        });
438        let compiled = parse_schema_json(&schema).expect("the server IR alias compiles");
439        assert_eq!(compiled.version, 2);
440        // Both spellings name the same field: the duplicate is a decode error,
441        // never a silent choice between the two values.
442        let mut both = schema.clone();
443        both["version"] = json!(3);
444        let error = parse_schema_json(&both).expect_err("duplicate version spellings");
445        assert!(error.starts_with("bad schema IR:"), "{error}");
446        // The alias is subject to the same range check.
447        let mut out_of_range = schema.clone();
448        out_of_range["schemaVersion"] = json!(0);
449        let error = parse_schema_json(&out_of_range).expect_err("out-of-range alias");
450        assert!(error.starts_with("sync.invalid_request:"), "{error}");
451    }
452
453    #[test]
454    fn compiles_an_allowlisted_string_fts_projection() {
455        let schema = parse_schema_json(&schema_with_fts(
456            json!({ "name": "body", "type": "string", "nullable": false }),
457            "unicode61 remove_diacritics 2",
458        ))
459        .expect("valid FTS schema");
460        assert_eq!(schema.tables[0].fts_indexes[0].name, "docs_fts");
461    }
462
463    #[test]
464    fn accepts_encrypted_declared_strings_and_rejects_other_fts_definitions() {
465        let encrypted = schema_with_fts(
466            json!({
467                "name": "body", "type": "bytes", "nullable": false,
468                "encrypted": true, "declaredType": "string"
469            }),
470            "unicode61",
471        );
472        parse_schema_json(&encrypted).expect("local plaintext FTS accepts encrypted strings");
473
474        let non_string = schema_with_fts(
475            json!({ "name": "body", "type": "integer", "nullable": false }),
476            "unicode61",
477        );
478        assert!(parse_schema_json(&non_string)
479            .expect_err("non-string FTS must fail")
480            .contains("must have string type"));
481
482        let tokenizer = schema_with_fts(
483            json!({ "name": "body", "type": "string", "nullable": false }),
484            "custom",
485        );
486        assert!(parse_schema_json(&tokenizer)
487            .expect_err("custom tokenizer must fail")
488            .contains("not allowlisted"));
489    }
490}