2026-07-18 09:38:16 +00:00
|
|
|
//! Views: read-time projections over a stored stream (spec §8).
|
2026-06-05 07:25:43 +00:00
|
|
|
|
|
|
|
|
use std::fmt;
|
|
|
|
|
|
|
|
|
|
use super::frame::{ChannelId, Frame, Position};
|
2026-06-23 15:42:28 +00:00
|
|
|
use super::record::{ChannelClassifier, ChannelKind, Record};
|
2026-06-05 07:25:43 +00:00
|
|
|
use super::store::{GapSpan, StoredStream};
|
|
|
|
|
|
|
|
|
|
/// One entry on the merged timeline: either a frame or a surfaced gap.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
pub enum LogEntry {
|
|
|
|
|
/// A stored frame, its payload decoded for display.
|
|
|
|
|
Frame(MergedFrame),
|
2026-07-12 06:14:34 +00:00
|
|
|
/// A run of positions that were assigned but never delivered.
|
2026-06-05 07:25:43 +00:00
|
|
|
Gap(GapSpan),
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// A frame as the merged log presents it.
|
2026-06-05 07:25:43 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
pub struct MergedFrame {
|
|
|
|
|
/// The frame's position on the node's single timeline.
|
|
|
|
|
pub position: Position,
|
2026-07-12 06:14:34 +00:00
|
|
|
/// The stream-local numeric channel id.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub channel: ChannelId,
|
2026-07-12 06:14:34 +00:00
|
|
|
/// The payload, decoded per caller metadata or degraded to bytes.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub body: Body,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// A payload decoded as far as the caller's classifier allows.
|
2026-06-05 07:25:43 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
pub enum Body {
|
|
|
|
|
/// A typed channel decoded to a structured value.
|
|
|
|
|
Record(serde_json::Value),
|
|
|
|
|
/// A raw-text channel as its line(s).
|
|
|
|
|
Text(String),
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Unknown or invalid payload bytes.
|
2026-06-05 07:25:43 +00:00
|
|
|
Raw(Vec<u8>),
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Decode a payload for display with a caller-owned name classifier.
|
2026-06-23 15:42:28 +00:00
|
|
|
pub fn decode_body_with<C: ChannelClassifier + ?Sized>(
|
2026-07-12 06:14:34 +00:00
|
|
|
channel_name: &str,
|
2026-06-23 15:42:28 +00:00
|
|
|
payload: &[u8],
|
|
|
|
|
classifier: &C,
|
|
|
|
|
) -> Body {
|
2026-07-12 06:14:34 +00:00
|
|
|
match classifier.classify(channel_name) {
|
2026-06-05 07:25:43 +00:00
|
|
|
ChannelKind::Typed => match serde_json::from_slice::<serde_json::Value>(payload) {
|
|
|
|
|
Ok(value) => Body::Record(value),
|
|
|
|
|
Err(_) => Body::Raw(payload.to_vec()),
|
|
|
|
|
},
|
|
|
|
|
ChannelKind::Text => match std::str::from_utf8(payload) {
|
|
|
|
|
Ok(text) => Body::Text(text.to_string()),
|
|
|
|
|
Err(_) => Body::Raw(payload.to_vec()),
|
|
|
|
|
},
|
|
|
|
|
ChannelKind::Opaque => Body::Raw(payload.to_vec()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Decode a payload with no caller registry. Unknown/raw is the safe default.
|
|
|
|
|
pub fn decode_body(_channel: &ChannelId, payload: &[u8]) -> Body {
|
|
|
|
|
Body::Raw(payload.to_vec())
|
2026-06-23 15:42:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
fn timeline_with_resolver<C, R>(
|
2026-06-23 15:42:28 +00:00
|
|
|
stream: &StoredStream,
|
|
|
|
|
classifier: &C,
|
2026-07-12 06:14:34 +00:00
|
|
|
resolve_name: R,
|
|
|
|
|
) -> Vec<LogEntry>
|
|
|
|
|
where
|
|
|
|
|
C: ChannelClassifier + ?Sized,
|
|
|
|
|
R: Fn(ChannelId) -> Option<String>,
|
|
|
|
|
{
|
2026-07-18 09:38:16 +00:00
|
|
|
// Capacity covers stored frames; surfaced gaps may add extra log entries.
|
2026-06-05 07:25:43 +00:00
|
|
|
let mut out = Vec::with_capacity(stream.len());
|
|
|
|
|
let mut prev: Option<u64> = None;
|
|
|
|
|
for frame in stream.frames() {
|
|
|
|
|
let pos = frame.position.0;
|
|
|
|
|
if let Some(p) = prev
|
|
|
|
|
&& pos > p + 1
|
|
|
|
|
{
|
2026-06-23 15:42:28 +00:00
|
|
|
out.push(LogEntry::Gap(GapSpan {
|
|
|
|
|
start: p + 1,
|
|
|
|
|
end: pos - 1,
|
|
|
|
|
}));
|
2026-06-05 07:25:43 +00:00
|
|
|
}
|
2026-07-12 06:14:34 +00:00
|
|
|
let body = match resolve_name(frame.channel) {
|
|
|
|
|
Some(name) => decode_body_with(&name, &frame.payload, classifier),
|
|
|
|
|
None => Body::Raw(frame.payload.clone()),
|
|
|
|
|
};
|
2026-06-05 07:25:43 +00:00
|
|
|
out.push(LogEntry::Frame(MergedFrame {
|
|
|
|
|
position: frame.position,
|
2026-07-12 06:14:34 +00:00
|
|
|
channel: frame.channel,
|
|
|
|
|
body,
|
2026-06-05 07:25:43 +00:00
|
|
|
}));
|
|
|
|
|
prev = Some(pos);
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Full merged log view with raw payload bodies.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub fn merged_log(stream: &StoredStream) -> Vec<LogEntry> {
|
2026-07-12 06:14:34 +00:00
|
|
|
timeline_with_resolver(stream, &|_: &str| ChannelKind::Opaque, |_| None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Full merged log using a caller-owned classifier and name resolver.
|
|
|
|
|
pub fn merged_log_with_names<C, R>(
|
|
|
|
|
stream: &StoredStream,
|
|
|
|
|
classifier: &C,
|
|
|
|
|
resolve_name: R,
|
|
|
|
|
) -> Vec<LogEntry>
|
|
|
|
|
where
|
|
|
|
|
C: ChannelClassifier + ?Sized,
|
|
|
|
|
R: Fn(ChannelId) -> Option<String>,
|
|
|
|
|
{
|
|
|
|
|
timeline_with_resolver(stream, classifier, resolve_name)
|
2026-06-23 15:42:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:38:16 +00:00
|
|
|
/// Transitional helper for callers that still classify by rendered numeric ids;
|
|
|
|
|
/// named decoding should use [`merged_log_with_names`].
|
2026-06-23 15:42:28 +00:00
|
|
|
pub fn merged_log_with<C: ChannelClassifier + ?Sized>(
|
|
|
|
|
stream: &StoredStream,
|
|
|
|
|
classifier: &C,
|
|
|
|
|
) -> Vec<LogEntry> {
|
2026-07-12 06:14:34 +00:00
|
|
|
timeline_with_resolver(stream, classifier, |channel| Some(channel.to_string()))
|
2026-06-05 07:25:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Replay the timeline with raw payload bodies.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub fn replay(stream: &StoredStream) -> impl Iterator<Item = LogEntry> {
|
2026-07-12 06:14:34 +00:00
|
|
|
merged_log(stream).into_iter()
|
2026-06-05 07:25:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Decode one typed channel into a time series.
|
|
|
|
|
pub fn metric_series_on<R: Record>(
|
|
|
|
|
stream: &StoredStream,
|
|
|
|
|
channel: ChannelId,
|
|
|
|
|
) -> Vec<(Position, R)> {
|
2026-06-05 07:25:43 +00:00
|
|
|
stream
|
|
|
|
|
.frames()
|
|
|
|
|
.filter(|f| f.channel == channel)
|
2026-06-23 15:42:28 +00:00
|
|
|
.filter_map(|f| {
|
|
|
|
|
R::decode(&f.payload)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|record| (f.position, record))
|
|
|
|
|
})
|
2026-06-05 07:25:43 +00:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Decode every frame whose payload parses as `R`.
|
|
|
|
|
pub fn metric_series<R: Record>(stream: &StoredStream) -> Vec<(Position, R)> {
|
|
|
|
|
stream
|
|
|
|
|
.frames()
|
|
|
|
|
.filter_map(|f| {
|
|
|
|
|
R::decode(&f.payload)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|record| (f.position, record))
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The last `n` frames in position order.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub fn tail(stream: &StoredStream, n: usize) -> Vec<Frame> {
|
|
|
|
|
let all = stream.to_vec();
|
|
|
|
|
let start = all.len().saturating_sub(n);
|
|
|
|
|
all[start..].to_vec()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Frames matching a predicate, in position order.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub fn filter<F>(stream: &StoredStream, predicate: F) -> Vec<Frame>
|
|
|
|
|
where
|
|
|
|
|
F: Fn(&Frame) -> bool,
|
|
|
|
|
{
|
|
|
|
|
stream.frames().filter(|f| predicate(f)).cloned().collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
/// Frames whose payload text contains `needle`.
|
2026-06-05 07:25:43 +00:00
|
|
|
pub fn grep(stream: &StoredStream, needle: &str) -> Vec<Frame> {
|
2026-06-23 15:42:28 +00:00
|
|
|
filter(stream, |f| {
|
|
|
|
|
String::from_utf8_lossy(&f.payload).contains(needle)
|
|
|
|
|
})
|
2026-06-05 07:25:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for Body {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Body::Record(value) => write!(f, "{value}"),
|
|
|
|
|
Body::Text(text) => f.write_str(text),
|
|
|
|
|
Body::Raw(bytes) => write!(f, "<{} opaque bytes>", bytes.len()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for LogEntry {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
LogEntry::Frame(frame) => {
|
2026-06-23 15:42:28 +00:00
|
|
|
write!(
|
|
|
|
|
f,
|
|
|
|
|
"#{:<4} [{}] {}",
|
|
|
|
|
frame.position, frame.channel, frame.body
|
|
|
|
|
)
|
2026-06-05 07:25:43 +00:00
|
|
|
}
|
|
|
|
|
LogEntry::Gap(span) => {
|
2026-06-23 15:42:28 +00:00
|
|
|
write!(
|
|
|
|
|
f,
|
|
|
|
|
"#{:<4} ── gap: {} position(s) missing ──",
|
|
|
|
|
span.start,
|
|
|
|
|
span.count()
|
|
|
|
|
)
|
2026-06-05 07:25:43 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|