//! A filesystem-backed [`TileServer`] — content-addressed blobs on disk plus a //! small JSON index from `(cell, tier)` to content id. //! //! This is the storage half of a real deployment, with no dependency beyond //! `std` (and `serde_json`, already used for receipts): tiles are written as //! immutable blobs named by their content id under `blobs/`, and an `index.json` //! records which blob serves each `(s2 cell, tier)`. [`DirStore::open`] reloads a //! persisted store, so a server restart keeps serving. Bind it under [`serve`] //! exactly like [`MemoryStore`]. //! //! [`serve`]: crate::serve //! [`MemoryStore`]: crate::MemoryStore use std::collections::HashMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use map_schema::{CellKey, ContentId, Tier}; use crate::{ServedTile, TileServer, TileSource}; /// A content-addressed tile store rooted at a directory. pub struct DirStore { root: PathBuf, /// `(s2 cell, tier rank) -> (content id, content type)`. index: HashMap<(u64, u8), (ContentId, String)>, } impl DirStore { /// Open (or create) a store rooted at `root`, loading any existing index. pub fn open(root: impl AsRef) -> io::Result { let root = root.as_ref().to_path_buf(); fs::create_dir_all(root.join("blobs"))?; let index = match fs::read(root.join("index.json")) { Ok(bytes) => parse_index(&bytes), Err(e) if e.kind() == io::ErrorKind::NotFound => HashMap::new(), Err(e) => return Err(e), }; Ok(DirStore { root, index }) } /// Write a tile for `cell` at `tier`: stores the bytes as a content-addressed /// blob, updates the index, and persists it. Returns the content id. pub fn insert( &mut self, cell: &CellKey, tier: Tier, bytes: &[u8], content_type: &str, ) -> io::Result { let id = ContentId::of_bytes(bytes); fs::write(self.blob_path(&id), bytes)?; self.index.insert((cell.s2.0, tier.rank()), (id, content_type.to_string())); self.save_index()?; Ok(id) } fn blob_path(&self, id: &ContentId) -> PathBuf { self.root.join("blobs").join(id.to_hex()) } fn save_index(&self) -> io::Result<()> { let mut entries: Vec = self .index .iter() .map(|(&(s2, rank), (id, ct))| { serde_json::json!({ "s2": s2, "rank": rank, "id": id.to_hex(), "ct": ct }) }) .collect(); // Stable on-disk order (the map iteration order is otherwise arbitrary). entries.sort_by_key(|e| (e["s2"].as_u64().unwrap_or(0), e["rank"].as_u64().unwrap_or(0))); let doc = serde_json::json!({ "tiles": entries }); fs::write(self.root.join("index.json"), serde_json::to_vec(&doc)?) } } fn parse_index(bytes: &[u8]) -> HashMap<(u64, u8), (ContentId, String)> { let mut out = HashMap::new(); let Ok(doc) = serde_json::from_slice::(bytes) else { return out; }; let Some(tiles) = doc["tiles"].as_array() else { return out; }; for e in tiles { let (Some(s2), Some(rank), Some(hex), Some(ct)) = ( e["s2"].as_u64(), e["rank"].as_u64(), e["id"].as_str(), e["ct"].as_str(), ) else { continue; }; if let Some(id) = content_id_from_hex(hex) { out.insert((s2, rank as u8), (id, ct.to_string())); } } out } fn content_id_from_hex(hex: &str) -> Option { if hex.len() != 64 { return None; } let mut bytes = [0u8; 32]; for (i, b) in bytes.iter_mut().enumerate() { *b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?; } Some(ContentId(bytes)) } impl TileSource for DirStore { fn cached(&self, _cell: &CellKey) -> Option { None } fn tile(&self, cell: &CellKey, tier: Tier) -> Option { self.index.get(&(cell.s2.0, tier.rank())).map(|(id, _)| *id) } } impl TileServer for DirStore { fn fetch(&self, id: &ContentId) -> Option { let bytes = fs::read(self.blob_path(id)).ok()?; // Recover the content type from the index (small; linear is fine). let content_type = self .index .values() .find(|(cid, _)| cid == id) .map(|(_, ct)| ct.clone()) .unwrap_or_else(|| "application/octet-stream".to_string()); Some(ServedTile { bytes, content_type }) } } #[cfg(test)] mod tests { use super::*; use crate::{serve, HttpReq}; use map_index::cell_key; use map_schema::{AltBand, Lod, TimeBucket}; fn temp_root(name: &str) -> PathBuf { let p = std::env::temp_dir().join(format!("map-serve-dir-{}-{}", std::process::id(), name)); let _ = fs::remove_dir_all(&p); p } fn cell() -> CellKey { cell_key(40.0, -74.0, 14, AltBand(0), TimeBucket::ATEMPORAL, Lod(0)) } #[test] fn writes_then_serves_from_disk() { let root = temp_root("serve"); let mut store = DirStore::open(&root).unwrap(); store.insert(&cell(), Tier::Base, b"on-disk-terrain", "model/gltf-binary").unwrap(); let req = HttpReq { method: "GET", path: "/tiles/auto/40.0/-74.0", if_none_match: None, sequence: 1, nonce: [0; 8], }; let res = serve(&store, "transaction.science", &req); assert_eq!(res.status, 200); assert_eq!(res.body, b"on-disk-terrain"); assert_eq!(res.header("Content-Type"), Some("model/gltf-binary")); assert_eq!(res.header("X-Map-Tier"), Some("F1")); let _ = fs::remove_dir_all(&root); } #[test] fn index_persists_across_reopen() { let root = temp_root("persist"); let id = { let mut store = DirStore::open(&root).unwrap(); store.insert(&cell(), Tier::Feature, b"vector-tile", "application/x-protobuf").unwrap() }; // Reopen: a fresh handle reloads the index and still resolves + fetches. let reopened = DirStore::open(&root).unwrap(); assert_eq!(reopened.tile(&cell(), Tier::Feature), Some(id)); let served = reopened.fetch(&id).unwrap(); assert_eq!(served.bytes, b"vector-tile"); assert_eq!(served.content_type, "application/x-protobuf"); let _ = fs::remove_dir_all(&root); } #[test] fn missing_tile_resolves_to_404() { let root = temp_root("empty"); let store = DirStore::open(&root).unwrap(); let req = HttpReq { method: "GET", path: "/tiles/auto/40.0/-74.0", if_none_match: None, sequence: 1, nonce: [0; 8], }; assert_eq!(serve(&store, "s", &req).status, 404); let _ = fs::remove_dir_all(&root); } }