use web_sys::FileSystemDirectoryHandle;
use crate::opfs_helpers::{
opfs_append_quota_guard, opfs_file_size, opfs_read_all, opfs_root,
};
pub use crate::opfs_helpers::OpfsError;
use super::{DataPoint, SeriesKey};
async fn opfs_walk_or_create(
root: &FileSystemDirectoryHandle,
key: &SeriesKey,
) -> Result<FileSystemDirectoryHandle, OpfsError> {
crate::opfs_helpers::opfs_walk_or_create_stream(
root,
&key.kind.slug(),
&key.exchange_label(),
&key.account_label(),
&key.symbol,
)
.await
}
pub struct DiskStore<T: DataPoint> {
key: SeriesKey,
current_day: String,
sym_dir: FileSystemDirectoryHandle,
dat_buf: Vec<u8>,
idx_buf: Vec<u8>,
blob_buf: Option<Vec<u8>>,
file_offset: u64,
blob_pos: u64,
records: u32,
idx_every: u32,
_phantom: std::marker::PhantomData<T>,
}
impl<T: DataPoint> DiskStore<T> {
pub async fn new(key: SeriesKey) -> Result<Self, OpfsError> {
Self::with_idx_every(key, 1024).await
}
pub async fn with_idx_every(key: SeriesKey, idx_every: u32) -> Result<Self, OpfsError> {
let storage = web_sys::window()
.ok_or(OpfsError::NoWindow)?
.navigator()
.storage();
if let Ok(p) = storage.persist() {
let _ = wasm_bindgen_futures::JsFuture::from(p).await;
}
let root = opfs_root().await?;
let day = utc_today_wasm();
let sym_dir = opfs_walk_or_create(&root, &key).await?;
let file_offset = opfs_file_size(&sym_dir, &format!("{day}.dat")).await?;
let blob_pos = if T::blob_pointer_offset().is_some() {
opfs_file_size(&sym_dir, &format!("{day}.blob")).await?
} else {
0
};
Ok(Self {
key,
current_day: day,
sym_dir,
dat_buf: Vec::new(),
idx_buf: Vec::new(),
blob_buf: if T::blob_pointer_offset().is_some() {
Some(Vec::new())
} else {
None
},
file_offset,
blob_pos,
records: 0,
idx_every: idx_every.max(1),
_phantom: std::marker::PhantomData,
})
}
pub fn append(&mut self, point: &T) {
let mut buf = vec![0u8; T::RECORD_SIZE];
point.encode(&mut buf);
if let (Some(blob_w), Some(tail_off)) =
(self.blob_buf.as_mut(), T::blob_pointer_offset())
{
if let Some(blob_bytes) = point.encode_blob() {
let off = self.blob_pos;
let len = blob_bytes.len() as u32;
blob_w.extend_from_slice(&blob_bytes);
self.blob_pos += blob_bytes.len() as u64;
buf[tail_off..tail_off + 8].copy_from_slice(&off.to_le_bytes());
buf[tail_off + 8..tail_off + 12].copy_from_slice(&len.to_le_bytes());
}
}
if self.records % self.idx_every == 0 {
let mut idx_entry = [0u8; 16];
idx_entry[0..8].copy_from_slice(&(point.timestamp_ms() as u64).to_le_bytes());
idx_entry[8..16].copy_from_slice(&self.file_offset.to_le_bytes());
self.idx_buf.extend_from_slice(&idx_entry);
}
self.dat_buf.extend_from_slice(&buf);
self.records = self.records.wrapping_add(1);
self.file_offset += T::RECORD_SIZE as u64;
}
pub fn append_batch(&mut self, points: &[T]) {
for p in points {
self.append(p);
}
}
pub async fn flush(&mut self) -> Result<(), OpfsError> {
let today = utc_today_wasm();
if today != self.current_day {
if let Err(e) = self.rotate_day(&today).await {
tracing::warn!(
target: "dig3_station::disk_store_wasm",
error = %e,
old_day = %self.current_day,
new_day = %today,
"OPFS day rotation failed; keeping old day handle"
);
}
}
if !self.dat_buf.is_empty() {
opfs_append_quota_guard(
&self.sym_dir,
&format!("{}.dat", self.current_day),
&self.dat_buf,
)
.await?;
self.dat_buf.clear();
}
if !self.idx_buf.is_empty() {
opfs_append_quota_guard(
&self.sym_dir,
&format!("{}.idx", self.current_day),
&self.idx_buf,
)
.await?;
self.idx_buf.clear();
}
if let Some(b) = self.blob_buf.as_mut() {
if !b.is_empty() {
opfs_append_quota_guard(
&self.sym_dir,
&format!("{}.blob", self.current_day),
b,
)
.await?;
b.clear();
}
}
Ok(())
}
pub async fn read_tail(&self, limit: usize) -> Result<Vec<T>, OpfsError> {
if limit == 0 {
return Ok(Vec::new());
}
let dat_bytes =
match opfs_read_all(&self.sym_dir, &format!("{}.dat", self.current_day)).await {
Ok(b) => b,
Err(OpfsError::FileNotFound) => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let total = dat_bytes.len();
if total < T::RECORD_SIZE {
return Ok(Vec::new());
}
let max_records = total / T::RECORD_SIZE;
let take = limit.min(max_records);
let offset = total - take * T::RECORD_SIZE;
let slice = &dat_bytes[offset..];
let blob_data: Option<Vec<u8>> = if T::blob_pointer_offset().is_some() {
opfs_read_all(&self.sym_dir, &format!("{}.blob", self.current_day))
.await
.ok()
} else {
None
};
let mut out = Vec::with_capacity(take);
for chunk in slice.chunks_exact(T::RECORD_SIZE) {
let point = match (T::blob_pointer_offset(), &blob_data) {
(Some(tail_off), Some(bd)) => {
let off = u64::from_le_bytes(
chunk[tail_off..tail_off + 8]
.try_into()
.unwrap_or([0u8; 8]),
) as usize;
let len = u32::from_le_bytes(
chunk[tail_off + 8..tail_off + 12]
.try_into()
.unwrap_or([0u8; 4]),
) as usize;
let blob_slice = bd.get(off..off + len).unwrap_or(&[]);
T::decode_blob(chunk, blob_slice)
}
_ => T::decode(chunk),
};
if let Some(p) = point {
out.push(p);
}
}
Ok(out)
}
pub fn key(&self) -> &SeriesKey {
&self.key
}
async fn rotate_day(&mut self, new_day: &str) -> Result<(), OpfsError> {
if !self.dat_buf.is_empty() {
opfs_append_quota_guard(
&self.sym_dir,
&format!("{}.dat", self.current_day),
&self.dat_buf,
)
.await
.map_err(|e| OpfsError::RotationFailed(Box::new(e)))?;
self.dat_buf.clear();
}
if !self.idx_buf.is_empty() {
opfs_append_quota_guard(
&self.sym_dir,
&format!("{}.idx", self.current_day),
&self.idx_buf,
)
.await
.map_err(|e| OpfsError::RotationFailed(Box::new(e)))?;
self.idx_buf.clear();
}
if let Some(b) = self.blob_buf.as_mut() {
if !b.is_empty() {
opfs_append_quota_guard(
&self.sym_dir,
&format!("{}.blob", self.current_day),
b,
)
.await
.map_err(|e| OpfsError::RotationFailed(Box::new(e)))?;
b.clear();
}
}
let root = opfs_root()
.await
.map_err(|e| OpfsError::RotationFailed(Box::new(e)))?;
let new_sym_dir = opfs_walk_or_create(&root, &self.key)
.await
.map_err(|e| OpfsError::RotationFailed(Box::new(e)))?;
let new_offset = opfs_file_size(&new_sym_dir, &format!("{new_day}.dat"))
.await
.unwrap_or(0);
let new_blob_pos = if T::blob_pointer_offset().is_some() {
opfs_file_size(&new_sym_dir, &format!("{new_day}.blob"))
.await
.unwrap_or(0)
} else {
0
};
self.sym_dir = new_sym_dir;
self.current_day = new_day.to_string();
self.file_offset = new_offset;
self.blob_pos = new_blob_pos;
self.records = 0;
Ok(())
}
}
fn utc_today_wasm() -> String {
use chrono::Utc;
Utc::now().format("%Y-%m-%d").to_string()
}