Skip to main content

taladb/
document.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2
3use serde::{Deserialize, Serialize};
4use ulid::Ulid;
5use web_time::{SystemTime, UNIX_EPOCH};
6
7/// Packs `(prev_ms_48 << 16) | seq_16` into a single u64 so that the common
8/// path is one lock-free CAS.  48 bits for the millisecond timestamp are
9/// sufficient until year 10895; 16 bits allow 65 536 distinct IDs per ms
10/// per process (any more would exceed even a very hot write loop).
11static ULID_STATE: AtomicU64 = AtomicU64::new(0);
12
13pub(crate) fn new_ulid_pub() -> Ulid {
14    new_ulid()
15}
16
17fn new_ulid() -> Ulid {
18    let ms = SystemTime::now()
19        .duration_since(UNIX_EPOCH)
20        .map(|d| d.as_millis() as u64)
21        .unwrap_or(0);
22
23    // Monotonic per-process counter within the same millisecond keeps bulk
24    // inserts from any thread sortable by ULID.
25    let seq = loop {
26        let cur = ULID_STATE.load(Ordering::Relaxed);
27        let cur_ms = cur >> 16;
28        let cur_seq = (cur & 0xFFFF) as u16;
29        let (next_ms, next_seq) = if ms == cur_ms {
30            (cur_ms, cur_seq.wrapping_add(1))
31        } else {
32            (ms, 0u16)
33        };
34        let packed = (next_ms << 16) | u64::from(next_seq);
35        if ULID_STATE
36            .compare_exchange_weak(cur, packed, Ordering::Relaxed, Ordering::Relaxed)
37            .is_ok()
38        {
39            break next_seq;
40        }
41    };
42
43    // 80-bit random payload. Entropy failure must not be silently ignored: a
44    // zeroed random field makes IDs predictable and collision-prone across
45    // processes.
46    let mut buf = [0u8; 10];
47    getrandom::fill(&mut buf)
48        .expect("taladb: system entropy source failed while generating a ULID");
49    // Upper 16 bits of the random field = monotonic sequence; lower 64 bits = random.
50    let rand_lo = u64::from_le_bytes(buf[..8].try_into().unwrap());
51    let random = (u128::from(seq) << 64) | u128::from(rand_lo);
52    Ulid::from_parts(ms, random)
53}
54
55/// A dynamically-typed value that maps to JSON conceptually but serializes via postcard.
56/// Uses Vec<(String, Value)> for objects (not HashMap) to guarantee deterministic serialization.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub enum Value {
59    Null,
60    Bool(bool),
61    Int(i64),
62    Float(f64),
63    Str(String),
64    Bytes(Vec<u8>),
65    Array(Vec<Self>),
66    Object(Vec<(String, Self)>),
67}
68
69impl Value {
70    pub fn type_name(&self) -> &'static str {
71        match self {
72            Self::Null => "null",
73            Self::Bool(_) => "bool",
74            Self::Int(_) => "int",
75            Self::Float(_) => "float",
76            Self::Str(_) => "string",
77            Self::Bytes(_) => "bytes",
78            Self::Array(_) => "array",
79            Self::Object(_) => "object",
80        }
81    }
82
83    pub fn as_bool(&self) -> Option<bool> {
84        if let Self::Bool(b) = self {
85            Some(*b)
86        } else {
87            None
88        }
89    }
90
91    pub fn as_int(&self) -> Option<i64> {
92        if let Self::Int(n) = self {
93            Some(*n)
94        } else {
95            None
96        }
97    }
98
99    pub fn as_float(&self) -> Option<f64> {
100        if let Self::Float(f) = self {
101            Some(*f)
102        } else {
103            None
104        }
105    }
106
107    pub fn as_str(&self) -> Option<&str> {
108        if let Self::Str(s) = self {
109            Some(s.as_str())
110        } else {
111            None
112        }
113    }
114
115    pub fn as_bytes(&self) -> Option<&[u8]> {
116        if let Self::Bytes(b) = self {
117            Some(b.as_slice())
118        } else {
119            None
120        }
121    }
122
123    pub fn as_array(&self) -> Option<&[Self]> {
124        if let Self::Array(a) = self {
125            Some(a.as_slice())
126        } else {
127            None
128        }
129    }
130
131    /// The fields of an object value, in insertion order.
132    pub fn as_object(&self) -> Option<&[(String, Self)]> {
133        if let Self::Object(o) = self {
134            Some(o.as_slice())
135        } else {
136            None
137        }
138    }
139
140    /// Look up a key in an object value.
141    ///
142    /// Returns `None` if this is not an object, or has no such key. Objects are
143    /// a `Vec` of pairs rather than a map (to keep serialization
144    /// deterministic), so this is a linear scan — fine for the document-shaped
145    /// objects it exists for, not for large ones.
146    pub fn get(&self, key: &str) -> Option<&Self> {
147        self.as_object()?
148            .iter()
149            .find(|(k, _)| k == key)
150            .map(|(_, v)| v)
151    }
152
153    /// True for [`Value::Null`].
154    pub fn is_null(&self) -> bool {
155        matches!(self, Self::Null)
156    }
157
158    /// Compare two Values for ordering (used by query engine range ops).
159    /// Returns None if the values are not comparable (different types).
160    pub fn partial_cmp_numeric(&self, other: &Self) -> Option<std::cmp::Ordering> {
161        match (self, other) {
162            (Self::Int(a), Self::Int(b)) => a.partial_cmp(b),
163            (Self::Float(a), Self::Float(b)) => a.partial_cmp(b),
164            (Self::Int(a), Self::Float(b)) => cmp_i64_f64(*a, *b),
165            (Self::Float(a), Self::Int(b)) => cmp_i64_f64(*b, *a).map(std::cmp::Ordering::reverse),
166            (Self::Str(a), Self::Str(b)) => a.partial_cmp(b),
167            (Self::Bool(a), Self::Bool(b)) => a.partial_cmp(b),
168            _ => None,
169        }
170    }
171}
172
173/// Build a `Value` from the Rust type that maps onto it.
174///
175/// Constructing a document meant naming the variant for every field —
176/// `("year".into(), Value::Int(1887))` — which is noise at every call site and
177/// gets worse the more fields a document has. With these, `1887.into()` works,
178/// and so does collecting an iterator of anything convertible.
179macro_rules! value_from {
180    ($($ty:ty => $variant:ident $(as $cast:ty)?),* $(,)?) => {
181        $(
182            impl From<$ty> for Value {
183                fn from(v: $ty) -> Self {
184                    Value::$variant(v $(as $cast)?)
185                }
186            }
187        )*
188    };
189}
190
191value_from! {
192    bool => Bool,
193    i8 => Int as i64,
194    i16 => Int as i64,
195    i32 => Int as i64,
196    i64 => Int,
197    u8 => Int as i64,
198    u16 => Int as i64,
199    u32 => Int as i64,
200    f32 => Float as f64,
201    f64 => Float,
202    String => Str,
203    Vec<u8> => Bytes,
204    Vec<Value> => Array,
205    Vec<(String, Value)> => Object,
206}
207
208impl From<&str> for Value {
209    fn from(v: &str) -> Self {
210        Self::Str(v.to_owned())
211    }
212}
213
214impl From<&[u8]> for Value {
215    fn from(v: &[u8]) -> Self {
216        Self::Bytes(v.to_vec())
217    }
218}
219
220/// `None` becomes [`Value::Null`] — the document model has no notion of an
221/// absent-but-present field, so an optional field is either null or omitted.
222impl<T: Into<Self>> From<Option<T>> for Value {
223    fn from(v: Option<T>) -> Self {
224        v.map_or(Self::Null, Into::into)
225    }
226}
227
228impl<T: Into<Self>> FromIterator<T> for Value {
229    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
230        Self::Array(iter.into_iter().map(Into::into).collect())
231    }
232}
233
234/// Human-readable rendering, for logs, error messages, and `{}` in tests.
235///
236/// This is **not** a serialization format — it is lossy on purpose (`Bytes`
237/// renders as a length, not its contents) and must not be parsed. Use serde for
238/// round-tripping.
239impl std::fmt::Display for Value {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            Self::Null => f.write_str("null"),
243            Self::Bool(b) => write!(f, "{b}"),
244            Self::Int(n) => write!(f, "{n}"),
245            Self::Float(x) => write!(f, "{x}"),
246            Self::Str(s) => f.write_str(s),
247            Self::Bytes(b) => write!(f, "<{} bytes>", b.len()),
248            Self::Array(items) => {
249                f.write_str("[")?;
250                for (i, item) in items.iter().enumerate() {
251                    if i > 0 {
252                        f.write_str(", ")?;
253                    }
254                    write!(f, "{item}")?;
255                }
256                f.write_str("]")
257            }
258            Self::Object(fields) => {
259                f.write_str("{")?;
260                for (i, (key, value)) in fields.iter().enumerate() {
261                    if i > 0 {
262                        f.write_str(", ")?;
263                    }
264                    write!(f, "{key}: {value}")?;
265                }
266                f.write_str("}")
267            }
268        }
269    }
270}
271
272/// Order an `i64` against an `f64` exactly, without routing either through the
273/// other's type.
274///
275/// `i64 as f64` silently rounds above 2^53, which made `$gt`/`$lt` return the
276/// wrong answer for large integers: `Int(9007199254740993)` rounds to
277/// `9007199254740992.0` and compared `Equal` to that float instead of
278/// `Greater`. `f64 as i64` is worse — it saturates, so every float past
279/// `i64::MAX` collapsed onto the same integer.
280///
281/// Instead: reject NaN, handle the out-of-i64-range floats by sign, then split
282/// the remaining float into its integer and fractional parts and compare in the
283/// integer domain. Every step is exact.
284fn cmp_i64_f64(a: i64, b: f64) -> Option<std::cmp::Ordering> {
285    use std::cmp::Ordering;
286
287    if b.is_nan() {
288        return None;
289    }
290    // 2^63 — the first f64 above `i64::MAX`. Both bounds are exactly
291    // representable, so these comparisons are themselves exact.
292    const TWO_POW_63: f64 = 9_223_372_036_854_775_808.0;
293    if b >= TWO_POW_63 {
294        return Some(Ordering::Less);
295    }
296    if b < -TWO_POW_63 {
297        return Some(Ordering::Greater);
298    }
299
300    // `b` now sits in [i64::MIN, i64::MAX], so truncation is lossless.
301    let truncated = b.trunc();
302    let whole = truncated as i64;
303    match a.cmp(&whole) {
304        // Equal integer parts — the fraction breaks the tie. `trunc` rounds
305        // toward zero, so a positive fraction makes `b` larger and a negative
306        // one makes it smaller.
307        Ordering::Equal => Some(if b > truncated {
308            Ordering::Less
309        } else if b < truncated {
310            Ordering::Greater
311        } else {
312            Ordering::Equal
313        }),
314        ord => Some(ord),
315    }
316}
317
318/// FNV-1a (128-bit) offset basis and prime, per the reference specification.
319///
320/// FNV is chosen over a stronger hash on purpose: [`derive_doc_id`] must be
321/// reimplemented **byte-identically** in TypeScript (see `deriveDocId` in
322/// `@taladb/*`), and FNV-1a is short enough to port without ambiguity. It is a
323/// non-cryptographic hash — that is fine here, because the input is a remote
324/// primary key from an origin the client already trusts, not adversarial input.
325const FNV1A128_OFFSET_BASIS: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
326const FNV1A128_PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
327
328/// Derive a stable, deterministic [`Ulid`] from a remote primary key.
329///
330/// The engine accepts a caller-supplied `_id` only if it is a ULID (see
331/// `take_supplied_id` in `collection.rs`), so a natural key — a SKU, a remote
332/// primary key, the name of a singleton settings document — cannot be stored
333/// as one directly. Hashing the key into a ULID gives it a stable identity:
334/// the same `(collection, key)` always maps to the same document, so a seed or
335/// a hydration from a server can be re-run without inserting duplicates.
336///
337/// `collection` is part of the preimage so the same remote id in two different
338/// collections cannot collide.
339///
340/// # Ordering caveat
341///
342/// The result is a hash, so its ULID timestamp prefix is **not chronological**.
343/// Documents written with a derived id do not come back in insertion order from
344/// an unordered `find()`. Reads over replicated collections must carry an
345/// explicit sort. (Documents written via `insert`/`insert_many` are unaffected —
346/// they still get monotonic ULIDs.)
347pub fn derive_doc_id(collection: &str, key: &str) -> Ulid {
348    // A 0x00 separator keeps the preimage unambiguous: without it,
349    // ("ab", "c") and ("a", "bc") would hash identically.
350    const SEPARATOR: [u8; 1] = [0u8];
351    let mut hash = FNV1A128_OFFSET_BASIS;
352    for &byte in collection
353        .as_bytes()
354        .iter()
355        .chain(SEPARATOR.iter())
356        .chain(key.as_bytes().iter())
357    {
358        hash ^= u128::from(byte);
359        hash = hash.wrapping_mul(FNV1A128_PRIME);
360    }
361    Ulid::from_bytes(hash.to_be_bytes())
362}
363
364/// A database document with a ULID primary key and ordered fields.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct Document {
367    pub id: Ulid,
368    pub fields: Vec<(String, Value)>,
369}
370
371impl Document {
372    pub fn new(fields: Vec<(String, Value)>) -> Self {
373        Self {
374            id: new_ulid(),
375            fields: dedupe_fields(fields),
376        }
377    }
378
379    pub fn with_id(id: Ulid, fields: Vec<(String, Value)>) -> Self {
380        Self {
381            id,
382            fields: dedupe_fields(fields),
383        }
384    }
385
386    /// Return the value at `key`, supporting dot-notation for nested objects.
387    ///
388    /// `"name"` returns the top-level `name` field.
389    /// `"address.city"` traverses into a nested `Value::Object`.
390    /// Deep paths like `"a.b.c"` are resolved recursively.
391    pub fn get(&self, key: &str) -> Option<&Value> {
392        if let Some(dot) = key.find('.') {
393            let (head, tail) = (&key[..dot], &key[dot + 1..]);
394            let parent = self
395                .fields
396                .iter()
397                .find(|(k, _)| k == head)
398                .map(|(_, v)| v)?;
399            value_get_nested(parent, tail)
400        } else {
401            self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v)
402        }
403    }
404
405    pub fn set(&mut self, key: impl Into<String>, value: Value) {
406        let key = key.into();
407        if let Some(entry) = self.fields.iter_mut().find(|(k, _)| k == &key) {
408            entry.1 = value;
409        } else {
410            self.fields.push((key, value));
411        }
412    }
413
414    pub fn remove(&mut self, key: &str) -> Option<Value> {
415        if let Some(pos) = self.fields.iter().position(|(k, _)| k == key) {
416            Some(self.fields.remove(pos).1)
417        } else {
418            None
419        }
420    }
421
422    pub fn contains_key(&self, key: &str) -> bool {
423        self.get(key).is_some()
424    }
425}
426
427/// Drop duplicate field names, keeping the first occurrence.
428///
429/// `get`/`set` only ever see the first entry for a name, but duplicates would
430/// all be serialized, silently bloating storage and resurfacing after a
431/// deserialize → mutate → serialize cycle. Normalizing at construction keeps
432/// the in-memory and on-disk views consistent.
433fn dedupe_fields(fields: Vec<(String, Value)>) -> Vec<(String, Value)> {
434    // Fast path: small field lists with unique names allocate nothing extra.
435    let has_dup = fields
436        .iter()
437        .enumerate()
438        .any(|(i, (k, _))| fields[..i].iter().any(|(k2, _)| k2 == k));
439    if !has_dup {
440        return fields;
441    }
442    let mut out: Vec<(String, Value)> = Vec::with_capacity(fields.len());
443    for (k, v) in fields {
444        if !out.iter().any(|(k2, _)| k2 == &k) {
445            out.push((k, v));
446        }
447    }
448    out
449}
450
451/// Traverse a `Value::Object` using a dot-separated path.
452///
453/// Returns `None` when nesting exceeds 64 levels to prevent stack overflow
454/// from adversarially crafted field paths.
455pub fn value_get_nested<'a>(val: &'a Value, path: &str) -> Option<&'a Value> {
456    value_get_nested_depth(val, path, 0)
457}
458
459fn value_get_nested_depth<'a>(val: &'a Value, path: &str, depth: u8) -> Option<&'a Value> {
460    if depth >= 64 {
461        return None;
462    }
463    if let Some(dot) = path.find('.') {
464        let (head, tail) = (&path[..dot], &path[dot + 1..]);
465        match val {
466            Value::Object(fields) => {
467                let child = fields.iter().find(|(k, _)| k == head).map(|(_, v)| v)?;
468                value_get_nested_depth(child, tail, depth + 1)
469            }
470            _ => None,
471        }
472    } else {
473        match val {
474            Value::Object(fields) => fields.iter().find(|(k, _)| k == path).map(|(_, v)| v),
475            _ => None,
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::cmp::Ordering;
484
485    /// Integers past 2^53 have no exact `f64`, so the old `a as f64` cast made
486    /// range queries answer `Equal` where the true answer is `Greater`/`Less`.
487    #[test]
488    fn int_float_comparison_is_exact_beyond_2_pow_53() {
489        // 2^53 + 1 — the first integer f64 cannot represent. It rounds down to
490        // 2^53, which is the float we compare it against.
491        let big = Value::Int(9_007_199_254_740_993);
492        let as_float = Value::Float(9_007_199_254_740_992.0);
493        assert_eq!(big.partial_cmp_numeric(&as_float), Some(Ordering::Greater));
494        assert_eq!(as_float.partial_cmp_numeric(&big), Some(Ordering::Less));
495
496        // Same story at the bottom of the range.
497        let small = Value::Int(-9_007_199_254_740_993);
498        let small_float = Value::Float(-9_007_199_254_740_992.0);
499        assert_eq!(
500            small.partial_cmp_numeric(&small_float),
501            Some(Ordering::Less)
502        );
503        assert_eq!(
504            small_float.partial_cmp_numeric(&small),
505            Some(Ordering::Greater)
506        );
507    }
508
509    /// `f64 as i64` saturates, so floats beyond the integer range must be
510    /// handled by sign rather than by casting.
511    #[test]
512    fn int_float_comparison_handles_out_of_range_floats() {
513        let max = Value::Int(i64::MAX);
514        let min = Value::Int(i64::MIN);
515
516        assert_eq!(
517            max.partial_cmp_numeric(&Value::Float(1e300)),
518            Some(Ordering::Less)
519        );
520        assert_eq!(
521            min.partial_cmp_numeric(&Value::Float(-1e300)),
522            Some(Ordering::Greater)
523        );
524        assert_eq!(
525            max.partial_cmp_numeric(&Value::Float(f64::INFINITY)),
526            Some(Ordering::Less)
527        );
528        assert_eq!(
529            min.partial_cmp_numeric(&Value::Float(f64::NEG_INFINITY)),
530            Some(Ordering::Greater)
531        );
532    }
533
534    #[test]
535    fn int_float_comparison_handles_fractions_and_nan() {
536        // Fractions break ties on equal integer parts, in both sign directions.
537        assert_eq!(
538            Value::Int(3).partial_cmp_numeric(&Value::Float(3.5)),
539            Some(Ordering::Less)
540        );
541        assert_eq!(
542            Value::Int(-3).partial_cmp_numeric(&Value::Float(-3.5)),
543            Some(Ordering::Greater)
544        );
545        assert_eq!(
546            Value::Int(3).partial_cmp_numeric(&Value::Float(3.0)),
547            Some(Ordering::Equal)
548        );
549
550        // NaN stays incomparable, as it was before.
551        assert_eq!(
552            Value::Int(1).partial_cmp_numeric(&Value::Float(f64::NAN)),
553            None
554        );
555        assert_eq!(
556            Value::Float(f64::NAN).partial_cmp_numeric(&Value::Int(1)),
557            None
558        );
559    }
560
561    #[test]
562    fn value_from_impls_cover_the_rust_types_that_map_onto_it() {
563        assert_eq!(Value::from(true), Value::Bool(true));
564        assert_eq!(Value::from(42i32), Value::Int(42));
565        assert_eq!(Value::from(42u8), Value::Int(42));
566        assert_eq!(Value::from(1.5f32), Value::Float(1.5));
567        assert_eq!(Value::from("hi"), Value::Str("hi".into()));
568        assert_eq!(Value::from(String::from("hi")), Value::Str("hi".into()));
569        assert_eq!(Value::from(vec![1u8, 2]), Value::Bytes(vec![1, 2]));
570
571        // Optionals collapse to null rather than being dropped.
572        assert_eq!(Value::from(None::<i64>), Value::Null);
573        assert_eq!(Value::from(Some(7i64)), Value::Int(7));
574
575        // Collecting builds an array without naming the variant.
576        let arr: Value = [1i64, 2, 3].into_iter().collect();
577        assert_eq!(
578            arr,
579            Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(3)])
580        );
581    }
582
583    #[test]
584    fn value_accessors_cover_every_variant() {
585        assert_eq!(Value::Bool(true).as_bool(), Some(true));
586        assert_eq!(Value::Int(1).as_int(), Some(1));
587        assert_eq!(Value::Float(1.5).as_float(), Some(1.5));
588        assert_eq!(Value::Str("s".into()).as_str(), Some("s"));
589        assert_eq!(Value::Bytes(vec![1, 2]).as_bytes(), Some(&[1u8, 2][..]));
590        assert_eq!(
591            Value::Array(vec![Value::Int(1)]).as_array().unwrap().len(),
592            1
593        );
594        assert!(Value::Null.is_null());
595
596        let obj = Value::Object(vec![("a".into(), Value::Int(1))]);
597        assert_eq!(obj.as_object().unwrap().len(), 1);
598        assert_eq!(obj.get("a"), Some(&Value::Int(1)));
599        assert_eq!(obj.get("missing"), None);
600
601        // Accessors are type-checked, not coercing.
602        assert_eq!(Value::Int(1).as_str(), None);
603        assert_eq!(Value::Str("1".into()).as_int(), None);
604        assert_eq!(Value::Int(1).get("a"), None);
605    }
606
607    #[test]
608    fn value_display_is_readable_and_hides_byte_contents() {
609        assert_eq!(Value::Null.to_string(), "null");
610        assert_eq!(Value::Int(1887).to_string(), "1887");
611        assert_eq!(Value::Str("Rizal".into()).to_string(), "Rizal");
612        // Bytes render as a length: dumping a blob into a log line is never
613        // what the caller wanted.
614        assert_eq!(Value::Bytes(vec![0; 500]).to_string(), "<500 bytes>");
615        assert_eq!(
616            Value::Array(vec![Value::Int(1), Value::Str("a".into())]).to_string(),
617            "[1, a]"
618        );
619        assert_eq!(
620            Value::Object(vec![
621                ("a".into(), Value::Int(1)),
622                ("b".into(), Value::Bool(false))
623            ])
624            .to_string(),
625            "{a: 1, b: false}"
626        );
627    }
628
629    #[test]
630    fn round_trip_postcard() {
631        let doc = Document::new(vec![
632            ("name".into(), Value::Str("Alice".into())),
633            ("age".into(), Value::Int(30)),
634            ("active".into(), Value::Bool(true)),
635            ("score".into(), Value::Float(9.5)),
636            (
637                "tags".into(),
638                Value::Array(vec![Value::Str("rust".into()), Value::Str("db".into())]),
639            ),
640        ]);
641
642        let bytes = postcard::to_allocvec(&doc).unwrap();
643        let decoded: Document = postcard::from_bytes(&bytes).unwrap();
644
645        assert_eq!(doc.id, decoded.id);
646        assert_eq!(doc.fields, decoded.fields);
647    }
648
649    #[test]
650    fn document_get_set_remove() {
651        let mut doc = Document::new(vec![("x".into(), Value::Int(1))]);
652        assert_eq!(doc.get("x"), Some(&Value::Int(1)));
653        doc.set("x", Value::Int(2));
654        assert_eq!(doc.get("x"), Some(&Value::Int(2)));
655        doc.set("y", Value::Bool(true));
656        assert_eq!(doc.get("y"), Some(&Value::Bool(true)));
657        doc.remove("x");
658        assert_eq!(doc.get("x"), None);
659    }
660
661    #[test]
662    fn derive_doc_id_is_deterministic() {
663        assert_eq!(
664            derive_doc_id("products", "sku-123"),
665            derive_doc_id("products", "sku-123")
666        );
667    }
668
669    #[test]
670    fn derive_doc_id_separates_collections() {
671        // The same remote key in two collections must not collide.
672        assert_ne!(derive_doc_id("products", "1"), derive_doc_id("orders", "1"));
673    }
674
675    #[test]
676    fn derive_doc_id_separator_prevents_boundary_collision() {
677        // Without the 0x00 separator these two preimages would be identical.
678        assert_ne!(derive_doc_id("ab", "c"), derive_doc_id("a", "bc"));
679    }
680
681    /// Cross-language contract. These vectors are duplicated verbatim in the
682    /// TypeScript `deriveDocId` test suite and **must** stay in lockstep: if the
683    /// two implementations ever disagree, two clients assign different `_id`s to
684    /// the same remote row and the replica silently forks into duplicates.
685    #[test]
686    fn derive_doc_id_cross_language_vectors() {
687        let vectors: &[(&str, &str, &str)] = &[
688            ("products", "sku-123", "56GC678DQYWW1Z98HPYJ90WVKH"),
689            ("products", "1", "7Z6Y6H8NG96ZGN4PJVDP18CSY2"),
690            ("orders", "1", "5VYD63GDV5KCDXV2A0SYRWSKVZ"),
691            ("", "", "6J535PJ40THJQQH49BE174M53Z"),
692            // Multi-byte UTF-8 in the key: the hash runs over raw bytes, so the
693            // TypeScript port must feed it UTF-8 bytes, not UTF-16 code units.
694            ("products", "sku-ñ-💡", "5NZ0PGNM2CF0BTHN0AAX8CWPAA"),
695        ];
696        for (collection, key, expected) in vectors {
697            assert_eq!(
698                derive_doc_id(collection, key).to_string(),
699                *expected,
700                "derive_doc_id({collection:?}, {key:?}) drifted — \
701                 the TypeScript implementation must match these exactly"
702            );
703        }
704    }
705}