Skip to main content

syncular_client/
values.rs

1//! Value conversions at the client's edges: driver JSON (`{"$bytes": hex}`
2//! convention) ↔ the §2.4 row-codec values ↔ SQLite storage, plus the §11.2
3//! canonical scope JSON (contractual across implementations).
4
5use std::collections::HashMap;
6
7use serde_json::{Map, Value};
8use ssp2::primitives::{RawJson, Reader, Writer};
9use ssp2::segment::{decode_row, encode_row, Column, ColumnType, ColumnValue, Row};
10use ssp2::util::utf16_lt;
11
12use crate::schema::TableSchema;
13
14/// §5.11 client-side encryption keys (`keyId → 32-byte key`) plus optional
15/// key selection. Empty ⇒ E2EE off. `key_id_for` defaults to per-table
16/// (`keyId = table`). Always present on the client; the crypto is compiled
17/// only under the `e2ee` feature.
18#[derive(Debug, Clone, Default)]
19pub struct EncryptionConfig {
20    pub keys: HashMap<String, Vec<u8>>,
21    /// Portable per-table selector: the named non-encrypted string column in
22    /// each plaintext row contains the key id used for new envelopes.
23    pub key_id_columns: HashMap<String, String>,
24}
25
26impl EncryptionConfig {
27    pub fn is_empty(&self) -> bool {
28        self.keys.is_empty()
29    }
30
31    /// §5.11 portable key selection: configured row column, then table name.
32    pub fn key_id_for(&self, table: &TableSchema, row: &Row) -> Result<String, String> {
33        let Some(column_name) = self.key_id_columns.get(&table.name) else {
34            return Ok(table.name.clone());
35        };
36        let Some(index) = table
37            .columns
38            .iter()
39            .position(|column| &column.name == column_name)
40        else {
41            return Err(format!(
42                "client.decrypt_failed: encryption key-id column {column_name:?} is not present on table {:?}",
43                table.name
44            ));
45        };
46        if table
47            .encrypted_columns
48            .iter()
49            .any(|column| column.index == index)
50        {
51            return Err(format!(
52                "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must not be encrypted",
53                table.name
54            ));
55        }
56        match row.get(index).and_then(|value| value.as_ref()) {
57            Some(ColumnValue::String(key_id)) if !key_id.is_empty() => Ok(key_id.clone()),
58            _ => Err(format!(
59                "client.decrypt_failed: encryption key-id column {column_name:?} on table {:?} must contain a non-empty string",
60                table.name
61            )),
62        }
63    }
64}
65
66/// The pinned §12 snake→camel conversion (DESIGN-queries.md §5) — the Rust
67/// copy of the typegen/TS-client algorithm, kept in lockstep by shared test
68/// vectors. Leading/trailing `_` runs are preserved; middle segments split
69/// on `_` (doubled underscores drop); no acronym awareness.
70pub fn snake_to_camel(name: &str) -> String {
71    let is_mappable = {
72        let bare = name.trim_start_matches('_');
73        !bare.is_empty()
74            && bare.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
75            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
76    };
77    if !is_mappable {
78        return name.to_owned();
79    }
80    let lead_len = name.len() - name.trim_start_matches('_').len();
81    let (lead, bare) = name.split_at(lead_len);
82    let trail_len = bare.len() - bare.trim_end_matches('_').len();
83    let (middle, trail) = bare.split_at(bare.len() - trail_len);
84    let mut segments = middle.split('_').filter(|s| !s.is_empty());
85    let Some(first) = segments.next() else {
86        return name.to_owned();
87    };
88    let mut out = String::with_capacity(name.len());
89    out.push_str(lead);
90    out.push_str(first);
91    for segment in segments {
92        let mut chars = segment.chars();
93        if let Some(head) = chars.next() {
94            out.push(head.to_ascii_uppercase());
95            out.push_str(chars.as_str());
96        }
97    }
98    out.push_str(trail);
99    out
100}
101
102/// §5 mutate key normalization: accept BOTH casings for upsert value keys —
103/// the SQL-truth snake_case and the generated row types' camelCase. A camel
104/// key renames to its column's SQL name when that is unambiguous (the alias
105/// equals no other column's exact name, and no two columns share it). A
106/// column given in both casings is an error. Unknown keys fail loud, matching
107/// the TypeScript core; silently dropping an app field would corrupt a
108/// full-row mutation while appearing successful.
109pub fn normalize_values_casing(
110    table: &TableSchema,
111    mut values: Map<String, Value>,
112) -> Result<Map<String, Value>, String> {
113    for column in &table.columns {
114        let camel = snake_to_camel(&column.name);
115        if camel == column.name {
116            continue;
117        }
118        // Exact names always win; an alias colliding with another column's
119        // real name (or with another column's alias) is not an alias.
120        if table.columns.iter().any(|c| c.name == camel) {
121            continue;
122        }
123        if table
124            .columns
125            .iter()
126            .filter(|c| snake_to_camel(&c.name) == camel)
127            .count()
128            > 1
129        {
130            continue;
131        }
132        if let Some(value) = values.remove(&camel) {
133            if values.contains_key(&column.name) {
134                return Err(format!(
135                    "table {:?}: column {:?} appears twice in mutation values (as both snake_case and camelCase) — pass it once",
136                    table.name, column.name
137                ));
138            }
139            values.insert(column.name.clone(), value);
140        }
141    }
142    for key in values.keys() {
143        if !table.columns.iter().any(|column| column.name == *key) {
144            if key.starts_with("_sync_") {
145                return Err(format!(
146                    "table {:?}: {:?} is an internal sync column and cannot appear in mutation values",
147                    table.name, key
148                ));
149            }
150            return Err(format!(
151                "table {:?}: unknown column {:?} in mutation values (snake_case and camelCase keys are accepted)",
152                table.name, key
153            ));
154        }
155    }
156    Ok(values)
157}
158
159pub fn bytes_to_hex(bytes: &[u8]) -> String {
160    let mut out = String::with_capacity(bytes.len() * 2);
161    for b in bytes {
162        out.push_str(&format!("{b:02x}"));
163    }
164    out
165}
166
167pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
168    if !hex.len().is_multiple_of(2) {
169        return Err("odd-length hex string".to_owned());
170    }
171    let mut out = Vec::with_capacity(hex.len() / 2);
172    let bytes = hex.as_bytes();
173    for pair in bytes.chunks(2) {
174        let s = std::str::from_utf8(pair).map_err(|_| "non-ASCII hex".to_owned())?;
175        out.push(u8::from_str_radix(s, 16).map_err(|e| format!("bad hex: {e}"))?);
176    }
177    Ok(out)
178}
179
180/// Driver JSON value → row-codec value for one column. `null`/absent maps to
181/// NULL; bytes travel as `{"$bytes": "<hex>"}`.
182pub fn json_to_column_value(
183    column: &Column,
184    value: Option<&Value>,
185) -> Result<Option<ColumnValue>, String> {
186    let value = match value {
187        None | Some(Value::Null) => return Ok(None),
188        Some(v) => v,
189    };
190    let fail = |expected: &str| {
191        Err(format!(
192            "column {:?}: expected {expected}, got {value}",
193            column.name
194        ))
195    };
196    match column.ty {
197        ColumnType::String => match value.as_str() {
198            Some(s) => Ok(Some(ColumnValue::String(s.to_owned()))),
199            None => fail("a string"),
200        },
201        ColumnType::Integer => match value.as_i64() {
202            Some(i) => Ok(Some(ColumnValue::Integer(i))),
203            None => fail("an integer"),
204        },
205        ColumnType::Float => match value.as_f64() {
206            Some(f) => Ok(Some(ColumnValue::Float(f))),
207            None => fail("a number"),
208        },
209        ColumnType::Boolean => match value.as_bool() {
210            Some(b) => Ok(Some(ColumnValue::Boolean(b))),
211            None => fail("a boolean"),
212        },
213        // `json` columns stay raw strings at the driver boundary (§2.4).
214        ColumnType::Json => match value.as_str() {
215            Some(s) => Ok(Some(ColumnValue::Json(RawJson(s.to_owned())))),
216            None => fail("a raw JSON string"),
217        },
218        // `blob_ref` (tag 7) also stays a raw string at the boundary (§5.9.1).
219        ColumnType::BlobRef => match value.as_str() {
220            Some(s) => Ok(Some(ColumnValue::BlobRef(RawJson(s.to_owned())))),
221            None => fail("a raw BlobRef JSON string"),
222        },
223        ColumnType::Bytes => match value.get("$bytes").and_then(Value::as_str) {
224            Some(hex) => Ok(Some(ColumnValue::Bytes(hex_to_bytes(hex)?))),
225            None => fail("a {\"$bytes\": hex} object"),
226        },
227        // §5.10: crdt bytes cross the boundary as {"$bytes": hex}, like bytes.
228        ColumnType::Crdt => match value.get("$bytes").and_then(Value::as_str) {
229            Some(hex) => Ok(Some(ColumnValue::Crdt(hex_to_bytes(hex)?))),
230            None => fail("a {\"$bytes\": hex} object"),
231        },
232    }
233}
234
235/// Row-codec value → driver JSON value.
236pub fn column_value_to_json(value: &Option<ColumnValue>) -> Value {
237    match value {
238        None => Value::Null,
239        Some(ColumnValue::String(s)) => Value::from(s.clone()),
240        Some(ColumnValue::Integer(i)) => Value::from(*i),
241        Some(ColumnValue::Float(f)) => {
242            serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
243        }
244        Some(ColumnValue::Boolean(b)) => Value::from(*b),
245        Some(ColumnValue::Json(raw)) => Value::from(raw.0.clone()),
246        Some(ColumnValue::BlobRef(raw)) => Value::from(raw.0.clone()),
247        Some(ColumnValue::Bytes(bytes)) => {
248            let mut map = Map::new();
249            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
250            Value::Object(map)
251        }
252        Some(ColumnValue::Crdt(bytes)) => {
253            let mut map = Map::new();
254            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(bytes)));
255            Value::Object(map)
256        }
257    }
258}
259
260/// Encode one full row (driver JSON values keyed by column name) with the
261/// generated row codec (§2.4, §6.1). §5.11: encrypted columns are encrypted
262/// here — the encode-at-send seam — before the codec serializes them as
263/// ciphertext-envelope `bytes` using `wire_columns`.
264pub fn encode_row_json(
265    table: &TableSchema,
266    row_id: &str,
267    values: &Map<String, Value>,
268    encryption: &EncryptionConfig,
269) -> Result<Vec<u8>, String> {
270    // Build the row from the LOCAL (declared-type) columns.
271    let mut row: Row = Vec::with_capacity(table.columns.len());
272    for column in &table.columns {
273        let value = json_to_column_value(column, values.get(&column.name))?;
274        if value.is_none() && !column.nullable {
275            return Err(format!(
276                "table {:?}: column {:?} is not nullable (§6.1 full-row payloads)",
277                table.name, column.name
278            ));
279        }
280        row.push(value);
281    }
282    if table.has_encrypted_columns() {
283        encrypt_row(table, row_id, &mut row, encryption)?;
284    }
285    // Serialize with the WIRE columns (encrypted columns are `bytes`).
286    let mut w = Writer::new();
287    encode_row(&mut w, &table.wire_columns, &row);
288    Ok(w.into_bytes())
289}
290
291/// Decode one row-codec payload; trailing bytes are a decode error. §5.11:
292/// encrypted columns are decrypted here — the apply seam — back to their
293/// declared-type plaintext value for the local mirror.
294pub fn decode_row_bytes(
295    table: &TableSchema,
296    payload: &[u8],
297    encryption: &EncryptionConfig,
298) -> Result<Row, String> {
299    let mut r = Reader::new(payload);
300    // Decode with the WIRE columns (encrypted columns arrive as `bytes`).
301    let mut row = decode_row(&mut r, &table.wire_columns).map_err(|e| e.to_string())?;
302    if !r.is_empty() {
303        return Err("row payload has trailing bytes".to_owned());
304    }
305    if table.has_encrypted_columns() {
306        decrypt_row(table, &mut row, encryption)?;
307    }
308    Ok(row)
309}
310
311/// §5.11: decrypt the encrypted columns of an already-decoded segment row
312/// (rows segments decode via their own column table, so decryption is a
313/// post-decode pass over the positional values).
314pub fn decrypt_segment_row(
315    table: &TableSchema,
316    row: &mut Row,
317    encryption: &EncryptionConfig,
318) -> Result<(), String> {
319    decrypt_row(table, row, encryption)
320}
321
322// -- §5.11 encrypt/decrypt seam (feature-gated) ------------------------------
323
324#[cfg(feature = "e2ee")]
325fn encrypt_row(
326    table: &TableSchema,
327    _row_id: &str,
328    row: &mut Row,
329    encryption: &EncryptionConfig,
330) -> Result<(), String> {
331    use rand_core::RngCore;
332    use ssp2::crypto::{encrypt_value, NONCE_LENGTH};
333    for enc in &table.encrypted_columns {
334        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
335            continue; // NULL stays NULL (§5.11)
336        };
337        let plain = column_value_to_plain(value)?;
338        let key_id = encryption.key_id_for(table, row)?;
339        let key = encryption
340            .keys
341            .get(&key_id)
342            .ok_or_else(|| format!("client.decrypt_failed: no key for keyId {key_id:?}"))?;
343        let mut nonce = [0u8; NONCE_LENGTH];
344        rand_core::OsRng.fill_bytes(&mut nonce);
345        let envelope = encrypt_value(&plain, &key_id, key, nonce)?;
346        row[enc.index] = Some(ColumnValue::Bytes(envelope));
347    }
348    Ok(())
349}
350
351#[cfg(feature = "e2ee")]
352fn decrypt_row(
353    table: &TableSchema,
354    row: &mut Row,
355    encryption: &EncryptionConfig,
356) -> Result<(), String> {
357    use ssp2::crypto::{decrypt_value, DeclaredType};
358    for enc in &table.encrypted_columns {
359        let Some(value) = row.get(enc.index).and_then(|v| v.as_ref()) else {
360            continue;
361        };
362        let ColumnValue::Bytes(envelope) = value else {
363            return Err(format!(
364                "client.decrypt_failed: encrypted column at index {} is not bytes",
365                enc.index
366            ));
367        };
368        let declared = DeclaredType::from_name(&enc.declared_type)
369            .ok_or_else(|| format!("unknown declaredType {:?}", enc.declared_type))?;
370        let keys = &encryption.keys;
371        let plain = decrypt_value(declared, envelope, |id| keys.get(id).cloned())
372            .map_err(|e| e.to_string())?;
373        row[enc.index] = Some(plain_to_column_value(plain));
374    }
375    Ok(())
376}
377
378/// Without the `e2ee` feature, a schema with encrypted columns is a
379/// misconfiguration — fail loud rather than ship plaintext.
380#[cfg(not(feature = "e2ee"))]
381fn encrypt_row(
382    table: &TableSchema,
383    _row_id: &str,
384    _row: &mut Row,
385    _encryption: &EncryptionConfig,
386) -> Result<(), String> {
387    Err(format!(
388        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
389        table.name
390    ))
391}
392
393#[cfg(not(feature = "e2ee"))]
394fn decrypt_row(
395    table: &TableSchema,
396    _row: &mut Row,
397    _encryption: &EncryptionConfig,
398) -> Result<(), String> {
399    Err(format!(
400        "table {:?} has encrypted columns but this build lacks the e2ee feature (§5.11)",
401        table.name
402    ))
403}
404
405#[cfg(feature = "e2ee")]
406fn column_value_to_plain(value: &ColumnValue) -> Result<ssp2::crypto::PlainValue, String> {
407    use ssp2::crypto::PlainValue;
408    Ok(match value {
409        ColumnValue::String(s) => PlainValue::String(s.clone()),
410        ColumnValue::Integer(i) => PlainValue::Integer(*i),
411        ColumnValue::Float(f) => PlainValue::Float(*f),
412        ColumnValue::Boolean(b) => PlainValue::Boolean(*b),
413        ColumnValue::Json(j) => PlainValue::Json(j.0.clone()),
414        ColumnValue::BlobRef(j) => PlainValue::BlobRef(j.0.clone()),
415        ColumnValue::Bytes(b) => PlainValue::Bytes(b.clone()),
416        ColumnValue::Crdt(_) => return Err("crdt columns cannot be encrypted (§5.11)".to_owned()),
417    })
418}
419
420#[cfg(feature = "e2ee")]
421fn plain_to_column_value(value: ssp2::crypto::PlainValue) -> ColumnValue {
422    use ssp2::crypto::PlainValue;
423    match value {
424        PlainValue::String(s) => ColumnValue::String(s),
425        PlainValue::Integer(i) => ColumnValue::Integer(i),
426        PlainValue::Float(f) => ColumnValue::Float(f),
427        PlainValue::Boolean(b) => ColumnValue::Boolean(b),
428        PlainValue::Json(s) => ColumnValue::Json(RawJson(s)),
429        PlainValue::BlobRef(s) => ColumnValue::BlobRef(RawJson(s)),
430        PlainValue::Bytes(b) => ColumnValue::Bytes(b),
431    }
432}
433
434/// Render a primary-key value as the wire `rowId` string.
435pub fn render_row_id(value: &Option<ColumnValue>) -> Result<String, String> {
436    match value {
437        Some(ColumnValue::String(s)) => Ok(s.clone()),
438        Some(ColumnValue::Integer(i)) => Ok(i.to_string()),
439        Some(ColumnValue::Float(f)) => Ok(f.to_string()),
440        Some(ColumnValue::Boolean(b)) => Ok(b.to_string()),
441        Some(ColumnValue::Json(raw)) => Ok(raw.0.clone()),
442        Some(ColumnValue::BlobRef(_)) => Err("blob_ref column cannot be a rowId".to_owned()),
443        Some(ColumnValue::Bytes(_)) => Err("bytes column cannot be a rowId".to_owned()),
444        Some(ColumnValue::Crdt(_)) => Err("crdt column cannot be a rowId".to_owned()),
445        None => Err("primary key value is missing".to_owned()),
446    }
447}
448
449/// Driver JSON value → wire `rowId` string (for optimistic upserts).
450pub fn render_row_id_json(value: Option<&Value>) -> Result<String, String> {
451    match value {
452        Some(Value::String(s)) => Ok(s.clone()),
453        Some(Value::Number(n)) => Ok(n.to_string()),
454        Some(Value::Bool(b)) => Ok(b.to_string()),
455        _ => Err("primary key value is missing or not renderable".to_owned()),
456    }
457}
458
459fn sort_utf16(values: &mut [String]) {
460    values.sort_by(|a, b| {
461        if utf16_lt(a, b) {
462            std::cmp::Ordering::Less
463        } else if utf16_lt(b, a) {
464            std::cmp::Ordering::Greater
465        } else {
466            std::cmp::Ordering::Equal
467        }
468    });
469}
470
471/// Sort scope-map keys into ascending code-unit order (the canonical `map`
472/// encoding of the Conventions section).
473pub fn sort_scope_map(map: &mut [(String, Vec<String>)]) {
474    map.sort_by(|a, b| {
475        if utf16_lt(&a.0, &b.0) {
476            std::cmp::Ordering::Less
477        } else if utf16_lt(&b.0, &a.0) {
478            std::cmp::Ordering::Greater
479        } else {
480            std::cmp::Ordering::Equal
481        }
482    });
483}
484
485/// §11.2 canonical JSON of a scope map: keys sorted by code-unit, value
486/// lists sorted and deduplicated, no insignificant whitespace.
487pub fn canonical_scope_json(scopes: &[(String, Vec<String>)]) -> String {
488    let mut entries: Vec<(String, Vec<String>)> = scopes.to_vec();
489    sort_scope_map(&mut entries);
490    let mut out = String::from("{");
491    for (i, (key, values)) in entries.iter().enumerate() {
492        if i > 0 {
493            out.push(',');
494        }
495        let mut sorted = values.clone();
496        sort_utf16(&mut sorted);
497        sorted.dedup();
498        out.push_str(&serde_json::to_string(key).expect("string serializes"));
499        out.push_str(":[");
500        for (j, value) in sorted.iter().enumerate() {
501            if j > 0 {
502                out.push(',');
503            }
504            out.push_str(&serde_json::to_string(value).expect("string serializes"));
505        }
506        out.push(']');
507    }
508    out.push('}');
509    out
510}
511
512/// Scope map as a driver JSON object (`variable → list of values`, §3.2).
513pub fn scope_map_to_json(scopes: &[(String, Vec<String>)]) -> Value {
514    let mut map = Map::new();
515    for (key, values) in scopes {
516        map.insert(
517            key.clone(),
518            Value::Array(values.iter().map(|v| Value::from(v.clone())).collect()),
519        );
520    }
521    Value::Object(map)
522}
523
524/// Driver JSON object → scope map, preserving key order.
525pub fn json_to_scope_map(value: &Value) -> Result<Vec<(String, Vec<String>)>, String> {
526    let object = value
527        .as_object()
528        .ok_or_else(|| "scope map must be an object".to_owned())?;
529    let mut out = Vec::with_capacity(object.len());
530    for (key, values) in object {
531        let list = values
532            .as_array()
533            .ok_or_else(|| format!("scope values for {key:?} must be a list (§0)"))?;
534        let mut strings = Vec::with_capacity(list.len());
535        for v in list {
536            strings.push(
537                v.as_str()
538                    .ok_or_else(|| format!("scope value for {key:?} is not a string"))?
539                    .to_owned(),
540            );
541        }
542        out.push((key.clone(), strings));
543    }
544    Ok(out)
545}
546
547#[cfg(test)]
548mod naming_tests {
549    use serde_json::{json, Map, Value};
550    use ssp2::segment::{Column, ColumnType, ColumnValue, Row};
551
552    use super::{normalize_values_casing, snake_to_camel, EncryptionConfig};
553    use crate::schema::{EncryptedColumn, TableSchema};
554
555    #[test]
556    fn snake_to_camel_pinned_vectors() {
557        for (input, expected) in [
558            ("created_at", "createdAt"),
559            ("col_2", "col2"),
560            ("user_id", "userId"),
561            ("_internal", "_internal"),
562            ("__foo_bar", "__fooBar"),
563            ("row_", "row_"),
564            ("id_url", "idUrl"),
565            ("api_key", "apiKey"),
566            ("title", "title"),
567            ("alreadyCamel", "alreadyCamel"),
568            ("a__b", "aB"),
569            ("_lead_and_trail_", "_leadAndTrail_"),
570            ("count(*)", "count(*)"),
571        ] {
572            assert_eq!(snake_to_camel(input), expected, "input {input:?}");
573        }
574    }
575
576    #[test]
577    fn portable_key_selector_reads_a_non_encrypted_string_column() {
578        let columns = vec![
579            Column {
580                name: "id".to_owned(),
581                ty: ColumnType::String,
582                nullable: false,
583            },
584            Column {
585                name: "encryption_key_id".to_owned(),
586                ty: ColumnType::String,
587                nullable: false,
588            },
589            Column {
590                name: "note".to_owned(),
591                ty: ColumnType::String,
592                nullable: false,
593            },
594        ];
595        let table = TableSchema {
596            name: "patients".to_owned(),
597            columns: columns.clone(),
598            wire_columns: columns,
599            primary_key: "id".to_owned(),
600            pk_index: 0,
601            scope_variables: Vec::new(),
602            indexes: Vec::new(),
603            fts_indexes: Vec::new(),
604            encrypted_columns: vec![EncryptedColumn {
605                index: 2,
606                declared_type: "string".to_owned(),
607            }],
608        };
609        let mut config = EncryptionConfig::default();
610        config
611            .key_id_columns
612            .insert("patients".to_owned(), "encryption_key_id".to_owned());
613        let row: Row = vec![
614            Some(ColumnValue::String("patient-1".to_owned())),
615            Some(ColumnValue::String("practice-key-v1".to_owned())),
616            Some(ColumnValue::String("Identity".to_owned())),
617        ];
618        assert_eq!(
619            config.key_id_for(&table, &row).expect("selects key"),
620            "practice-key-v1"
621        );
622    }
623
624    fn table(names: &[&str]) -> TableSchema {
625        let columns: Vec<Column> = names
626            .iter()
627            .map(|n| Column {
628                name: (*n).to_owned(),
629                ty: ColumnType::String,
630                nullable: true,
631            })
632            .collect();
633        TableSchema {
634            name: "t".to_owned(),
635            columns: columns.clone(),
636            wire_columns: columns,
637            primary_key: "id".to_owned(),
638            pk_index: 0,
639            scope_variables: Vec::new(),
640            indexes: Vec::new(),
641            fts_indexes: Vec::new(),
642            encrypted_columns: Vec::new(),
643        }
644    }
645
646    fn map(entries: &[(&str, &str)]) -> Map<String, Value> {
647        entries
648            .iter()
649            .map(|(k, v)| ((*k).to_owned(), json!(v)))
650            .collect()
651    }
652
653    #[test]
654    fn camel_keys_normalize_to_sql_names() {
655        let t = table(&["id", "list_id", "updated_at_ms"]);
656        let out = normalize_values_casing(
657            &t,
658            map(&[("id", "x"), ("listId", "l"), ("updatedAtMs", "9")]),
659        )
660        .expect("normalizes");
661        assert_eq!(out.get("list_id"), Some(&json!("l")));
662        assert_eq!(out.get("updated_at_ms"), Some(&json!("9")));
663        assert!(!out.contains_key("listId"));
664    }
665
666    #[test]
667    fn snake_keys_pass_through() {
668        let t = table(&["id", "list_id"]);
669        let out = normalize_values_casing(&t, map(&[("id", "x"), ("list_id", "l")])).expect("ok");
670        assert_eq!(out.get("list_id"), Some(&json!("l")));
671    }
672
673    #[test]
674    fn both_casings_for_one_column_is_an_error() {
675        let t = table(&["id", "list_id"]);
676        let err = normalize_values_casing(&t, map(&[("list_id", "a"), ("listId", "b")]))
677            .expect_err("rejects");
678        assert!(err.contains("both snake_case and camelCase"), "{err}");
679    }
680
681    #[test]
682    fn an_alias_colliding_with_a_real_column_never_steals_it() {
683        // `col_2` camel-maps to `col2`, which IS a column: exact wins, no rename.
684        let t = table(&["id", "col_2", "col2"]);
685        let out = normalize_values_casing(&t, map(&[("id", "x"), ("col2", "v")])).expect("ok");
686        assert_eq!(out.get("col2"), Some(&json!("v")));
687        assert!(!out.contains_key("col_2"));
688    }
689
690    #[test]
691    fn unknown_and_internal_columns_fail_loud() {
692        let t = table(&["id", "title"]);
693        let unknown = normalize_values_casing(&t, map(&[("id", "x"), ("typo", "v")]))
694            .expect_err("unknown field");
695        assert!(unknown.contains("unknown column"), "{unknown}");
696        let internal = normalize_values_casing(&t, map(&[("id", "x"), ("_sync_version", "1")]))
697            .expect_err("internal field");
698        assert!(internal.contains("internal sync column"), "{internal}");
699    }
700}