Skip to content

Commit d41c127

Browse files
authored
v1.5.3: Fix write pipeline, WAL concurrency, --read-only mode (#18)
Write pipeline fixes: - Auto-link resolver now skips code blocks, inline code, frontmatter, and filenames with extensions (prevents content mangling) - Frontmatter merge: user-provided FM fields merged into auto-generated block instead of creating duplicate FM sections - mtime synced to DB after edit/rewrite operations (prevents false conflict errors on successive writes) Concurrency: - SQLite WAL mode + 5s busy_timeout for concurrent MCP + CLI access Read-only mode: - --read-only flag for engraph serve disables all write MCP and HTTP tools while keeping search/read/context tools accessible 450 tests (up from 426), all passing.
1 parent 8d33a0b commit d41c127

8 files changed

Lines changed: 1011 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "engraph"
3-
version = "1.5.0"
3+
version = "1.5.3"
44
edition = "2024"
55
description = "Local knowledge graph for AI agents. Hybrid search + MCP server for Obsidian vaults."
66
license = "MIT"

src/http.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub struct ApiState {
4343
pub no_auth: bool,
4444
pub recent_writes: RecentWrites,
4545
pub rate_limiter: Arc<RateLimiter>,
46+
pub read_only: bool,
4647
}
4748

4849
// ---------------------------------------------------------------------------
@@ -697,6 +698,9 @@ async fn handle_create(
697698
Json(body): Json<CreateBody>,
698699
) -> Result<impl IntoResponse, ApiError> {
699700
authorize(&headers, &state, true)?;
701+
if state.read_only {
702+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
703+
}
700704
let store = state.store.lock().await;
701705
let mut embedder = state.embedder.lock().await;
702706
let input = CreateNoteInput {
@@ -726,6 +730,9 @@ async fn handle_append(
726730
Json(body): Json<AppendBody>,
727731
) -> Result<impl IntoResponse, ApiError> {
728732
authorize(&headers, &state, true)?;
733+
if state.read_only {
734+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
735+
}
729736
let store = state.store.lock().await;
730737
let mut embedder = state.embedder.lock().await;
731738
let input = AppendInput {
@@ -746,6 +753,9 @@ async fn handle_edit(
746753
Json(body): Json<EditBody>,
747754
) -> Result<impl IntoResponse, ApiError> {
748755
authorize(&headers, &state, true)?;
756+
if state.read_only {
757+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
758+
}
749759
let store = state.store.lock().await;
750760
let mode = match body.mode.as_deref().unwrap_or("append") {
751761
"replace" => EditMode::Replace,
@@ -772,6 +782,9 @@ async fn handle_rewrite(
772782
Json(body): Json<RewriteBody>,
773783
) -> Result<impl IntoResponse, ApiError> {
774784
authorize(&headers, &state, true)?;
785+
if state.read_only {
786+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
787+
}
775788
let store = state.store.lock().await;
776789
let input = RewriteInput {
777790
file: body.file,
@@ -792,6 +805,9 @@ async fn handle_edit_frontmatter(
792805
Json(body): Json<EditFrontmatterBody>,
793806
) -> Result<impl IntoResponse, ApiError> {
794807
authorize(&headers, &state, true)?;
808+
if state.read_only {
809+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
810+
}
795811
let ops = parse_frontmatter_ops(&body.operations)?;
796812
let store = state.store.lock().await;
797813
let input = EditFrontmatterInput {
@@ -812,6 +828,9 @@ async fn handle_move(
812828
Json(body): Json<MoveBody>,
813829
) -> Result<impl IntoResponse, ApiError> {
814830
authorize(&headers, &state, true)?;
831+
if state.read_only {
832+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
833+
}
815834
let store = state.store.lock().await;
816835
let result = writer::move_note(&body.file, &body.new_folder, &store, &state.vault_path)
817836
.map_err(|e| ApiError::internal(&format!("{e:#}")))?;
@@ -826,6 +845,9 @@ async fn handle_archive(
826845
Json(body): Json<ArchiveBody>,
827846
) -> Result<impl IntoResponse, ApiError> {
828847
authorize(&headers, &state, true)?;
848+
if state.read_only {
849+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
850+
}
829851
let store = state.store.lock().await;
830852
let result = writer::archive_note(
831853
&body.file,
@@ -845,6 +867,9 @@ async fn handle_unarchive(
845867
Json(body): Json<UnarchiveBody>,
846868
) -> Result<impl IntoResponse, ApiError> {
847869
authorize(&headers, &state, true)?;
870+
if state.read_only {
871+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
872+
}
848873
let store = state.store.lock().await;
849874
let mut embedder = state.embedder.lock().await;
850875
let result = writer::unarchive_note(&body.file, &store, &mut *embedder, &state.vault_path)
@@ -860,6 +885,9 @@ async fn handle_update_metadata(
860885
Json(body): Json<UpdateMetadataBody>,
861886
) -> Result<impl IntoResponse, ApiError> {
862887
authorize(&headers, &state, true)?;
888+
if state.read_only {
889+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
890+
}
863891
let store = state.store.lock().await;
864892
let input = UpdateMetadataInput {
865893
file: body.file,
@@ -883,6 +911,9 @@ async fn handle_migrate_preview(
883911
headers: HeaderMap,
884912
) -> Result<impl IntoResponse, ApiError> {
885913
authorize(&headers, &state, true)?;
914+
if state.read_only {
915+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
916+
}
886917
let store = state.store.lock().await;
887918
let profile_ref = state.profile.as_ref().as_ref();
888919
let preview = crate::migrate::generate_preview(&store, &state.vault_path, profile_ref)
@@ -901,6 +932,9 @@ async fn handle_migrate_apply(
901932
Json(body): Json<MigrateApplyBody>,
902933
) -> Result<impl IntoResponse, ApiError> {
903934
authorize(&headers, &state, true)?;
935+
if state.read_only {
936+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
937+
}
904938
let store = state.store.lock().await;
905939
let preview: crate::migrate::MigrationPreview = serde_json::from_value(body.preview)
906940
.map_err(|e| ApiError::bad_request(&format!("Invalid preview: {e}")))?;
@@ -914,6 +948,9 @@ async fn handle_migrate_undo(
914948
headers: HeaderMap,
915949
) -> Result<impl IntoResponse, ApiError> {
916950
authorize(&headers, &state, true)?;
951+
if state.read_only {
952+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
953+
}
917954
let store = state.store.lock().await;
918955
let result = crate::migrate::undo_last(&store, &state.vault_path)
919956
.map_err(|e| ApiError::internal(&format!("{e:#}")))?;
@@ -926,6 +963,9 @@ async fn handle_delete(
926963
Json(body): Json<DeleteBody>,
927964
) -> Result<impl IntoResponse, ApiError> {
928965
authorize(&headers, &state, true)?;
966+
if state.read_only {
967+
return Err(ApiError::forbidden("Write operations disabled in read-only mode"));
968+
}
929969
let store = state.store.lock().await;
930970
let mode = match body.mode.as_deref().unwrap_or("soft") {
931971
"hard" => DeleteMode::Hard,
@@ -1013,6 +1053,7 @@ mod tests {
10131053
no_auth: false,
10141054
recent_writes: Arc::new(Mutex::new(HashMap::<PathBuf, SystemTime>::new())),
10151055
rate_limiter,
1056+
read_only: false,
10161057
}
10171058
}
10181059

0 commit comments

Comments
 (0)