Skip to main content

core_storage/
wal.rs

1use crate::types::Value;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub enum WalRecord {
6    InsertNode {
7        label: String,
8        key: String,
9        props: Vec<(String, Value)>,
10    },
11    InsertEdge {
12        edge_type: String,
13        src_key: String,
14        dst_key: String,
15    },
16    SetProp {
17        key: String,
18        field: String,
19        value: Value,
20    },
21    CreateRule {
22        def_bytes: Vec<u8>,
23    },
24    DeleteRule {
25        name: String,
26    },
27    // ── Mutation variants (appended last — bincode is positional) ─────────────
28    RemoveProp {
29        key: String,
30        field: String,
31    },
32    DeleteEdge {
33        edge_type: String,
34        src_key: String,
35        dst_key: String,
36    },
37    DeleteNode {
38        key: String,
39    },
40    /// One WAL frame = one atomic batch. Nested `Batch` inside a `Batch` is
41    /// invalid: `encode_record` debug-asserts against it, and `decode_all`
42    /// treats a frame whose payload deserialises to a nested `Batch` as corrupt
43    /// (stops cleanly before that frame, returning the valid prefix).
44    Batch(Vec<WalRecord>),
45    /// Recompute one rule from scratch (un-trip / repair). Appended after
46    /// `Batch`; bincode discriminant is 9. `Batch` stays at 8.
47    RebuildRule {
48        name: String,
49    },
50    // ── View variants (appended LAST; bincode discriminant is positional) ─────
51    /// Create (or restore) a materialized property view.  Bincoded `ViewDef`
52    /// bytes keep core-storage free of core-rules types.  Discriminant 10.
53    CreateView {
54        def_bytes: Vec<u8>,
55    },
56    /// Delete a named view and remove its values from all nodes.  Discriminant 11.
57    DeleteView {
58        name: String,
59    },
60    // ── Full-text-lite variants (appended after views; discriminants 12, 13) ──
61    /// Enable full-text indexing on all nodes of `label` for field `field`.
62    /// The index is rebuilt from live data on open; this record only persists
63    /// the declaration.  Discriminant 12.
64    EnableFulltext {
65        label: String,
66        field: String,
67    },
68    /// Disable full-text indexing for `(label, field)` and drop its postings.
69    /// Discriminant 13.
70    DisableFulltext {
71        label: String,
72        field: String,
73    },
74    // ── Dense-id variants (appended after full-text; discriminants 14–17) ──
75    /// Insert a node using interned label/field ids. Key remains a string once.
76    /// Discriminant 14.
77    InsertNodeId {
78        label: u32,
79        key: String,
80        props: Vec<(u32, Value)>,
81    },
82    /// Set a property using dense node id and interned field id. Discriminant 15.
83    SetPropId {
84        id: u32,
85        field: u32,
86        value: Value,
87    },
88    /// Insert an edge using interned etype and dense node ids. Discriminant 16.
89    InsertEdgeId {
90        etype: u32,
91        src: u32,
92        dst: u32,
93    },
94    /// Bind intern id `id` to `text` so subsequent `*Id` records can replay
95    /// without a snapshot intern table. Discriminant 17. Apply is idempotent
96    /// when the string is already bound to `id`.
97    ///
98    /// Discriminant order is append-order, not emit-order: in a WAL stream
99    /// `Intern` always *precedes* the `*Id` records that reference it, even
100    /// though it carries the highest discriminant of the 14–17 group.
101    Intern {
102        id: u32,
103        text: String,
104    },
105    // ── History-marker variants (appended after Intern; discriminants 18–19) ──
106    //
107    // These are HISTORY MARKERS only — they record that a rule-derived edge was
108    // added or retracted at a given commit.  They carry **zero replay semantics**:
109    // every apply/replay site must treat them as no-ops (rules re-derive
110    // deterministically on open/replay). Their sole purpose is to make
111    // `edge_history` and `was_linked` aware of derived-edge lifetimes without
112    // adding any new state.
113    //
114    // Note: churny top-k rules write one marker per edge-fire/retract per
115    // commit.  Snapshot truncation bounds the WAL size; Task 4's archive
116    // support will retain markers across snapshot boundaries.
117    /// A rule-derived edge was added.  Discriminant 18.
118    DerivedEdgeAdded {
119        rule: String,
120        edge_type: String,
121        src_key: String,
122        dst_key: String,
123    },
124    /// A rule-derived edge was retracted.  Discriminant 19.
125    DerivedEdgeRetracted {
126        rule: String,
127        edge_type: String,
128        src_key: String,
129        dst_key: String,
130    },
131    // ── Key-mutation variants (appended after history markers; discriminant 20) ──
132    //
133    // RenameNode updates the key-table entry for an existing node without
134    // changing its dense id.  All edges, properties, rules, and history remain
135    // valid — only the string key resolves differently after replay.
136    /// Rename a node's key.  Dense id is unchanged.  Discriminant 20.
137    RenameNode {
138        old_key: String,
139        new_key: String,
140    },
141    // ── Property-index variants (appended after rename; discriminants 21, 22) ──
142    //
143    // Like the full-text variants, these persist only the *declaration* of an
144    // equality index on `(label, field)`; the postings are rebuilt from live
145    // data on open.  Zero replay state beyond the enabled set.
146    /// Enable an equality index on `(label, field)`.  Discriminant 21.
147    EnableIndex {
148        label: String,
149        field: String,
150    },
151    /// Disable the equality index on `(label, field)` and drop its postings.
152    /// Discriminant 22.
153    DisableIndex {
154        label: String,
155        field: String,
156    },
157}
158
159/// Encode a single WAL record as a framed byte sequence: `[len u32][crc u32][payload]`.
160///
161/// # Panics (debug builds)
162/// Panics if `rec` is a `Batch` that contains a nested `Batch` — nested batches
163/// are semantically invalid.
164pub fn encode_record(rec: &WalRecord) -> Vec<u8> {
165    if let WalRecord::Batch(inner) = rec {
166        debug_assert!(
167            !inner.iter().any(|r| matches!(r, WalRecord::Batch(_))),
168            "nested Batch is invalid: a Batch may not contain another Batch"
169        );
170    }
171    let payload = bincode::serialize(rec).expect("walrecord serialize cannot fail");
172    let crc = crc32fast::hash(&payload);
173    let mut out = Vec::with_capacity(8 + payload.len());
174    out.extend((payload.len() as u32).to_le_bytes());
175    out.extend(crc.to_le_bytes());
176    out.extend(payload);
177    out
178}
179
180/// Count the number of complete, valid WAL frames in `bytes`.
181///
182/// Each frame counts as one commit — both `Batch` frames (which represent an
183/// atomic multi-op commit) and legacy single-op frames (InsertNode, SetProp,
184/// CreateRule, etc.).  The numbering used by `GraphDb::open_at` is 0-based:
185/// commit 0 is the first frame, commit N-1 is the last frame in a WAL with N
186/// total commits.
187///
188/// Equivalent to `decode_all(bytes).0.len() as u64`; exposed as a named
189/// function so commit-count semantics are pinned independently of the decoder.
190pub fn wal_commits(bytes: &[u8]) -> u64 {
191    decode_all(bytes).0.len() as u64
192}
193
194/// Decode as many complete, valid WAL frames as possible from `bytes`.
195///
196/// Returns `(records, valid_len)` where `valid_len` is the byte offset of the
197/// first frame that was torn, corrupt, or undeserializable — callers can
198/// truncate the WAL file to `valid_len` to discard the invalid tail.
199///
200/// A `Batch` frame whose inner record list contains a nested `Batch` is treated
201/// as corrupt: decoding stops before that frame (the frame itself is not pushed).
202pub fn decode_all(bytes: &[u8]) -> (Vec<WalRecord>, usize) {
203    let mut recs = Vec::new();
204    let mut pos = 0usize;
205    loop {
206        if bytes.len() < pos + 8 {
207            return (recs, pos);
208        }
209        // Infallible: the `bytes.len() >= pos + 8` guard above ensures both
210        // 4-byte slices are exactly 4 bytes wide; `try_into` cannot fail.
211        let len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()) as usize;
212        let crc = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap());
213        let start = pos + 8;
214        if bytes.len() < start + len {
215            return (recs, pos); // torn tail
216        }
217        let payload = &bytes[start..start + len];
218        if crc32fast::hash(payload) != crc {
219            return (recs, pos); // corrupt tail
220        }
221        match bincode::deserialize::<WalRecord>(payload) {
222            Ok(WalRecord::Batch(inner)) => {
223                // Nested Batch inside a Batch is invalid; treat as corrupt frame.
224                if inner.iter().any(|r| matches!(r, WalRecord::Batch(_))) {
225                    return (recs, pos);
226                }
227                recs.push(WalRecord::Batch(inner));
228            }
229            Ok(r) => recs.push(r),
230            Err(_) => return (recs, pos),
231        }
232        pos = start + len;
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::types::Value;
240
241    fn sample() -> Vec<WalRecord> {
242        vec![
243            WalRecord::InsertNode {
244                label: "L".into(),
245                key: "k1".into(),
246                props: vec![("f".into(), Value::Int(1))],
247            },
248            WalRecord::InsertEdge {
249                edge_type: "E".into(),
250                src_key: "k1".into(),
251                dst_key: "k2".into(),
252            },
253        ]
254    }
255
256    #[test]
257    fn roundtrip_multiple_records() {
258        let mut bytes = Vec::new();
259        for r in sample() {
260            bytes.extend(encode_record(&r));
261        }
262        let (recs, consumed) = decode_all(&bytes);
263        assert_eq!(recs, sample());
264        assert_eq!(consumed, bytes.len());
265    }
266
267    #[test]
268    fn torn_tail_is_dropped_whole() {
269        let mut bytes = Vec::new();
270        for r in sample() {
271            bytes.extend(encode_record(&r));
272        }
273        let full = bytes.len();
274        let first = encode_record(&sample()[0]).len();
275        bytes.truncate(full - 3); // tear the second record
276        let (recs, consumed) = decode_all(&bytes);
277        assert_eq!(recs.len(), 1);
278        assert_eq!(consumed, first);
279    }
280
281    #[test]
282    fn corrupt_crc_stops_replay_at_last_valid() {
283        let mut bytes = encode_record(&sample()[0]);
284        let n = bytes.len();
285        bytes[n - 1] ^= 0xFF; // flip a payload byte
286        let (recs, consumed) = decode_all(&bytes);
287        assert!(recs.is_empty());
288        assert_eq!(consumed, 0);
289    }
290
291    #[test]
292    fn empty_input_is_fine() {
293        let (recs, consumed) = decode_all(&[]);
294        assert!(recs.is_empty());
295        assert_eq!(consumed, 0);
296    }
297
298    // ── Task 2: new variant roundtrips ────────────────────────────────────────
299
300    #[test]
301    fn roundtrip_remove_prop() {
302        let r = WalRecord::RemoveProp {
303            key: "n1".into(),
304            field: "age".into(),
305        };
306        let bytes = encode_record(&r);
307        let (recs, _) = decode_all(&bytes);
308        assert_eq!(recs, vec![r]);
309    }
310
311    #[test]
312    fn roundtrip_delete_edge() {
313        let r = WalRecord::DeleteEdge {
314            edge_type: "KNOWS".into(),
315            src_key: "a".into(),
316            dst_key: "b".into(),
317        };
318        let bytes = encode_record(&r);
319        let (recs, _) = decode_all(&bytes);
320        assert_eq!(recs, vec![r]);
321    }
322
323    #[test]
324    fn roundtrip_delete_node() {
325        let r = WalRecord::DeleteNode { key: "x".into() };
326        let bytes = encode_record(&r);
327        let (recs, _) = decode_all(&bytes);
328        assert_eq!(recs, vec![r]);
329    }
330
331    #[test]
332    fn roundtrip_rebuild_rule() {
333        let r = WalRecord::RebuildRule { name: "eq".into() };
334        let bytes = encode_record(&r);
335        let (recs, _) = decode_all(&bytes);
336        assert_eq!(recs, vec![r]);
337    }
338
339    #[test]
340    fn batch_of_three_is_one_frame() {
341        let inner = vec![
342            WalRecord::DeleteNode { key: "a".into() },
343            WalRecord::DeleteNode { key: "b".into() },
344            WalRecord::DeleteNode { key: "c".into() },
345        ];
346        let batch = WalRecord::Batch(inner.clone());
347        let frame = encode_record(&batch);
348
349        // Exactly ONE frame: one (u32 len + u32 crc) header at offset 0.
350        // Verify by calling decode_all on the raw bytes.
351        let (recs, consumed) = decode_all(&frame);
352        assert_eq!(consumed, frame.len(), "should consume the whole frame");
353        assert_eq!(recs.len(), 1, "one decoded record (the Batch)");
354        assert_eq!(recs[0], WalRecord::Batch(inner));
355    }
356
357    #[test]
358    fn torn_mid_batch_frame_drops_whole_batch() {
359        // Two plain records before the batch, then a batch frame that is torn.
360        let pre = sample();
361        let batch = WalRecord::Batch(vec![
362            WalRecord::DeleteNode { key: "a".into() },
363            WalRecord::DeleteNode { key: "b".into() },
364        ]);
365        let mut bytes = Vec::new();
366        for r in &pre {
367            bytes.extend(encode_record(r));
368        }
369        let batch_start = bytes.len();
370        bytes.extend(encode_record(&batch));
371
372        // Truncate 3 bytes inside the batch frame.
373        bytes.truncate(bytes.len() - 3);
374
375        let (recs, consumed) = decode_all(&bytes);
376        assert_eq!(recs, pre, "only pre-batch records survive");
377        assert_eq!(
378            consumed, batch_start,
379            "valid_len stops at batch frame start"
380        );
381    }
382
383    #[test]
384    #[cfg(debug_assertions)]
385    #[should_panic(expected = "nested Batch")]
386    fn nested_batch_encode_panics_in_debug() {
387        let inner_batch = WalRecord::Batch(vec![WalRecord::DeleteNode { key: "z".into() }]);
388        let outer = WalRecord::Batch(vec![inner_batch]);
389        encode_record(&outer); // must debug_assert-panic
390    }
391
392    /// Pin the exact on-disk wire format for two variants: one pre-existing
393    /// (discriminant 0) and the first new variant added in Plan 4 (discriminant 5).
394    ///
395    /// **If this test fails you have broken every existing database file.**
396    /// WAL variants must ONLY be appended — never reordered or inserted.
397    /// The frame layout is `[len: u32 LE][crc32: u32 LE][bincode payload]`.
398    /// The discriminant is the first 4 bytes of the payload (u32 LE).
399    /// `Batch` stays at discriminant 8; `RebuildRule` is 9.
400    #[test]
401    fn golden_bytes_pin_wire_format() {
402        // ── Variant 0: InsertNode { label: "L", key: "k", props: [] } ──────────
403        let insert_node = WalRecord::InsertNode {
404            label: "L".into(),
405            key: "k".into(),
406            props: vec![],
407        };
408        #[rustfmt::skip]
409        let expected_insert_node: &[u8] = &[
410            // header: len=30 LE, crc32 LE
411            30, 0, 0, 0, 114, 69, 253, 24,
412            // payload: discriminant=0 (InsertNode)
413            0, 0, 0, 0,
414            // label "L": len=1, b'L'
415            1, 0, 0, 0, 0, 0, 0, 0, 76,
416            // key "k": len=1, b'k'
417            1, 0, 0, 0, 0, 0, 0, 0, 107,
418            // props: len=0
419            0, 0, 0, 0, 0, 0, 0, 0,
420        ];
421        assert_eq!(
422            encode_record(&insert_node),
423            expected_insert_node,
424            "InsertNode wire format changed — this breaks all existing WAL files"
425        );
426
427        // ── Variant 5: RemoveProp { key: "n1", field: "age" } ─────────────────
428        // This is the first Plan-4 mutation variant; pins the append boundary.
429        let remove_prop = WalRecord::RemoveProp {
430            key: "n1".into(),
431            field: "age".into(),
432        };
433        #[rustfmt::skip]
434        let expected_remove_prop: &[u8] = &[
435            // header: len=25 LE, crc32 LE
436            25, 0, 0, 0, 35, 214, 55, 239,
437            // payload: discriminant=5 (RemoveProp)
438            5, 0, 0, 0,
439            // key "n1": len=2, b'n', b'1'
440            2, 0, 0, 0, 0, 0, 0, 0, 110, 49,
441            // field "age": len=3, b'a', b'g', b'e'
442            3, 0, 0, 0, 0, 0, 0, 0, 97, 103, 101,
443        ];
444        assert_eq!(
445            encode_record(&remove_prop),
446            expected_remove_prop,
447            "RemoveProp wire format changed — this breaks all existing WAL files"
448        );
449
450        // ── Variant 8: Batch([DeleteNode { key: "z" }]) ──────────────────────
451        // Pins discriminant 8 with an exact-bytes golden (payload hardcoded;
452        // CRC derived from that payload so the frame is self-consistent).
453        // An accidental insertion of any variant before `Batch` in the enum
454        // changes the discriminant bytes, which breaks this assertion
455        // immediately — preventing silent corruption of every existing WAL file
456        // that contains Batch frames.
457        let batch_single = WalRecord::Batch(vec![WalRecord::DeleteNode { key: "z".into() }]);
458        // Payload: discriminant 8, vec len 1, DeleteNode discriminant 7, key "z".
459        // 4 + 8 + 4 + 8 + 1 = 25 bytes.
460        #[rustfmt::skip]
461        let batch_payload: &[u8] = &[
462            // discriminant=8 (Batch)
463            8, 0, 0, 0,
464            // inner vec len=1 (bincode u64 LE)
465            1, 0, 0, 0, 0, 0, 0, 0,
466            // DeleteNode discriminant=7
467            7, 0, 0, 0,
468            // key "z": len=1 (u64 LE), b'z'=122
469            1, 0, 0, 0, 0, 0, 0, 0, 122,
470        ];
471        let batch_crc = crc32fast::hash(batch_payload);
472        let mut expected_batch_frame: Vec<u8> = Vec::with_capacity(8 + batch_payload.len());
473        expected_batch_frame.extend((batch_payload.len() as u32).to_le_bytes());
474        expected_batch_frame.extend(batch_crc.to_le_bytes());
475        expected_batch_frame.extend_from_slice(batch_payload);
476        assert_eq!(
477            encode_record(&batch_single),
478            expected_batch_frame,
479            "Batch (discriminant 8) wire format changed — a variant may have \
480             been inserted before position 8, breaking all existing WAL Batch frames"
481        );
482
483        // ── Variant 9: RebuildRule { name: "eq" } ─────────────────────────────
484        // Batch remains discriminant 8; this variant is appended after it.
485        let rebuild = WalRecord::RebuildRule { name: "eq".into() };
486        #[rustfmt::skip]
487        let expected_rebuild: &[u8] = &[
488            // header: len=14 LE, crc32 LE
489            14, 0, 0, 0, 242, 136, 144, 68,
490            // payload: discriminant=9 (RebuildRule)
491            9, 0, 0, 0,
492            // name "eq": len=2, b'e', b'q'
493            2, 0, 0, 0, 0, 0, 0, 0, 101, 113,
494        ];
495        assert_eq!(
496            encode_record(&rebuild),
497            expected_rebuild,
498            "RebuildRule wire format changed — append-only WAL variants"
499        );
500    }
501
502    // ── Task 3: view variant roundtrips + wire pins ───────────────────────────
503
504    #[test]
505    fn roundtrip_create_view() {
506        let r = WalRecord::CreateView {
507            def_bytes: vec![1, 2, 3],
508        };
509        let bytes = encode_record(&r);
510        let (recs, _) = decode_all(&bytes);
511        assert_eq!(recs, vec![r]);
512    }
513
514    #[test]
515    fn roundtrip_delete_view() {
516        let r = WalRecord::DeleteView {
517            name: "my_view".into(),
518        };
519        let bytes = encode_record(&r);
520        let (recs, _) = decode_all(&bytes);
521        assert_eq!(recs, vec![r]);
522    }
523
524    /// Pin discriminants 10 (CreateView) and 11 (DeleteView).
525    ///
526    /// **If this test fails you have broken every existing database file.**
527    /// WAL variants must ONLY be appended — never reordered or inserted.
528    #[test]
529    fn golden_bytes_pin_view_wire_format() {
530        // ── Variant 10: CreateView { def_bytes: [0xDE, 0xAD] } ───────────────
531        let create_view = WalRecord::CreateView {
532            def_bytes: vec![0xDE, 0xAD],
533        };
534        let cv_payload = bincode::serialize(&create_view).unwrap();
535        // discriminant must be 10 (0x0a 0x00 0x00 0x00 in LE)
536        assert_eq!(
537            &cv_payload[0..4],
538            &[10, 0, 0, 0],
539            "CreateView discriminant changed — a variant was inserted before position 10"
540        );
541
542        // ── Variant 11: DeleteView { name: "v" } ─────────────────────────────
543        let delete_view = WalRecord::DeleteView { name: "v".into() };
544        let dv_payload = bincode::serialize(&delete_view).unwrap();
545        assert_eq!(
546            &dv_payload[0..4],
547            &[11, 0, 0, 0],
548            "DeleteView discriminant changed — a variant was inserted before position 11"
549        );
550
551        // Roundtrip both through encode_record / decode_all.
552        let mut buf = encode_record(&create_view);
553        buf.extend(encode_record(&delete_view));
554        let (recs, consumed) = decode_all(&buf);
555        assert_eq!(consumed, buf.len());
556        assert_eq!(recs.len(), 2);
557        assert_eq!(
558            recs[0],
559            WalRecord::CreateView {
560                def_bytes: vec![0xDE, 0xAD]
561            }
562        );
563        assert_eq!(recs[1], WalRecord::DeleteView { name: "v".into() });
564    }
565
566    // ── Task full-text-lite: new variant roundtrips + wire pin ────────────────
567
568    #[test]
569    fn roundtrip_enable_fulltext() {
570        let r = WalRecord::EnableFulltext {
571            label: "Person".into(),
572            field: "bio".into(),
573        };
574        let bytes = encode_record(&r);
575        let (recs, _) = decode_all(&bytes);
576        assert_eq!(recs, vec![r]);
577    }
578
579    #[test]
580    fn roundtrip_disable_fulltext() {
581        let r = WalRecord::DisableFulltext {
582            label: "Person".into(),
583            field: "bio".into(),
584        };
585        let bytes = encode_record(&r);
586        let (recs, _) = decode_all(&bytes);
587        assert_eq!(recs, vec![r]);
588    }
589
590    /// Pin discriminants 12 (EnableFulltext) and 13 (DisableFulltext).
591    ///
592    /// **If this test fails you have broken every existing database file.**
593    /// WAL variants must ONLY be appended — never reordered or inserted.
594    #[test]
595    fn golden_bytes_pin_fulltext_wire_format() {
596        // ── Variant 12: EnableFulltext { label: "A", field: "b" } ────────────
597        let enable = WalRecord::EnableFulltext {
598            label: "A".into(),
599            field: "b".into(),
600        };
601        let ep = bincode::serialize(&enable).unwrap();
602        assert_eq!(
603            &ep[0..4],
604            &[12, 0, 0, 0],
605            "EnableFulltext discriminant changed — a variant was inserted before position 12"
606        );
607
608        // ── Variant 13: DisableFulltext { label: "A", field: "b" } ───────────
609        let disable = WalRecord::DisableFulltext {
610            label: "A".into(),
611            field: "b".into(),
612        };
613        let dp = bincode::serialize(&disable).unwrap();
614        assert_eq!(
615            &dp[0..4],
616            &[13, 0, 0, 0],
617            "DisableFulltext discriminant changed — a variant was inserted before position 13"
618        );
619
620        // Roundtrip both through encode_record / decode_all.
621        let mut buf = encode_record(&enable);
622        buf.extend(encode_record(&disable));
623        let (recs, consumed) = decode_all(&buf);
624        assert_eq!(consumed, buf.len());
625        assert_eq!(recs.len(), 2);
626        assert_eq!(
627            recs[0],
628            WalRecord::EnableFulltext {
629                label: "A".into(),
630                field: "b".into()
631            }
632        );
633        assert_eq!(
634            recs[1],
635            WalRecord::DisableFulltext {
636                label: "A".into(),
637                field: "b".into()
638            }
639        );
640    }
641
642    /// Pin discriminants 21 (EnableIndex) and 22 (DisableIndex).
643    ///
644    /// **If this test fails you have broken every existing database file.**
645    /// WAL variants must ONLY be appended — never reordered or inserted.
646    #[test]
647    fn golden_bytes_pin_property_index_wire_format() {
648        let enable = WalRecord::EnableIndex {
649            label: "A".into(),
650            field: "b".into(),
651        };
652        let ep = bincode::serialize(&enable).unwrap();
653        assert_eq!(
654            &ep[0..4],
655            &[21, 0, 0, 0],
656            "EnableIndex discriminant changed — a variant was inserted before position 21"
657        );
658
659        let disable = WalRecord::DisableIndex {
660            label: "A".into(),
661            field: "b".into(),
662        };
663        let dp = bincode::serialize(&disable).unwrap();
664        assert_eq!(
665            &dp[0..4],
666            &[22, 0, 0, 0],
667            "DisableIndex discriminant changed — a variant was inserted before position 22"
668        );
669
670        let mut buf = encode_record(&enable);
671        buf.extend(encode_record(&disable));
672        let (recs, consumed) = decode_all(&buf);
673        assert_eq!(consumed, buf.len());
674        assert_eq!(recs, vec![enable, disable]);
675    }
676
677    #[test]
678    fn roundtrip_dense_id_variants_append_after_fulltext() {
679        let recs = vec![
680            WalRecord::Intern {
681                id: 0,
682                text: "Person".into(),
683            },
684            WalRecord::InsertNodeId {
685                label: 0,
686                key: "a".into(),
687                props: vec![(1, Value::Int(1))],
688            },
689            WalRecord::SetPropId {
690                id: 0,
691                field: 1,
692                value: Value::Int(2),
693            },
694            WalRecord::InsertEdgeId {
695                etype: 2,
696                src: 0,
697                dst: 1,
698            },
699        ];
700        for r in &recs {
701            let bytes = encode_record(r);
702            let (got, n) = decode_all(&bytes);
703            assert_eq!(n, bytes.len());
704            assert_eq!(got, vec![r.clone()]);
705        }
706        let p = bincode::serialize(&recs[1]).unwrap();
707        assert_eq!(&p[0..4], &[14, 0, 0, 0], "InsertNodeId discriminant is 14");
708        let p = bincode::serialize(&recs[2]).unwrap();
709        assert_eq!(&p[0..4], &[15, 0, 0, 0], "SetPropId discriminant is 15");
710        let p = bincode::serialize(&recs[3]).unwrap();
711        assert_eq!(&p[0..4], &[16, 0, 0, 0], "InsertEdgeId discriminant is 16");
712        let p = bincode::serialize(&recs[0]).unwrap();
713        assert_eq!(&p[0..4], &[17, 0, 0, 0], "Intern discriminant is 17");
714    }
715
716    #[test]
717    fn history_marker_discriminants_pinned() {
718        // DerivedEdgeAdded and DerivedEdgeRetracted are history-marker variants
719        // appended to the WAL for rule attribution; their discriminants must
720        // never shift (any insertion before them would corrupt existing WAL files).
721        let added = WalRecord::DerivedEdgeAdded {
722            rule: "r".into(),
723            edge_type: "T".into(),
724            src_key: "a".into(),
725            dst_key: "b".into(),
726        };
727        let retracted = WalRecord::DerivedEdgeRetracted {
728            rule: "r".into(),
729            edge_type: "T".into(),
730            src_key: "a".into(),
731            dst_key: "b".into(),
732        };
733        let pa = bincode::serialize(&added).unwrap();
734        assert_eq!(
735            &pa[0..4],
736            &[18, 0, 0, 0],
737            "DerivedEdgeAdded discriminant changed — a variant was inserted before position 18"
738        );
739        let pr = bincode::serialize(&retracted).unwrap();
740        assert_eq!(
741            &pr[0..4],
742            &[19, 0, 0, 0],
743            "DerivedEdgeRetracted discriminant changed — a variant was inserted before position 19"
744        );
745        // Roundtrip both through encode_record / decode_all.
746        let mut buf = encode_record(&added);
747        buf.extend(encode_record(&retracted));
748        let (recs, consumed) = decode_all(&buf);
749        assert_eq!(consumed, buf.len());
750        assert_eq!(recs.len(), 2);
751        assert_eq!(
752            recs[0],
753            WalRecord::DerivedEdgeAdded {
754                rule: "r".into(),
755                edge_type: "T".into(),
756                src_key: "a".into(),
757                dst_key: "b".into(),
758            }
759        );
760        assert_eq!(
761            recs[1],
762            WalRecord::DerivedEdgeRetracted {
763                rule: "r".into(),
764                edge_type: "T".into(),
765                src_key: "a".into(),
766                dst_key: "b".into(),
767            }
768        );
769    }
770
771    /// Pin discriminant 20 (RenameNode).
772    ///
773    /// **If this test fails you have broken every existing database file.**
774    /// WAL variants must ONLY be appended — never reordered or inserted.
775    #[test]
776    fn rename_node_discriminant_pinned() {
777        let r = WalRecord::RenameNode {
778            old_key: "a".into(),
779            new_key: "b".into(),
780        };
781        let payload = bincode::serialize(&r).unwrap();
782        assert_eq!(
783            &payload[0..4],
784            &[20, 0, 0, 0],
785            "RenameNode discriminant changed — a variant was inserted before position 20"
786        );
787        // Roundtrip through encode_record / decode_all.
788        let bytes = encode_record(&r);
789        let (recs, consumed) = decode_all(&bytes);
790        assert_eq!(consumed, bytes.len());
791        assert_eq!(recs.len(), 1);
792        assert_eq!(
793            recs[0],
794            WalRecord::RenameNode {
795                old_key: "a".into(),
796                new_key: "b".into(),
797            }
798        );
799    }
800
801    #[test]
802    fn nested_batch_decode_is_treated_as_corrupt() {
803        // Manually encode a Batch whose payload contains a nested Batch by
804        // serializing the raw bincode bytes, bypassing encode_record's assert.
805        let inner_batch = WalRecord::Batch(vec![WalRecord::DeleteNode { key: "z".into() }]);
806        let outer = WalRecord::Batch(vec![inner_batch]);
807
808        // Encode the payload without the debug assert by calling bincode directly.
809        let payload = bincode::serialize(&outer).unwrap();
810        let crc = crc32fast::hash(&payload);
811        let mut frame = Vec::with_capacity(8 + payload.len());
812        frame.extend((payload.len() as u32).to_le_bytes());
813        frame.extend(crc.to_le_bytes());
814        frame.extend(&payload);
815
816        // Prepend a valid record so we can verify the stop position.
817        let good = encode_record(&WalRecord::DeleteNode { key: "good".into() });
818        let good_len = good.len();
819        let mut bytes = good;
820        bytes.extend(&frame);
821
822        let (recs, consumed) = decode_all(&bytes);
823        assert_eq!(recs.len(), 1);
824        assert_eq!(recs[0], WalRecord::DeleteNode { key: "good".into() });
825        assert_eq!(
826            consumed, good_len,
827            "stops cleanly before the nested-batch frame"
828        );
829    }
830}