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 serde::Deserialize;
7use ssp2::segment::{Column, ColumnType};
8
9#[derive(Debug, Clone, Deserialize)]
10pub struct SchemaIr {
11    pub version: i32,
12    pub tables: Vec<TableIr>,
13}
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct TableIr {
18    pub name: String,
19    pub columns: Vec<ColumnIr>,
20    pub primary_key: String,
21    pub scopes: Vec<ScopePatternIr>,
22    /// Local secondary indexes; absent in the IR for index-free tables
23    /// (typegen omits the key), so default to empty on deserialize.
24    #[serde(default)]
25    pub indexes: Vec<IndexIr>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ColumnIr {
30    pub name: String,
31    #[serde(rename = "type")]
32    pub column_type: String,
33    pub nullable: bool,
34    /// §5.11: this column is encrypted end-to-end. When set, `column_type` is
35    /// `bytes` (the wire type) and `declared_type` is the app type.
36    #[serde(default)]
37    pub encrypted: bool,
38    /// §5.11: the app-side type of an encrypted column (`declaredType` in JSON).
39    #[serde(default, rename = "declaredType")]
40    pub declared_type: Option<String>,
41}
42
43/// One local secondary index (the CREATE INDEX migration subset, §2.4).
44#[derive(Debug, Clone, Deserialize)]
45pub struct IndexIr {
46    pub name: String,
47    pub columns: Vec<String>,
48    pub unique: bool,
49}
50
51/// One compiled local secondary index — created on the base + visible tables.
52#[derive(Debug, Clone)]
53pub struct IndexSchema {
54    pub name: String,
55    pub columns: Vec<String>,
56    pub unique: bool,
57}
58
59#[derive(Debug, Clone, Deserialize)]
60pub struct ScopePatternIr {
61    pub pattern: String,
62    #[serde(default)]
63    pub column: Option<String>,
64}
65
66/// One declared scope variable, mapped to its local column (§3.1, §3.3).
67#[derive(Debug, Clone)]
68pub struct ScopeVariable {
69    pub variable: String,
70    pub column: String,
71}
72
73/// §5.11: one encrypted column — its positional index and its app-side
74/// declared type name (`string`, `integer`, …). The wire `Column.ty` is
75/// `bytes`; this carries the pre-flip type for the encrypt/decrypt seam.
76#[derive(Debug, Clone)]
77pub struct EncryptedColumn {
78    pub index: usize,
79    pub declared_type: String,
80}
81
82#[derive(Debug, Clone)]
83pub struct TableSchema {
84    pub name: String,
85    /// LOCAL columns (declaration order). For an encrypted column (§5.11) the
86    /// `ty` is the DECLARED type — the local mirror stores plaintext, so
87    /// read-back and DDL use the real type. Identical to `wire_columns` when
88    /// the table has no encrypted columns.
89    pub columns: Vec<Column>,
90    /// WIRE columns (§2.4 positional codec): identical to `columns` except an
91    /// encrypted column's `ty` is `bytes` (the ciphertext envelope rides the
92    /// bytes machinery). Used by `encode_row_json`/`decode_row_bytes` and the
93    /// §5.2 segment column-table validation.
94    pub wire_columns: Vec<Column>,
95    pub primary_key: String,
96    pub pk_index: usize,
97    pub scope_variables: Vec<ScopeVariable>,
98    /// Local secondary indexes, in declaration order (empty when none).
99    pub indexes: Vec<IndexSchema>,
100    /// §5.11: encrypted columns (index + declared type). Empty ⇒ no E2EE.
101    pub encrypted_columns: Vec<EncryptedColumn>,
102}
103
104impl TableSchema {
105    /// §5.11: true when any column is encrypted (skip the seam entirely else).
106    pub fn has_encrypted_columns(&self) -> bool {
107        !self.encrypted_columns.is_empty()
108    }
109}
110
111impl TableSchema {
112    /// §3.3 purge mapping: the generated local scope column for a variable,
113    /// or `None` (the fail-closed case).
114    pub fn scope_column(&self, variable: &str) -> Option<&str> {
115        self.scope_variables
116            .iter()
117            .find(|s| s.variable == variable)
118            .map(|s| s.column.as_str())
119    }
120}
121
122#[derive(Debug, Clone)]
123pub struct ClientSchema {
124    pub version: i32,
125    pub tables: Vec<TableSchema>,
126}
127
128impl ClientSchema {
129    pub fn table(&self, name: &str) -> Option<&TableSchema> {
130        self.tables.iter().find(|t| t.name == name)
131    }
132}
133
134fn parse_column_type(name: &str) -> Result<ColumnType, String> {
135    match name {
136        "string" => Ok(ColumnType::String),
137        "integer" => Ok(ColumnType::Integer),
138        "float" => Ok(ColumnType::Float),
139        "boolean" => Ok(ColumnType::Boolean),
140        "json" => Ok(ColumnType::Json),
141        "bytes" => Ok(ColumnType::Bytes),
142        "blob_ref" => Ok(ColumnType::BlobRef),
143        "crdt" => Ok(ColumnType::Crdt),
144        other => Err(format!("unknown column type {other:?}")),
145    }
146}
147
148/// Extract `{variable}` from a `'prefix:{variable}'` pattern (§3.1).
149fn parse_pattern_variable(pattern: &str) -> Result<String, String> {
150    let open = pattern
151        .find('{')
152        .ok_or_else(|| format!("scope pattern {pattern:?} has no {{variable}}"))?;
153    let close = pattern
154        .rfind('}')
155        .filter(|end| *end > open)
156        .ok_or_else(|| format!("scope pattern {pattern:?} has no closing brace"))?;
157    let variable = &pattern[open + 1..close];
158    if variable.is_empty() {
159        return Err(format!("scope pattern {pattern:?} has an empty variable"));
160    }
161    Ok(variable.to_owned())
162}
163
164pub fn compile_schema(ir: &SchemaIr) -> Result<ClientSchema, String> {
165    let mut tables = Vec::with_capacity(ir.tables.len());
166    for table in &ir.tables {
167        // wire_columns carry the on-the-wire type (bytes for encrypted); the
168        // local `columns` carry the declared type so the local mirror is
169        // plaintext (§5.11).
170        let mut wire_columns = Vec::with_capacity(table.columns.len());
171        let mut columns = Vec::with_capacity(table.columns.len());
172        let mut encrypted_columns = Vec::new();
173        for (index, column) in table.columns.iter().enumerate() {
174            let wire_ty = parse_column_type(&column.column_type)?;
175            wire_columns.push(Column {
176                name: column.name.clone(),
177                ty: wire_ty,
178                nullable: column.nullable,
179            });
180            if column.encrypted {
181                let declared_name = column.declared_type.clone().ok_or_else(|| {
182                    format!(
183                        "table {:?}: encrypted column {:?} has no declaredType (§5.11)",
184                        table.name, column.name
185                    )
186                })?;
187                let declared_ty = parse_column_type(&declared_name)?;
188                columns.push(Column {
189                    name: column.name.clone(),
190                    ty: declared_ty,
191                    nullable: column.nullable,
192                });
193                encrypted_columns.push(EncryptedColumn {
194                    index,
195                    declared_type: declared_name,
196                });
197            } else {
198                columns.push(Column {
199                    name: column.name.clone(),
200                    ty: wire_ty,
201                    nullable: column.nullable,
202                });
203            }
204        }
205        let pk_index = columns
206            .iter()
207            .position(|c| c.name == table.primary_key)
208            .ok_or_else(|| {
209                format!(
210                    "table {:?}: primary key {:?} is not a column",
211                    table.name, table.primary_key
212                )
213            })?;
214        let mut scope_variables = Vec::with_capacity(table.scopes.len());
215        for scope in &table.scopes {
216            let variable = parse_pattern_variable(&scope.pattern)?;
217            let column = scope.column.clone().unwrap_or_else(|| variable.clone());
218            scope_variables.push(ScopeVariable { variable, column });
219        }
220        let mut indexes = Vec::with_capacity(table.indexes.len());
221        for index in &table.indexes {
222            for col in &index.columns {
223                if !columns.iter().any(|c| &c.name == col) {
224                    return Err(format!(
225                        "table {:?}: index {:?} names unknown column {col:?}",
226                        table.name, index.name
227                    ));
228                }
229            }
230            indexes.push(IndexSchema {
231                name: index.name.clone(),
232                columns: index.columns.clone(),
233                unique: index.unique,
234            });
235        }
236        tables.push(TableSchema {
237            name: table.name.clone(),
238            columns,
239            wire_columns,
240            primary_key: table.primary_key.clone(),
241            pk_index,
242            scope_variables,
243            indexes,
244            encrypted_columns,
245        });
246    }
247    Ok(ClientSchema {
248        version: ir.version,
249        tables,
250    })
251}
252
253pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
254    let ir: SchemaIr =
255        serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
256    compile_schema(&ir)
257}