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