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