ANW-13 GET /notes/<path>: NoteStore + axum HttpServer wiring
This commit is contained in:
parent
095ea7fbf0
commit
e1fe1dcb4e
7 changed files with 661 additions and 72 deletions
|
|
@ -25,7 +25,7 @@ serde_yaml = "0.9"
|
|||
tantivy = "0.25"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
|
|
|||
71
src/app.rs
71
src/app.rs
|
|
@ -36,7 +36,9 @@ use serde::{Deserialize, Serialize};
|
|||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::http::{self as http_layer, HttpState};
|
||||
use crate::index::NoteIndex;
|
||||
use crate::store::NoteStore;
|
||||
use crate::vault::{self, Note};
|
||||
use crate::watcher::run_debouncer;
|
||||
|
||||
|
|
@ -108,6 +110,10 @@ pub struct Anwesen {
|
|||
pub vault: PathBuf,
|
||||
pub bind: SocketAddr,
|
||||
pub counters: Arc<RestartCounters>,
|
||||
/// Shared in-memory note store. The scanner populates it, the watcher
|
||||
/// keeps it current via the writer's batches, and the HTTP layer reads
|
||||
/// from it on every request.
|
||||
pub store: Arc<NoteStore>,
|
||||
}
|
||||
|
||||
impl Anwesen {
|
||||
|
|
@ -117,6 +123,7 @@ impl Anwesen {
|
|||
vault,
|
||||
bind,
|
||||
counters: RestartCounters::new(),
|
||||
store: NoteStore::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,6 +149,7 @@ impl Application for Anwesen {
|
|||
let children = [
|
||||
IndexWriter {
|
||||
counters: self.counters.clone(),
|
||||
store: self.store.clone(),
|
||||
}
|
||||
.child_spec(),
|
||||
VaultScanner {
|
||||
|
|
@ -157,6 +165,7 @@ impl Application for Anwesen {
|
|||
HttpServer {
|
||||
bind: self.bind,
|
||||
counters: self.counters.clone(),
|
||||
store: self.store.clone(),
|
||||
}
|
||||
.child_spec(),
|
||||
];
|
||||
|
|
@ -394,14 +403,17 @@ pub struct IndexBatch {
|
|||
#[derive(Clone)]
|
||||
pub struct IndexWriter {
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
}
|
||||
|
||||
impl IndexWriter {
|
||||
fn child_spec(self) -> ChildSpec {
|
||||
let counters = self.counters.clone();
|
||||
let store = self.store.clone();
|
||||
ChildSpec::new(INDEX_WRITER_NAME).start(move || {
|
||||
IndexWriterState {
|
||||
counters: counters.clone(),
|
||||
store: store.clone(),
|
||||
index: None,
|
||||
}
|
||||
.start_link(GenServerOptions::new().name(INDEX_WRITER_NAME))
|
||||
|
|
@ -411,9 +423,12 @@ impl IndexWriter {
|
|||
|
||||
/// Runtime state for the [`IndexWriter`] process. Held in a separate struct
|
||||
/// so the `Clone`-friendly child-spec form (which doesn't carry the live
|
||||
/// `NoteIndex`) stays simple.
|
||||
/// `NoteIndex`) stays simple. Mirrors every write into the shared
|
||||
/// [`NoteStore`] so the HTTP layer can serve read-one and listing
|
||||
/// responses without consulting the index.
|
||||
pub(crate) struct IndexWriterState {
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
/// Created lazily in `init` so a Tantivy construction failure surfaces
|
||||
/// as an `ExitReason` and triggers a supervisor restart, rather than
|
||||
/// poisoning the child spec.
|
||||
|
|
@ -445,6 +460,7 @@ impl GenServer for IndexWriterState {
|
|||
tracing::error!(error = %e, "index_writer: rebuild failed");
|
||||
return Err(ExitReason::from(format!("rebuild: {e}")));
|
||||
}
|
||||
self.store.replace(notes);
|
||||
tracing::info!(notes = count, "index_writer: rebuilt");
|
||||
}
|
||||
IndexWriterMessage::Batch(batch) => {
|
||||
|
|
@ -453,6 +469,7 @@ impl GenServer for IndexWriterState {
|
|||
tracing::error!(error = %e, "index_writer: batch apply failed");
|
||||
return Err(ExitReason::from(format!("batch: {e}")));
|
||||
}
|
||||
self.store.apply_batch(batch.upserts, &batch.deletes);
|
||||
tracing::info!(upserts = u, deletes = d, "index_writer: batch applied");
|
||||
}
|
||||
IndexWriterMessage::Upsert(note) => {
|
||||
|
|
@ -461,6 +478,7 @@ impl GenServer for IndexWriterState {
|
|||
tracing::error!(%path, error = %e, "index_writer: upsert failed");
|
||||
return Err(ExitReason::from(format!("upsert {path}: {e}")));
|
||||
}
|
||||
self.store.upsert(*note);
|
||||
tracing::debug!(%path, "index_writer: upserted");
|
||||
}
|
||||
IndexWriterMessage::Delete(path) => {
|
||||
|
|
@ -468,6 +486,7 @@ impl GenServer for IndexWriterState {
|
|||
tracing::error!(%path, error = %e, "index_writer: delete failed");
|
||||
return Err(ExitReason::from(format!("delete {path}: {e}")));
|
||||
}
|
||||
self.store.delete(&path);
|
||||
tracing::debug!(%path, "index_writer: deleted");
|
||||
}
|
||||
}
|
||||
|
|
@ -483,12 +502,12 @@ impl GenServer for IndexWriterState {
|
|||
}
|
||||
}
|
||||
|
||||
// -- http_server (stub for ANW-13/14/15) ------------------------------------
|
||||
// -- http_server ------------------------------------------------------------
|
||||
|
||||
/// The HTTP server has no inbound message protocol; the single `Noop`
|
||||
/// variant satisfies Hydra's `Receivable` bound.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum HttpServerMessage {
|
||||
/// Placeholder; replaced by the axum surface in
|
||||
/// [ANW-13](https://crvrs.youtrack.cloud/issue/ANW-13) and following.
|
||||
Noop,
|
||||
}
|
||||
|
||||
|
|
@ -496,32 +515,62 @@ pub enum HttpServerMessage {
|
|||
pub struct HttpServer {
|
||||
bind: SocketAddr,
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
}
|
||||
|
||||
impl HttpServer {
|
||||
fn child_spec(self) -> ChildSpec {
|
||||
let bind = self.bind;
|
||||
let counters = self.counters.clone();
|
||||
let store = self.store.clone();
|
||||
ChildSpec::new(HTTP_SERVER_NAME).start(move || {
|
||||
HttpServer {
|
||||
HttpServerState {
|
||||
bind,
|
||||
counters: counters.clone(),
|
||||
store: store.clone(),
|
||||
server: None,
|
||||
}
|
||||
.start_link(GenServerOptions::new().name(HTTP_SERVER_NAME))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GenServer for HttpServer {
|
||||
struct HttpServerState {
|
||||
bind: SocketAddr,
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
server: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for HttpServerState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.server.take() {
|
||||
h.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenServer for HttpServerState {
|
||||
type Message = HttpServerMessage;
|
||||
|
||||
async fn init(&mut self) -> Result<(), ExitReason> {
|
||||
let restart = self.counters.http_server.record_init();
|
||||
tracing::warn!(
|
||||
restart,
|
||||
bind = %self.bind,
|
||||
"http_server: stub -- no port bound yet (real binding lands in ANW-13)"
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(self.bind)
|
||||
.await
|
||||
.map_err(|e| ExitReason::from(format!("http_server: bind {}: {e}", self.bind)))?;
|
||||
|
||||
let router = http_layer::router(HttpState {
|
||||
store: self.store.clone(),
|
||||
});
|
||||
let server = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await {
|
||||
tracing::error!(error = %e, "http_server: serve loop exited with error");
|
||||
}
|
||||
});
|
||||
self.server = Some(server);
|
||||
|
||||
tracing::info!(restart, bind = %self.bind, "http_server: init");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
358
src/http.rs
Normal file
358
src/http.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
//! HTTP surface for Anwesen.
|
||||
//!
|
||||
//! Implements the read-one-note endpoint per [ANW-13]:
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /notes/<path> -> JSON {path, frontmatter, body, last_modified, etag, size}
|
||||
//! GET /notes/<path> + Accept: text/markdown -> raw file bytes
|
||||
//! GET /notes/<path> + If-None-Match: "<etag>" -> 304 on match
|
||||
//! ```
|
||||
//!
|
||||
//! Subsequent issues bolt the folder index ([ANW-14]), query ([ANW-15]) and
|
||||
//! `/health` ([ANW-8]) onto the same [`Router`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path as AxumPath, State};
|
||||
use axum::http::header::{ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::store::NoteStore;
|
||||
use crate::vault::{Note, frontmatter_to_json};
|
||||
|
||||
/// Shared state injected into every handler.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpState {
|
||||
pub store: Arc<NoteStore>,
|
||||
}
|
||||
|
||||
pub fn router(state: HttpState) -> Router {
|
||||
Router::new()
|
||||
.route("/notes/{*path}", get(get_note))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NoteResponse<'a> {
|
||||
path: &'a str,
|
||||
frontmatter: serde_json::Value,
|
||||
body: &'a str,
|
||||
last_modified: String,
|
||||
etag: &'a str,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Note> for NoteResponse<'a> {
|
||||
fn from(note: &'a Note) -> Self {
|
||||
Self {
|
||||
path: ¬e.path,
|
||||
frontmatter: frontmatter_to_json(¬e.frontmatter),
|
||||
body: ¬e.body,
|
||||
last_modified: note.last_modified.to_rfc3339(),
|
||||
etag: ¬e.etag,
|
||||
size: note.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_note(
|
||||
State(state): State<HttpState>,
|
||||
AxumPath(path): AxumPath<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
match resolve_path(&path) {
|
||||
Err(reason) => bad_request(reason).into_response(),
|
||||
Ok(canonical) => match state.store.get(&canonical) {
|
||||
None => not_found().into_response(),
|
||||
Some(note) => respond_note(¬e, &headers).into_response(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_note(note: &Note, headers: &HeaderMap) -> Response {
|
||||
if let Some(client_etag) = headers.get(IF_NONE_MATCH)
|
||||
&& etag_matches(client_etag, ¬e.etag)
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(ETAG, note.etag.as_str())
|
||||
.body(Body::empty())
|
||||
.expect("static response");
|
||||
}
|
||||
|
||||
if wants_markdown(headers) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, "text/markdown; charset=utf-8")
|
||||
.header(ETAG, note.etag.as_str())
|
||||
.body(Body::from(note.raw_bytes.clone()))
|
||||
.expect("static response");
|
||||
}
|
||||
|
||||
let body = serde_json::to_vec(&NoteResponse::from(note)).expect("note serializes");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(ETAG, note.etag.as_str())
|
||||
.body(Body::from(body))
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
fn wants_markdown(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get_all(ACCEPT)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|s| s.split(','))
|
||||
.any(|raw| {
|
||||
// Strip media-type parameters (`text/markdown; q=0.9`) before compare.
|
||||
let mime = raw.split(';').next().unwrap_or(raw).trim();
|
||||
mime.eq_ignore_ascii_case("text/markdown")
|
||||
})
|
||||
}
|
||||
|
||||
fn etag_matches(client: &HeaderValue, server_etag: &str) -> bool {
|
||||
let Ok(s) = client.to_str() else {
|
||||
return false;
|
||||
};
|
||||
// `If-None-Match` may carry one or more comma-separated entity-tags;
|
||||
// we ignore weak validators (W/) since the server only emits strong.
|
||||
s.split(',').any(|raw| {
|
||||
let trimmed = raw.trim();
|
||||
let stripped = trimmed.strip_prefix("W/").unwrap_or(trimmed);
|
||||
stripped == server_etag || trimmed == "*"
|
||||
})
|
||||
}
|
||||
|
||||
fn bad_request(reason: &str) -> (StatusCode, String) {
|
||||
(StatusCode::BAD_REQUEST, format!("bad request: {reason}\n"))
|
||||
}
|
||||
|
||||
fn not_found() -> (StatusCode, &'static str) {
|
||||
(StatusCode::NOT_FOUND, "not found\n")
|
||||
}
|
||||
|
||||
/// Normalize a request path per the User Manual:
|
||||
///
|
||||
/// - URL-decode the entire path once;
|
||||
/// - strip leading slashes;
|
||||
/// - reject any `..` segment (or empty segment / `.`) with `400`.
|
||||
///
|
||||
/// Returns the vault-relative form (forward-slash, no leading slash).
|
||||
fn resolve_path(raw: &str) -> Result<String, &'static str> {
|
||||
let decoded = percent_decode(raw).ok_or("invalid percent-encoding")?;
|
||||
let stripped = decoded.trim_start_matches('/').to_string();
|
||||
if stripped.is_empty() {
|
||||
return Err("path is empty");
|
||||
}
|
||||
for seg in stripped.split('/') {
|
||||
if seg.is_empty() || seg == "." || seg == ".." {
|
||||
return Err("path contains forbidden segment");
|
||||
}
|
||||
}
|
||||
Ok(stripped)
|
||||
}
|
||||
|
||||
/// Tiny dependency-free single-pass percent-decoder. Anwesen URL-decodes
|
||||
/// each path component exactly once per the User Manual contract; we keep
|
||||
/// the decoder small rather than pull `percent-encoding` for one call.
|
||||
fn percent_decode(input: &str) -> Option<String> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' {
|
||||
if i + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hi = hex_value(bytes[i + 1])?;
|
||||
let lo = hex_value(bytes[i + 2])?;
|
||||
out.push((hi << 4) | lo);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
fn hex_value(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::to_bytes;
|
||||
use axum::http::Request;
|
||||
use chrono::DateTime;
|
||||
use std::collections::BTreeMap;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::vault::Value;
|
||||
|
||||
fn note(path: &str, body: &str) -> Note {
|
||||
let mut fm: BTreeMap<String, Value> = BTreeMap::new();
|
||||
fm.insert("tag".into(), Value::String("demo".into()));
|
||||
let raw = format!("---\ntag: demo\n---\n{body}");
|
||||
let etag = format!("\"{}\"", blake3::hash(raw.as_bytes()).to_hex());
|
||||
let size = raw.len() as u64;
|
||||
Note {
|
||||
path: path.into(),
|
||||
frontmatter: fm,
|
||||
body: body.into(),
|
||||
raw_bytes: raw.into_bytes(),
|
||||
last_modified: DateTime::from_timestamp(0, 0).unwrap(),
|
||||
etag,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
fn router_with(notes: Vec<Note>) -> (Router, Arc<NoteStore>) {
|
||||
let store = NoteStore::new();
|
||||
store.replace(notes);
|
||||
let r = router(HttpState {
|
||||
store: store.clone(),
|
||||
});
|
||||
(r, store)
|
||||
}
|
||||
|
||||
async fn send(router: Router, req: Request<Body>) -> Response {
|
||||
router.oneshot(req).await.expect("oneshot")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_path_returns_404() {
|
||||
let (r, _) = router_with(vec![]);
|
||||
let req = Request::get("/notes/missing.md")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn double_dot_rejected_with_400() {
|
||||
let (r, _) = router_with(vec![note("a.md", "x")]);
|
||||
let req = Request::get("/notes/../etc/passwd")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn percent_encoded_path_decoded_once() {
|
||||
let (r, _) = router_with(vec![note("dir with space/a.md", "x")]);
|
||||
let req = Request::get("/notes/dir%20with%20space/a.md")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_response_is_json_with_etag_header() {
|
||||
let n = note("a.md", "body");
|
||||
let expected_etag = n.etag.clone();
|
||||
let (r, _) = router_with(vec![n]);
|
||||
let req = Request::get("/notes/a.md").body(Body::empty()).unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get(CONTENT_TYPE).unwrap(),
|
||||
"application/json"
|
||||
);
|
||||
assert_eq!(resp.headers().get(ETAG).unwrap(), expected_etag.as_str());
|
||||
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["path"], "a.md");
|
||||
assert_eq!(v["body"], "body");
|
||||
assert!(v["frontmatter"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_text_markdown_returns_raw_bytes() {
|
||||
let n = note("a.md", "body");
|
||||
let raw = n.raw_bytes.clone();
|
||||
let (r, _) = router_with(vec![n]);
|
||||
let req = Request::get("/notes/a.md")
|
||||
.header(ACCEPT, "text/markdown")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get(CONTENT_TYPE).unwrap(),
|
||||
"text/markdown; charset=utf-8"
|
||||
);
|
||||
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
assert_eq!(bytes.as_ref(), raw.as_slice());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_none_match_returns_304() {
|
||||
let n = note("a.md", "body");
|
||||
let etag = n.etag.clone();
|
||||
let (r, _) = router_with(vec![n]);
|
||||
let req = Request::get("/notes/a.md")
|
||||
.header(IF_NONE_MATCH, etag.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
|
||||
assert_eq!(resp.headers().get(ETAG).unwrap(), etag.as_str());
|
||||
// 304 must not carry a body.
|
||||
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
assert!(bytes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_none_match_star_returns_304() {
|
||||
let n = note("a.md", "body");
|
||||
let (r, _) = router_with(vec![n]);
|
||||
let req = Request::get("/notes/a.md")
|
||||
.header(IF_NONE_MATCH, "*")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_none_match_with_different_etag_returns_full_body() {
|
||||
let n = note("a.md", "body");
|
||||
let (r, _) = router_with(vec![n]);
|
||||
let req = Request::get("/notes/a.md")
|
||||
.header(IF_NONE_MATCH, "\"other\"")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = send(r, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_normalizations() {
|
||||
assert_eq!(resolve_path("Notes/a.md").unwrap(), "Notes/a.md");
|
||||
assert_eq!(resolve_path("/Notes/a.md").unwrap(), "Notes/a.md");
|
||||
assert_eq!(resolve_path("////Notes/a.md").unwrap(), "Notes/a.md");
|
||||
assert_eq!(resolve_path("Notes/a%20b.md").unwrap(), "Notes/a b.md");
|
||||
assert!(resolve_path("..").is_err());
|
||||
assert!(resolve_path("a/../b").is_err());
|
||||
assert!(resolve_path("a//b").is_err());
|
||||
assert!(resolve_path("a/./b").is_err());
|
||||
assert!(resolve_path("").is_err());
|
||||
assert!(resolve_path("a/%ZZ.md").is_err());
|
||||
}
|
||||
}
|
||||
64
src/index.rs
64
src/index.rs
|
|
@ -14,11 +14,11 @@
|
|||
//! driven from the filesystem watcher in [ANW-16].
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||
use serde_json::json;
|
||||
use tantivy::schema::{Field, IndexRecordOption, Schema, TextFieldIndexing, TextOptions};
|
||||
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
|
||||
|
||||
use crate::vault::{Note, Value};
|
||||
use crate::vault::{Note, frontmatter_to_json};
|
||||
|
||||
/// 50 MB heap for the writer. Tantivy's documented minimum is 15 MB; this
|
||||
/// sits comfortably above that for low-thousands-of-notes vaults without
|
||||
|
|
@ -181,37 +181,6 @@ impl NoteIndex {
|
|||
}
|
||||
}
|
||||
|
||||
/// Convert a typed [`Value`] tree to `serde_json::Value`. Typed dates and
|
||||
/// datetimes are emitted as their ISO-8601 / RFC 3339 string forms so they
|
||||
/// sort correctly under range queries with the `raw` tokenizer.
|
||||
fn value_to_json(v: &Value) -> JsonValue {
|
||||
match v {
|
||||
Value::Null => JsonValue::Null,
|
||||
Value::Bool(b) => JsonValue::Bool(*b),
|
||||
Value::Int(i) => json!(i),
|
||||
Value::Float(f) => json!(f),
|
||||
Value::String(s) => JsonValue::String(s.clone()),
|
||||
Value::Date(d) => JsonValue::String(d.format("%Y-%m-%d").to_string()),
|
||||
Value::DateTime(dt) => JsonValue::String(dt.to_rfc3339()),
|
||||
Value::Sequence(seq) => JsonValue::Array(seq.iter().map(value_to_json).collect()),
|
||||
Value::Mapping(m) => {
|
||||
let mut map = JsonMap::new();
|
||||
for (k, v) in m {
|
||||
map.insert(k.clone(), value_to_json(v));
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frontmatter_to_json(fm: &crate::vault::Frontmatter) -> JsonValue {
|
||||
let mut map = JsonMap::new();
|
||||
for (k, v) in fm {
|
||||
map.insert(k.clone(), value_to_json(v));
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -220,6 +189,8 @@ mod tests {
|
|||
use tantivy::collector::TopDocs;
|
||||
use tantivy::query::TermQuery;
|
||||
|
||||
use crate::vault::Value;
|
||||
|
||||
fn sample_note(path: &str, tag: &str) -> Note {
|
||||
let mut fm: BTreeMap<String, Value> = BTreeMap::new();
|
||||
fm.insert(
|
||||
|
|
@ -295,31 +266,4 @@ mod tests {
|
|||
idx.delete("never-indexed.md").unwrap();
|
||||
assert_eq!(idx.document_count().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_json_coerces_dates_to_iso_strings() {
|
||||
let d = chrono::NaiveDate::from_ymd_opt(2026, 5, 14).unwrap();
|
||||
assert_eq!(
|
||||
value_to_json(&Value::Date(d)),
|
||||
JsonValue::String("2026-05-14".into())
|
||||
);
|
||||
|
||||
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-14T10:14:22Z").unwrap();
|
||||
assert_eq!(
|
||||
value_to_json(&Value::DateTime(dt)),
|
||||
JsonValue::String("2026-05-14T10:14:22+00:00".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_json_handles_nested_structures() {
|
||||
let mut inner: BTreeMap<String, Value> = BTreeMap::new();
|
||||
inner.insert("name".into(), Value::String("brn".into()));
|
||||
let v = Value::Mapping(inner);
|
||||
let j = value_to_json(&v);
|
||||
let JsonValue::Object(obj) = j else {
|
||||
panic!("expected object");
|
||||
};
|
||||
assert_eq!(obj.get("name"), Some(&JsonValue::String("brn".into())));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! supervisor tree, and the HTTP surface as those issues land.
|
||||
|
||||
pub mod app;
|
||||
pub mod http;
|
||||
pub mod index;
|
||||
pub mod store;
|
||||
pub mod vault;
|
||||
pub mod watcher;
|
||||
|
|
|
|||
155
src/store.rs
Normal file
155
src/store.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//! In-memory note store. Holds the authoritative `vault::Note` record set
|
||||
//! that the scanner produces and the watcher maintains; consumed by the
|
||||
//! HTTP layer ([ANW-13] / [ANW-14] / [ANW-15]) for read-one and listing
|
||||
//! responses.
|
||||
//!
|
||||
//! Per [[ADR-002 Tantivy as Frontmatter Index]] / [ANW-11], the daemon
|
||||
//! rereads from disk only on a watcher event for that path. The store is
|
||||
//! that in-memory cache; the index in [`crate::index`] sits beside it,
|
||||
//! shaped for frontmatter queries rather than full-record retrieval.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::vault::Note;
|
||||
|
||||
/// Shared, thread-safe handle to the note set keyed by vault-relative path.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoteStore {
|
||||
inner: RwLock<BTreeMap<String, Note>>,
|
||||
}
|
||||
|
||||
impl NoteStore {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Replace the entire record set. Used at startup and after overflow
|
||||
/// recovery.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the inner `RwLock` has been poisoned by a panic in a writer.
|
||||
/// Anwesen does not call user code under the lock, so this is unreachable
|
||||
/// in practice.
|
||||
pub fn replace(&self, notes: Vec<Note>) {
|
||||
let mut guard = self.inner.write().expect("note_store: write lock poisoned");
|
||||
guard.clear();
|
||||
for note in notes {
|
||||
guard.insert(note.path.clone(), note);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one debounce-window's worth of changes. Mirrors the order in
|
||||
/// [`crate::index::NoteIndex::apply_batch`]: deletes first, then upserts.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the inner `RwLock` has been poisoned.
|
||||
pub fn apply_batch(&self, upserts: Vec<Note>, deletes: &[String]) {
|
||||
let mut guard = self.inner.write().expect("note_store: write lock poisoned");
|
||||
for path in deletes {
|
||||
guard.remove(path);
|
||||
}
|
||||
for note in upserts {
|
||||
guard.insert(note.path.clone(), note);
|
||||
}
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
/// Panics if the inner `RwLock` has been poisoned.
|
||||
pub fn upsert(&self, note: Note) {
|
||||
let mut guard = self.inner.write().expect("note_store: write lock poisoned");
|
||||
guard.insert(note.path.clone(), note);
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
/// Panics if the inner `RwLock` has been poisoned.
|
||||
pub fn delete(&self, path: &str) {
|
||||
let mut guard = self.inner.write().expect("note_store: write lock poisoned");
|
||||
guard.remove(path);
|
||||
}
|
||||
|
||||
/// Clone a single record by path, or `None` if absent. The read lock is
|
||||
/// held only for the lookup; the returned `Note` is independent.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the inner `RwLock` has been poisoned.
|
||||
#[must_use]
|
||||
pub fn get(&self, path: &str) -> Option<Note> {
|
||||
let guard = self.inner.read().expect("note_store: read lock poisoned");
|
||||
guard.get(path).cloned()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
/// Panics if the inner `RwLock` has been poisoned.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("note_store: read lock poisoned")
|
||||
.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::DateTime;
|
||||
use std::collections::BTreeMap as Bm;
|
||||
|
||||
fn note(path: &str) -> Note {
|
||||
Note {
|
||||
path: path.into(),
|
||||
frontmatter: Bm::new(),
|
||||
body: String::new(),
|
||||
raw_bytes: b"hi".to_vec(),
|
||||
last_modified: DateTime::from_timestamp(0, 0).unwrap(),
|
||||
etag: "\"abc\"".into(),
|
||||
size: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_clears_previous() {
|
||||
let s = NoteStore::new();
|
||||
s.replace(vec![note("a.md"), note("b.md")]);
|
||||
assert_eq!(s.len(), 2);
|
||||
s.replace(vec![note("c.md")]);
|
||||
assert_eq!(s.len(), 1);
|
||||
assert!(s.get("a.md").is_none());
|
||||
assert!(s.get("c.md").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_batch_deletes_then_upserts() {
|
||||
let s = NoteStore::new();
|
||||
s.replace(vec![note("a.md"), note("b.md")]);
|
||||
s.apply_batch(vec![note("c.md")], &["a.md".to_string()]);
|
||||
assert_eq!(s.len(), 2);
|
||||
assert!(s.get("a.md").is_none());
|
||||
assert!(s.get("b.md").is_some());
|
||||
assert!(s.get("c.md").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_by_path() {
|
||||
let s = NoteStore::new();
|
||||
s.upsert(note("a.md"));
|
||||
let mut n2 = note("a.md");
|
||||
n2.etag = "\"def\"".into();
|
||||
s.upsert(n2);
|
||||
assert_eq!(s.get("a.md").unwrap().etag, "\"def\"");
|
||||
assert_eq!(s.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_unknown_path_is_none() {
|
||||
let s = NoteStore::new();
|
||||
assert!(s.get("nope.md").is_none());
|
||||
}
|
||||
}
|
||||
81
src/vault.rs
81
src/vault.rs
|
|
@ -20,6 +20,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||
use thiserror::Error;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
|
|
@ -45,6 +46,11 @@ pub type Frontmatter = BTreeMap<String, Value>;
|
|||
|
||||
/// Frontmatter value with the type-coercion contract from
|
||||
/// [[ADR-005 Frontmatter Contract Type Coercion and Cross-Note Shapes]] applied.
|
||||
///
|
||||
/// The derived `Serialize` / `Deserialize` is used for Hydra messaging
|
||||
/// (binary-format internal transport). HTTP responses must emit the flat,
|
||||
/// YAML-natural shape promised by the User Manual; call [`Value::to_json`]
|
||||
/// for that.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Value {
|
||||
Null,
|
||||
|
|
@ -58,6 +64,45 @@ pub enum Value {
|
|||
Mapping(BTreeMap<String, Value>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Convert to a plain [`serde_json::Value`] tree -- the form the User
|
||||
/// Manual promises HTTP consumers (e.g. `"tags": ["a", "b"]` rather than
|
||||
/// `{"Sequence": [{"String": "a"}, ...]}`). Typed dates and datetimes
|
||||
/// emit as their ISO-8601 / RFC 3339 string forms so they sort correctly
|
||||
/// under range queries against the index.
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
match self {
|
||||
Value::Null => JsonValue::Null,
|
||||
Value::Bool(b) => JsonValue::Bool(*b),
|
||||
Value::Int(i) => json!(i),
|
||||
Value::Float(f) => json!(f),
|
||||
Value::String(s) => JsonValue::String(s.clone()),
|
||||
Value::Date(d) => JsonValue::String(d.format("%Y-%m-%d").to_string()),
|
||||
Value::DateTime(dt) => JsonValue::String(dt.to_rfc3339()),
|
||||
Value::Sequence(seq) => JsonValue::Array(seq.iter().map(Value::to_json).collect()),
|
||||
Value::Mapping(m) => {
|
||||
let mut map = JsonMap::new();
|
||||
for (k, v) in m {
|
||||
map.insert(k.clone(), v.to_json());
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a whole [`Frontmatter`] tree to a [`serde_json::Value`] object
|
||||
/// suitable for HTTP responses or for Tantivy ingestion.
|
||||
#[must_use]
|
||||
pub fn frontmatter_to_json(fm: &Frontmatter) -> JsonValue {
|
||||
let mut map = JsonMap::new();
|
||||
for (k, v) in fm {
|
||||
map.insert(k.clone(), v.to_json());
|
||||
}
|
||||
JsonValue::Object(map)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScanResult {
|
||||
pub notes: Vec<Note>,
|
||||
|
|
@ -427,4 +472,40 @@ mod tests {
|
|||
assert!(yaml.is_empty());
|
||||
assert!(body.starts_with("---\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_json_coerces_dates_to_iso_strings() {
|
||||
let d = NaiveDate::from_ymd_opt(2026, 5, 14).unwrap();
|
||||
assert_eq!(
|
||||
Value::Date(d).to_json(),
|
||||
JsonValue::String("2026-05-14".into())
|
||||
);
|
||||
|
||||
let dt = DateTime::parse_from_rfc3339("2026-05-14T10:14:22Z").unwrap();
|
||||
assert_eq!(
|
||||
Value::DateTime(dt).to_json(),
|
||||
JsonValue::String("2026-05-14T10:14:22+00:00".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_json_emits_flat_yaml_natural_shape() {
|
||||
// The User Manual contract: tags: [a, b] -> JSON ["a", "b"], not the
|
||||
// tagged-enum form `{"Sequence": [{"String": "a"}, ...]}`.
|
||||
let v = Value::Sequence(vec![Value::String("a".into()), Value::String("b".into())]);
|
||||
let j = v.to_json();
|
||||
assert_eq!(j, json!(["a", "b"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_json_handles_nested_structures() {
|
||||
let mut inner: BTreeMap<String, Value> = BTreeMap::new();
|
||||
inner.insert("name".into(), Value::String("brn".into()));
|
||||
let v = Value::Mapping(inner);
|
||||
let j = v.to_json();
|
||||
let JsonValue::Object(obj) = j else {
|
||||
panic!("expected object");
|
||||
};
|
||||
assert_eq!(obj.get("name"), Some(&JsonValue::String("brn".into())));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue