Rindle docs and package mapSkip to main content

rindle/
flat_protocol.rs

1//! The flat-change **subscription protocol**: the batch envelope, epoch/seq framing,
2//! and the receiver-side safety rules (`FLAT-CHANGES-DESIGN.md` §5.4/§5.5/§2.3).
3//!
4//! This is the layer that turns the parts — the flat change stream ([`crate::flat`]),
5//! the reference [`Receiver`] ([`crate::flat_receiver`]), and the wire schema +
6//! fingerprint ([`crate::wire_schema`]) — into something a transport carries.
7//!
8//! ## Shape
9//!
10//! - A subscription opens with a [`Hello`] (sent once): the `epoch`, the
11//!   [`COMPARATOR_VERSION`] contract, the [`WireSchema`], and its [`SchemaFp`].
12//! - The **hydrate snapshot** establishes the baseline (logical seq 0): either a single
13//!   [`Batch`] (seq 0) via [`Publisher::snapshot`], or — for a large `O(full result)`
14//!   snapshot — a sequence of [`SnapshotChunk`]s via [`Publisher::snapshot_chunks`]
15//!   terminated by a `last` marker (the receiver renders only after it).
16//! - Then incremental [`Batch`]es flow at **seq 1, 2, …** (one per non-empty
17//!   transaction). Each batch carries `{ epoch, seq, schema_fp, events }`; `events` apply
18//!   in array order and the receiver renders only after the whole batch.
19//!
20//! ## Sender ([`Publisher`])
21//!
22//! Drives the seq counter and stamps every batch with the subscription's `epoch` +
23//! `schema_fp`. **Empty transactions emit no batch and consume no seq** — so `seq` is
24//! gap-free *over emitted batches*, which is exactly what lets the receiver treat any
25//! gap as a lost batch.
26//!
27//! ## Receiver ([`Subscriber`])
28//!
29//! Wraps a [`Receiver`] and enforces (§2.3): the comparator contract (at [`Hello`]),
30//! and per batch — epoch match, schema-fingerprint match, and strict in-order seq.
31//! A **duplicate** (already-applied seq) is discarded idempotently (`rc` add/remove are
32//! NOT idempotent, so re-applying would corrupt). A **gap** (seq beyond the next
33//! expected) is unrecoverable from deltas — the sender cannot re-derive a missed one —
34//! so the only repair is a full re-hydrate under a **new epoch**; stale in-flight
35//! batches from the old epoch are then rejected by [`ProtocolError::EpochMismatch`].
36
37use crate::changes::CaughtChange;
38use crate::flat::{flatten_all, FlatChange};
39use crate::flat_receiver::{Receiver, RecvNode};
40use crate::value::Schema;
41use crate::wire_schema::{schema_fp, to_schema, to_wire, SchemaFp, WireSchema, COMPARATOR_VERSION};
42
43/// The subscription handshake, sent once before any [`Batch`].
44#[cfg_attr(
45    any(feature = "testkit", feature = "serde"),
46    derive(serde::Serialize, serde::Deserialize)
47)]
48#[derive(Clone, Debug)]
49pub struct Hello {
50    pub epoch: u64,
51    /// The `compare_values` algorithm-contract version the sender used (§4/§5.5).
52    pub comparator_version: u32,
53    pub schema: WireSchema,
54    pub schema_fp: SchemaFp,
55}
56
57/// One transaction's flat changes (or the seq-0 hydrate snapshot). `events` apply in
58/// order; the receiver renders only after the whole batch (`FLAT-CHANGES-DESIGN.md` §5.4).
59#[cfg_attr(
60    any(feature = "testkit", feature = "serde"),
61    derive(serde::Serialize, serde::Deserialize)
62)]
63#[derive(Clone, Debug)]
64pub struct Batch {
65    pub epoch: u64,
66    pub seq: u64,
67    pub schema_fp: SchemaFp,
68    pub events: Vec<FlatChange>,
69}
70
71/// One frame of a **chunked hydrate snapshot** (`FLAT-CHANGES-DESIGN.md` §5.4). The
72/// initial snapshot is `O(full result)` — the whole tree as top-level `Add`s (each with
73/// its inline subtree) — so it is paged across `SnapshotChunk`s rather than one giant
74/// batch. `adds` is a slice of those top-level `Add`s; `index` is gap-free within the
75/// snapshot; `last` is the `snapshot_complete` marker (the receiver renders only after
76/// it). All chunks carry the subscription `epoch` + `schema_fp` so a mid-snapshot drift
77/// is caught. A chunk's `adds` apply atomically (each carries its full subtree).
78#[cfg_attr(
79    any(feature = "testkit", feature = "serde"),
80    derive(serde::Serialize, serde::Deserialize)
81)]
82#[derive(Clone, Debug)]
83pub struct SnapshotChunk {
84    pub epoch: u64,
85    pub schema_fp: SchemaFp,
86    /// 0-based, gap-free within this snapshot.
87    pub index: u64,
88    /// The `snapshot_complete` marker — `true` on the final chunk.
89    pub last: bool,
90    /// Top-level `Add`s (each with its inline subtree) for this page.
91    pub adds: Vec<FlatChange>,
92}
93
94/// The outcome of applying a [`SnapshotChunk`].
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum SnapStatus {
97    /// Chunk applied; more chunks expected.
98    Accepted,
99    /// The final (`last`) chunk applied — the snapshot is complete; the tree may render
100    /// and incremental [`Batch`]es (seq ≥ 1) may follow.
101    Complete,
102    /// An already-applied (or post-completion) chunk — discarded idempotently.
103    Duplicate,
104}
105
106/// A protocol violation a [`Subscriber`] surfaces. All but a duplicate are fatal to the
107/// current subscription — the consumer must discard its tree and re-subscribe (the
108/// sender re-hydrates under a new epoch).
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum ProtocolError {
111    /// The sender's comparator contract differs — reconstruction would silently corrupt.
112    ComparatorMismatch { expected: u32, got: u32 },
113    /// A batch from a different (usually stale) subscription epoch.
114    EpochMismatch { expected: u64, got: u64 },
115    /// The batch (or advertised) schema fingerprint differs from the subscribed one.
116    SchemaMismatch { expected: SchemaFp, got: SchemaFp },
117    /// A seq beyond the next expected ⇒ a batch was lost. Unrecoverable from deltas;
118    /// re-hydrate. (`expected` is the next seq the receiver wanted.)
119    Gap { expected: u64, got: u64 },
120}
121
122/// The outcome of applying a [`Batch`].
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum Applied {
125    /// Applied; the reconstructed tree advanced.
126    Applied,
127    /// An already-applied seq (re-delivery) — discarded idempotently, tree unchanged.
128    Duplicate,
129}
130
131/// Sender side: stamps batches with the subscription `epoch` + `schema_fp` and drives
132/// the gap-free seq. Graph-agnostic — the caller drains the change-sink and hands the
133/// [`CaughtChange`]s here.
134pub struct Publisher {
135    epoch: u64,
136    schema: WireSchema,
137    schema_fp: SchemaFp,
138    next_seq: u64,
139}
140
141impl Publisher {
142    /// Open a publisher at `epoch` over a hierarchical view [`Schema`]. Bump `epoch`
143    /// for each fresh (re-)subscription so the receiver can reject stale batches.
144    pub fn new(epoch: u64, view_schema: &Schema) -> Publisher {
145        let schema = to_wire(view_schema);
146        let fp = schema_fp(&schema);
147        Publisher {
148            epoch,
149            schema,
150            schema_fp: fp,
151            next_seq: 0,
152        }
153    }
154
155    /// The handshake to send first.
156    pub fn hello(&self) -> Hello {
157        Hello {
158            epoch: self.epoch,
159            comparator_version: COMPARATOR_VERSION,
160            schema: self.schema.clone(),
161            schema_fp: self.schema_fp,
162        }
163    }
164
165    /// The hydrate snapshot (the flat `Add`s from
166    /// [`Graph::try_hydrate_change_sink`](crate::graph::Graph::try_hydrate_change_sink)) as a
167    /// single batch (seq 0). Always emitted — even for an empty result — so the receiver
168    /// learns the snapshot is complete. For a large result prefer [`snapshot_chunks`].
169    ///
170    /// [`snapshot_chunks`]: Publisher::snapshot_chunks
171    pub fn snapshot(&mut self, caught: &[CaughtChange]) -> Batch {
172        self.emit(caught)
173    }
174
175    /// The hydrate snapshot as a sequence of [`SnapshotChunk`]s of at most
176    /// `max_per_chunk` top-level entries each (the last chunk carries `last = true`).
177    /// Use this instead of [`snapshot`](Publisher::snapshot) when the result is large:
178    /// the snapshot is `O(full result)`, so a single batch can blow a transport frame /
179    /// pin peak memory. An empty result still yields one terminal (`last`) chunk so the
180    /// receiver gets the completion marker.
181    ///
182    /// Reserves the seq-0 slot for the baseline — incremental [`commit`](Publisher::commit)s
183    /// start at seq 1, exactly as after [`snapshot`](Publisher::snapshot). Call this
184    /// (or `snapshot`) once, **before** any `commit`.
185    pub fn snapshot_chunks(
186        &mut self,
187        caught: &[CaughtChange],
188        max_per_chunk: usize,
189    ) -> Vec<SnapshotChunk> {
190        assert!(max_per_chunk >= 1, "max_per_chunk must be >= 1");
191        // The baseline occupies the logical seq-0 slot; increments start at seq 1.
192        self.next_seq = 1;
193        let events = flatten_all(caught);
194        if events.is_empty() {
195            return vec![SnapshotChunk {
196                epoch: self.epoch,
197                schema_fp: self.schema_fp,
198                index: 0,
199                last: true,
200                adds: Vec::new(),
201            }];
202        }
203        let total = events.len().div_ceil(max_per_chunk);
204        events
205            .chunks(max_per_chunk)
206            .enumerate()
207            .map(|(i, slice)| SnapshotChunk {
208                epoch: self.epoch,
209                schema_fp: self.schema_fp,
210                index: i as u64,
211                last: i + 1 == total,
212                adds: slice.to_vec(),
213            })
214            .collect()
215    }
216
217    /// Wrap one transaction's drained changes
218    /// ([`Graph::take_sink_changes`](crate::graph::Graph::take_sink_changes)). Returns
219    /// `None` for an empty transaction — **no batch, no seq consumed** — keeping seq
220    /// gap-free over emitted batches.
221    pub fn commit(&mut self, caught: &[CaughtChange]) -> Option<Batch> {
222        if caught.is_empty() {
223            return None;
224        }
225        Some(self.emit(caught))
226    }
227
228    fn emit(&mut self, caught: &[CaughtChange]) -> Batch {
229        let seq = self.next_seq;
230        self.next_seq += 1;
231        Batch {
232            epoch: self.epoch,
233            seq,
234            schema_fp: self.schema_fp,
235            events: flatten_all(caught),
236        }
237    }
238
239    /// The subscription epoch.
240    pub fn epoch(&self) -> u64 {
241        self.epoch
242    }
243}
244
245/// The subscriber's lifecycle: establishing the baseline, then live increments.
246#[derive(Clone, Copy, Debug)]
247enum Phase {
248    /// Establishing the baseline. `next_chunk` is the next [`SnapshotChunk`] index
249    /// expected; `0` also admits a single-shot [`snapshot`](Publisher::snapshot) batch.
250    Snapshot { next_chunk: u64 },
251    /// Baseline established; only incremental [`Batch`]es (seq ≥ 1) are accepted.
252    Live { last_seq: u64 },
253}
254
255/// Receiver side: a [`Receiver`] plus the protocol state, enforcing the §2.3/§5.4 rules.
256/// The baseline is established by either a single-shot [`apply`](Subscriber::apply) of
257/// the seq-0 snapshot batch **or** a sequence of [`apply_snapshot_chunk`] calls; then
258/// incremental batches flow.
259///
260/// [`apply_snapshot_chunk`]: Subscriber::apply_snapshot_chunk
261#[derive(Debug)]
262pub struct Subscriber {
263    epoch: u64,
264    schema_fp: SchemaFp,
265    recv: Receiver,
266    phase: Phase,
267}
268
269impl Subscriber {
270    /// Open from a [`Hello`]: hard-reject a comparator-contract mismatch (§5.5), verify
271    /// the advertised fingerprint matches the shipped schema, and build the receiver
272    /// from that schema. Apply the snapshot next (single-shot or chunked).
273    pub fn open(hello: &Hello) -> Result<Subscriber, ProtocolError> {
274        if hello.comparator_version != COMPARATOR_VERSION {
275            return Err(ProtocolError::ComparatorMismatch {
276                expected: COMPARATOR_VERSION,
277                got: hello.comparator_version,
278            });
279        }
280        // Defensive: the advertised fp must equal the fp of the shipped schema.
281        let computed = schema_fp(&hello.schema);
282        if computed != hello.schema_fp {
283            return Err(ProtocolError::SchemaMismatch {
284                expected: hello.schema_fp,
285                got: computed,
286            });
287        }
288        Ok(Subscriber {
289            epoch: hello.epoch,
290            schema_fp: hello.schema_fp,
291            recv: Receiver::new(to_schema(&hello.schema)),
292            phase: Phase::Snapshot { next_chunk: 0 },
293        })
294    }
295
296    #[inline]
297    fn check_frame(&self, epoch: u64, fp: SchemaFp) -> Result<(), ProtocolError> {
298        if epoch != self.epoch {
299            return Err(ProtocolError::EpochMismatch {
300                expected: self.epoch,
301                got: epoch,
302            });
303        }
304        if fp != self.schema_fp {
305            return Err(ProtocolError::SchemaMismatch {
306                expected: self.schema_fp,
307                got: fp,
308            });
309        }
310        Ok(())
311    }
312
313    /// Apply one [`SnapshotChunk`] of a chunked hydrate snapshot. Validates epoch +
314    /// schema-fp + strict in-order chunk index, applies its `adds`, and on the `last`
315    /// chunk transitions to live (snapshot complete — the tree may render).
316    /// A re-delivered (or post-completion) chunk is a no-op [`SnapStatus::Duplicate`];
317    /// a chunk-index gap is a fatal [`ProtocolError::Gap`] (re-hydrate).
318    pub fn apply_snapshot_chunk(
319        &mut self,
320        chunk: &SnapshotChunk,
321    ) -> Result<SnapStatus, ProtocolError> {
322        self.check_frame(chunk.epoch, chunk.schema_fp)?;
323        match &mut self.phase {
324            // Snapshot already complete — any further chunk is a stale re-delivery.
325            Phase::Live { .. } => Ok(SnapStatus::Duplicate),
326            Phase::Snapshot { next_chunk } => {
327                if chunk.index < *next_chunk {
328                    return Ok(SnapStatus::Duplicate);
329                }
330                if chunk.index > *next_chunk {
331                    return Err(ProtocolError::Gap {
332                        expected: *next_chunk,
333                        got: chunk.index,
334                    });
335                }
336                self.recv.apply_all(&chunk.adds);
337                if chunk.last {
338                    self.phase = Phase::Live { last_seq: 0 };
339                    Ok(SnapStatus::Complete)
340                } else {
341                    *next_chunk += 1;
342                    Ok(SnapStatus::Accepted)
343                }
344            }
345        }
346    }
347
348    /// Apply one incremental [`Batch`] (or the single-shot seq-0 snapshot batch).
349    /// Validates epoch + schema-fp + strict in-order seq, then folds the events in
350    /// order. A duplicate (already-applied seq) is a no-op [`Applied::Duplicate`]; a gap
351    /// is a fatal [`ProtocolError::Gap`] (re-hydrate).
352    pub fn apply(&mut self, batch: &Batch) -> Result<Applied, ProtocolError> {
353        self.check_frame(batch.epoch, batch.schema_fp)?;
354        match &mut self.phase {
355            Phase::Snapshot { next_chunk } => {
356                // Single-shot baseline: only valid before any chunk, at seq 0.
357                if *next_chunk == 0 && batch.seq == 0 {
358                    self.recv.apply_all(&batch.events);
359                    self.phase = Phase::Live { last_seq: 0 };
360                    Ok(Applied::Applied)
361                } else {
362                    // Mid chunked-snapshot (last chunk lost) or baseline missing —
363                    // unrecoverable from deltas; re-hydrate.
364                    Err(ProtocolError::Gap {
365                        expected: 0,
366                        got: batch.seq,
367                    })
368                }
369            }
370            Phase::Live { last_seq } => {
371                let expected = *last_seq + 1;
372                if batch.seq < expected {
373                    // Already applied (re-delivery). `rc` ops are not idempotent — discard.
374                    return Ok(Applied::Duplicate);
375                }
376                if batch.seq > expected {
377                    return Err(ProtocolError::Gap {
378                        expected,
379                        got: batch.seq,
380                    });
381                }
382                self.recv.apply_all(&batch.events);
383                *last_seq = batch.seq;
384                Ok(Applied::Applied)
385            }
386        }
387    }
388
389    /// The reconstructed top-level result (`root[""]`). Valid to read once the snapshot
390    /// is complete ([`snapshot_complete`](Subscriber::snapshot_complete)); mid-snapshot
391    /// it is a partial tree (render only after completion — §5.4).
392    pub fn top(&self) -> &[RecvNode] {
393        self.recv.top()
394    }
395
396    /// The subscription epoch this subscriber accepts.
397    pub fn epoch(&self) -> u64 {
398        self.epoch
399    }
400
401    /// Whether the hydrate snapshot is complete (the tree may render).
402    pub fn snapshot_complete(&self) -> bool {
403        matches!(self.phase, Phase::Live { .. })
404    }
405
406    /// The last applied incremental seq (`None` until the snapshot is complete; `Some(0)`
407    /// immediately after the snapshot, then the last applied batch seq).
408    pub fn last_seq(&self) -> Option<u64> {
409        match self.phase {
410            Phase::Live { last_seq } => Some(last_seq),
411            Phase::Snapshot { .. } => None,
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::changes::{CaughtChange, CaughtNode};
420    use crate::value::{owned_row, OwnedValue as V, Schema};
421
422    fn schema() -> Schema {
423        Schema::new(vec!["id", "val"], vec![0], vec![(0, true)])
424    }
425    fn add(id: i64, val: i64) -> CaughtChange {
426        CaughtChange::Add(CaughtNode {
427            row: owned_row(vec![V::Int(id), V::Int(val)]),
428            relationships: Default::default(),
429        })
430    }
431    fn ids(top: &[RecvNode]) -> Vec<i64> {
432        top.iter()
433            .map(|n| match n.row.col(0) {
434                crate::value::Value::Int(i) => i,
435                o => panic!("{o:?}"),
436            })
437            .collect()
438    }
439
440    #[test]
441    fn open_rejects_comparator_mismatch() {
442        let pubr = Publisher::new(1, &schema());
443        let mut hello = pubr.hello();
444        hello.comparator_version = COMPARATOR_VERSION + 1;
445        assert_eq!(
446            Subscriber::open(&hello).unwrap_err(),
447            ProtocolError::ComparatorMismatch {
448                expected: COMPARATOR_VERSION,
449                got: COMPARATOR_VERSION + 1,
450            }
451        );
452    }
453
454    #[test]
455    fn open_rejects_forged_fingerprint() {
456        let pubr = Publisher::new(1, &schema());
457        let mut hello = pubr.hello();
458        hello.schema_fp = SchemaFp(hello.schema_fp.0 ^ 0xdead); // lie about the fp
459        assert!(matches!(
460            Subscriber::open(&hello),
461            Err(ProtocolError::SchemaMismatch { .. })
462        ));
463    }
464
465    #[test]
466    fn snapshot_then_in_order_commits_apply() {
467        let mut pubr = Publisher::new(1, &schema());
468        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
469
470        let snap = pubr.snapshot(&[add(1, 10), add(2, 20)]);
471        assert_eq!(snap.seq, 0);
472        assert_eq!(sub.apply(&snap), Ok(Applied::Applied));
473        assert_eq!(ids(sub.top()), vec![1, 2]);
474
475        let b1 = pubr.commit(&[add(3, 30)]).expect("non-empty");
476        assert_eq!(b1.seq, 1);
477        assert_eq!(sub.apply(&b1), Ok(Applied::Applied));
478        assert_eq!(ids(sub.top()), vec![1, 2, 3]);
479        assert_eq!(sub.last_seq(), Some(1));
480    }
481
482    #[test]
483    fn empty_commit_emits_no_batch_and_keeps_seq_gap_free() {
484        let mut pubr = Publisher::new(1, &schema());
485        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
486        sub.apply(&pubr.snapshot(&[add(1, 10)])).unwrap(); // seq 0
487
488        assert!(pubr.commit(&[]).is_none(), "empty txn → no batch");
489        // The next real commit is still seq 1 (the empty txn consumed nothing).
490        let b = pubr.commit(&[add(2, 20)]).expect("non-empty");
491        assert_eq!(b.seq, 1);
492        assert_eq!(sub.apply(&b), Ok(Applied::Applied));
493        assert_eq!(ids(sub.top()), vec![1, 2]);
494    }
495
496    #[test]
497    fn gap_is_rejected() {
498        let mut pubr = Publisher::new(1, &schema());
499        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
500        sub.apply(&pubr.snapshot(&[add(1, 10)])).unwrap(); // seq 0, expected next = 1
501
502        // Fabricate a seq-2 batch (seq 1 was lost in transit).
503        let mut b2 = pubr.commit(&[add(2, 20)]).unwrap(); // really seq 1
504        b2.seq = 2;
505        assert_eq!(
506            sub.apply(&b2),
507            Err(ProtocolError::Gap {
508                expected: 1,
509                got: 2
510            })
511        );
512        // The tree did not advance.
513        assert_eq!(ids(sub.top()), vec![1]);
514    }
515
516    #[test]
517    fn duplicate_is_idempotently_discarded() {
518        let mut pubr = Publisher::new(1, &schema());
519        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
520        let snap = pubr.snapshot(&[add(1, 10)]);
521        sub.apply(&snap).unwrap(); // seq 0
522        let b1 = pubr.commit(&[add(2, 20)]).unwrap();
523        sub.apply(&b1).unwrap(); // seq 1
524
525        // Re-deliver seq 1: must NOT double-apply (rc add is not idempotent).
526        assert_eq!(sub.apply(&b1), Ok(Applied::Duplicate));
527        assert_eq!(ids(sub.top()), vec![1, 2]);
528        assert_eq!(sub.top()[1].rc, 1, "duplicate add must not bump rc");
529        // Re-deliver the snapshot (seq 0) too → still a no-op duplicate.
530        assert_eq!(sub.apply(&snap), Ok(Applied::Duplicate));
531        assert_eq!(ids(sub.top()), vec![1, 2]);
532    }
533
534    #[test]
535    fn stale_epoch_batch_is_rejected_after_resubscribe() {
536        // Epoch-1 subscription.
537        let mut pub1 = Publisher::new(1, &schema());
538        let mut sub1 = Subscriber::open(&pub1.hello()).unwrap();
539        sub1.apply(&pub1.snapshot(&[add(1, 10)])).unwrap();
540        let stale = pub1.commit(&[add(2, 20)]).unwrap(); // epoch 1, seq 1
541
542        // A gap forced a re-subscribe at epoch 2 (fresh snapshot of the current state).
543        let mut pub2 = Publisher::new(2, &schema());
544        let mut sub2 = Subscriber::open(&pub2.hello()).unwrap();
545        sub2.apply(&pub2.snapshot(&[add(1, 10), add(2, 20)]))
546            .unwrap();
547        assert_eq!(ids(sub2.top()), vec![1, 2]);
548
549        // The stale epoch-1 batch is rejected by the epoch-2 subscriber.
550        assert_eq!(
551            sub2.apply(&stale),
552            Err(ProtocolError::EpochMismatch {
553                expected: 2,
554                got: 1
555            })
556        );
557    }
558
559    #[test]
560    fn chunked_snapshot_assembles_then_increments() {
561        let mut pubr = Publisher::new(1, &schema());
562        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
563
564        // Five entries, two per chunk → 3 chunks (indices 0,1,2; last on index 2).
565        let chunks = pubr.snapshot_chunks(
566            &[add(1, 10), add(2, 20), add(3, 30), add(4, 40), add(5, 50)],
567            2,
568        );
569        assert_eq!(chunks.len(), 3);
570        assert!(!chunks[0].last && !chunks[1].last && chunks[2].last);
571
572        assert_eq!(
573            sub.apply_snapshot_chunk(&chunks[0]),
574            Ok(SnapStatus::Accepted)
575        );
576        assert!(!sub.snapshot_complete());
577        assert_eq!(
578            sub.apply_snapshot_chunk(&chunks[1]),
579            Ok(SnapStatus::Accepted)
580        );
581        assert_eq!(
582            sub.apply_snapshot_chunk(&chunks[2]),
583            Ok(SnapStatus::Complete)
584        );
585        assert!(sub.snapshot_complete());
586        assert_eq!(ids(sub.top()), vec![1, 2, 3, 4, 5]);
587        assert_eq!(sub.last_seq(), Some(0));
588
589        // Increments resume at seq 1 (the baseline reserved seq 0).
590        let b = pubr.commit(&[add(6, 60)]).expect("non-empty");
591        assert_eq!(b.seq, 1);
592        assert_eq!(sub.apply(&b), Ok(Applied::Applied));
593        assert_eq!(ids(sub.top()), vec![1, 2, 3, 4, 5, 6]);
594    }
595
596    #[test]
597    fn empty_chunked_snapshot_still_completes() {
598        let mut pubr = Publisher::new(1, &schema());
599        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
600        let chunks = pubr.snapshot_chunks(&[], 4);
601        assert_eq!(chunks.len(), 1);
602        assert!(chunks[0].last);
603        assert_eq!(
604            sub.apply_snapshot_chunk(&chunks[0]),
605            Ok(SnapStatus::Complete)
606        );
607        assert!(sub.snapshot_complete());
608        assert!(sub.top().is_empty());
609    }
610
611    #[test]
612    fn snapshot_chunk_gap_and_duplicate() {
613        let mut pubr = Publisher::new(1, &schema());
614        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
615        let chunks = pubr.snapshot_chunks(&[add(1, 10), add(2, 20), add(3, 30)], 1);
616        assert_eq!(chunks.len(), 3);
617
618        sub.apply_snapshot_chunk(&chunks[0]).unwrap();
619        // Skipping chunk 1 (deliver chunk 2) → gap.
620        assert_eq!(
621            sub.apply_snapshot_chunk(&chunks[2]),
622            Err(ProtocolError::Gap {
623                expected: 1,
624                got: 2
625            })
626        );
627        // Re-deliver chunk 0 → duplicate, no double-apply (rc unchanged).
628        assert_eq!(
629            sub.apply_snapshot_chunk(&chunks[0]),
630            Ok(SnapStatus::Duplicate)
631        );
632        assert_eq!(sub.top()[0].rc, 1);
633        // In order from here completes.
634        sub.apply_snapshot_chunk(&chunks[1]).unwrap();
635        assert_eq!(
636            sub.apply_snapshot_chunk(&chunks[2]),
637            Ok(SnapStatus::Complete)
638        );
639        assert_eq!(ids(sub.top()), vec![1, 2, 3]);
640    }
641
642    #[test]
643    fn batch_mid_chunked_snapshot_is_a_gap() {
644        let mut pubr = Publisher::new(1, &schema());
645        let mut sub = Subscriber::open(&pubr.hello()).unwrap();
646        let chunks = pubr.snapshot_chunks(&[add(1, 10), add(2, 20)], 1);
647        sub.apply_snapshot_chunk(&chunks[0]).unwrap(); // chunk 0 of 2; not complete
648                                                       // An incremental batch before the snapshot completes → gap (the `last`
649                                                       // chunk was lost); the consumer must re-hydrate.
650        let b = pubr.commit(&[add(9, 90)]).expect("non-empty");
651        assert!(matches!(sub.apply(&b), Err(ProtocolError::Gap { .. })));
652    }
653}