1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: implement entry deletion

- database: cascade_cached_bytes_after_subtree_delete — one-pass set-aware
  SQL that excludes the entire subtree simultaneously, avoiding sibling-blob
  cross-counting bug
- database: delete_entry — collects subtree IDs, runs set-aware cascade,
  NULLs archive_run_items.produced_entry_id (FK blocker), deletes children
  then root; ON DELETE CASCADE handles artifacts/tags/collections
- server: DELETE /api/archives/:archive_id/entries/:entry_uid route,
  wrapped in a transaction for atomicity
- frontend: deleteEntry API call, handleEntryDeleted callback in App.jsx
  (optimistic list removal + selection clear), Delete entry button in
  ContextRail with confirm guard
- tests: 4 database tests (unknown uid, subtree removal, run_item nulling,
  cached_bytes recalculation) + 3 route tests (204+gone, 404, 401)
This commit is contained in:
TheGeneralist 2026-07-04 13:46:19 +02:00
parent cad3ae9885
commit ed1f883ff1
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
15 changed files with 416 additions and 128 deletions

View file

@ -215,7 +215,7 @@ pub fn app_with_state(state: AppState) -> Router {
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid",
get(entry_detail).patch(patch_entry_handler),
get(entry_detail).patch(patch_entry_handler).delete(delete_entry_handler),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
@ -615,6 +615,25 @@ async fn patch_entry_handler(
}
}
async fn delete_entry_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, entry_uid)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let mut conn = database::open_or_initialize(&mounted.archive_path)?;
// Transaction: if any step fails (cascade update, FK null, or delete), nothing is committed.
let tx = conn.transaction()?;
let found = database::delete_entry(&tx, &entry_uid)?;
tx.commit()?;
if found {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found("entry not found"))
}
}
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
@ -2958,4 +2977,72 @@ mod tests {
);
}
#[tokio::test]
async fn delete_entry_returns_204_and_entry_is_gone() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let entry = make_test_entry(&archive_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// Confirm the row is actually gone from the DB.
let conn = database::open_or_initialize(&archive_path).unwrap();
let exists: Option<i64> = conn
.query_row(
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
[&entry.entry_uid],
|r| r.get(0),
)
.optional()
.unwrap();
assert!(exists.is_none(), "entry row should be deleted");
}
#[tokio::test]
async fn delete_entry_returns_404_for_missing_entry() {
let dir = tempfile::tempdir().unwrap();
let (registry, _archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/archives/test/entries/entry_doesnotexist")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_entry_requires_auth() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let entry = make_test_entry(&archive_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-DuR832Rs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oE_dvyrb.css">
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
</head>
<body>
<div id="root"></div>