Coverage for src/local_deep_research/research_library/deletion/services/document_deletion.py: 97%
203 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Document deletion service.
4Handles:
5- Full document deletion with proper cascade cleanup
6- Blob-only deletion (remove PDF, keep text)
7- Remove from collection (unlink or delete if orphaned)
8"""
10import threading
11from contextlib import contextmanager
12from typing import Any, Dict, Iterator, List, Tuple
14from loguru import logger
16from ....constants import FILE_PATH_BLOB_DELETED
17from ....database.models.library import (
18 Collection,
19 Document,
20 DocumentChunk,
21 DocumentCollection,
22 RagDocumentStatus,
23 SourceType,
24)
25from ....database.session_context import get_user_db_session
26from ....database.session_passwords import capture_request_db_password
27from ..utils.cascade_helper import CascadeHelper
30# Document deletion is a read-then-delete workflow with filesystem and FAISS
31# side effects around the database transaction. Serialize it per user/document
32# across service instances so duplicate browser requests cannot both pass the
33# initial existence check. The registry is ref-counted so completed document
34# IDs do not accumulate for the lifetime of the process.
35_document_delete_locks: Dict[Tuple[str, str], threading.Lock] = {}
36_document_delete_lock_refs: Dict[Tuple[str, str], int] = {}
37_document_delete_locks_guard = threading.Lock()
40@contextmanager
41def _hold_document_delete_lock(
42 username: str, document_id: str
43) -> Iterator[None]:
44 """Hold the process-local lock for one user's document deletion.
46 The lock is intentionally non-reentrant. Callers must not start a nested
47 deletion for the same user and document while this context is active.
48 """
49 key = (username, document_id)
50 with _document_delete_locks_guard:
51 lock = _document_delete_locks.get(key)
52 if lock is None:
53 lock = threading.Lock()
54 _document_delete_locks[key] = lock
55 _document_delete_lock_refs[key] = (
56 _document_delete_lock_refs.get(key, 0) + 1
57 )
59 try:
60 with lock:
61 yield
62 finally:
63 with _document_delete_locks_guard:
64 remaining = _document_delete_lock_refs[key] - 1
65 if remaining:
66 _document_delete_lock_refs[key] = remaining
67 else:
68 _document_delete_lock_refs.pop(key, None)
69 # Identity check protects against future registry maintenance
70 # replacing the lock while this holder is winding down.
71 if _document_delete_locks.get(key) is lock: 71 ↛ exitline 71 didn't jump to the function exit
72 _document_delete_locks.pop(key, None)
75def _is_note_document(session, document) -> bool:
76 """Return True if the document represents a note (source_type='note').
78 Notes have their own deletion API at /api/notes/<id>. Letting them be
79 deleted via the generic document-deletion endpoints would bypass the
80 B1 protection that was specifically built to prevent note data loss
81 (see PROTECTED_COLLECTION_TYPES in collection_deletion.py).
83 The check is local-by-design — keep this module free of imports from
84 the notes package to avoid an import cycle.
85 """
86 note_source = session.query(SourceType).filter_by(name="note").first()
87 return bool(note_source) and document.source_type_id == note_source.id
90class DocumentDeletionService:
91 """Service for document deletion operations."""
93 def __init__(self, username: str):
94 """
95 Initialize document deletion service.
97 Args:
98 username: Username for database session
99 """
100 self.username = username
102 def delete_document(self, document_id: str) -> Dict[str, Any]:
103 """Delete one document, serializing duplicate in-process requests."""
104 with _hold_document_delete_lock(self.username, document_id):
105 result, rag_collection_ids = self._delete_document_locked(
106 document_id
107 )
109 # Post-commit FAISS + chunk-row cleanup. Runs after the DB transaction
110 # has committed AND the keyed lock is released, allowing concurrent
111 # requests to immediately observe "Document not found" without waiting
112 # for slow vector-store IO.
113 if result.get("deleted"):
114 self._purge_document_rag(
115 document_id, rag_collection_ids, full_delete=True
116 )
118 return result
120 def _delete_document_locked(
121 self, document_id: str
122 ) -> Tuple[Dict[str, Any], List[str]]:
123 """Delete a document and all related data while its keyed lock is held.
125 This method ensures complete DB/file cleanup:
126 - DocumentChunks (no FK constraint, manual cleanup required)
127 - DocumentBlob (CASCADE handles, but we track for stats)
128 - Filesystem files
129 - DownloadTracker update
130 - DocumentCollection links (CASCADE)
131 - RagDocumentStatus (CASCADE)
133 Args:
134 document_id: ID of the document to delete
136 Returns:
137 Tuple of (result_dict, rag_collection_ids) for post-commit purge.
138 """
139 result: Dict[str, Any] = {
140 "deleted": False,
141 "document_id": document_id,
142 "title": None,
143 "blob_deleted": False,
144 "blob_size": 0,
145 "chunks_deleted": 0,
146 "collections_unlinked": 0,
147 "file_deleted": False,
148 }
149 rag_collection_ids: List[str] = []
151 with get_user_db_session(self.username) as session:
152 try:
153 # Get document
154 document = session.get(Document, document_id)
155 if not document:
156 return {
157 "deleted": False,
158 "document_id": document_id,
159 "error": "Document not found",
160 }, []
162 # Notes are not deletable via the generic document API.
163 # The Notes-collection deletion guard only blocks the
164 # cascade path; without this check a user could wipe an
165 # individual note by passing its UUID here, and the bulk
166 # endpoint amplifies the same gap. Route the caller to
167 # DELETE /api/notes/<id> which goes through NoteService
168 # with the proper version/link cleanup.
169 if _is_note_document(session, document):
170 return {
171 "deleted": False,
172 "document_id": document_id,
173 "title": document.title or "Untitled",
174 "error": (
175 "Notes cannot be deleted via the document API. "
176 "Use DELETE /api/notes/<id> instead."
177 ),
178 "is_note": True,
179 }, []
181 title = document.title or document.filename or "Untitled"
182 result["title"] = title
184 # 1. Get collections before deletion for chunk cleanup
185 collections = CascadeHelper.get_document_collections(
186 session, document_id
187 )
188 result["collections_unlinked"] = len(collections)
190 # Every collection that may hold FAISS vectors / chunk rows
191 # for this document: the UNION of its linked collections and
192 # the collections that actually have chunk rows. The chunk-row
193 # half matters because a partial/failed reindex can leave
194 # chunks in a collection whose join row is gone or was never
195 # marked indexed. Used by the post-commit RAG purge below.
196 chunk_collection_rows = (
197 session.query(DocumentChunk.collection_name)
198 .filter_by(source_type="document", source_id=document_id)
199 .distinct()
200 .all()
201 )
202 rag_collection_ids = list(
203 {str(c) for c in collections}
204 | {
205 row[0].removeprefix("collection_")
206 for row in chunk_collection_rows
207 if row[0]
208 }
209 )
211 # 2. Count chunks for stats. The rows themselves (and their
212 # FAISS vectors) are removed in the post-commit RAG cleanup
213 # phase below — purge_document_vectors resolves the FAISS
214 # int ids from these rows, so they must outlive this
215 # transaction (mirrors NoteService.delete_note).
216 result["chunks_deleted"] = (
217 session.query(DocumentChunk)
218 .filter(
219 DocumentChunk.source_id == document_id,
220 DocumentChunk.source_type == "document",
221 )
222 .count()
223 )
225 # 3. Get blob size before deletion (for stats)
226 result["blob_size"] = CascadeHelper.get_document_blob_size(
227 session, document_id
228 )
229 result["blob_deleted"] = result["blob_size"] > 0
231 # 4. Delete filesystem file if exists
232 if document.storage_mode == "filesystem" and document.file_path:
233 from ...utils import get_absolute_path_from_settings
235 try:
236 # Delete only within this user's own root — never the
237 # legacy shared root, where a colliding relative path
238 # can resolve to another tenant's file (#5521).
239 file_path = get_absolute_path_from_settings(
240 document.file_path,
241 self.username,
242 allow_legacy_fallback=False,
243 )
244 # get_absolute_path_from_settings("") returns the
245 # library root (its `if not relative_path` branch),
246 # never None, so this can't currently trip. Guarded
247 # anyway: _resolved_containment_ok() treats
248 # allowed_root=None as "no boundary enforced" for
249 # callers that can't cheaply supply a root (e.g. the
250 # FAISS sweeps), which is fine there but would be a
251 # silent fail-open here. Fail closed instead of
252 # unlinking with no containment boundary.
253 allowed_root = get_absolute_path_from_settings("")
254 if file_path and allowed_root is not None:
255 result["file_deleted"] = (
256 CascadeHelper.delete_filesystem_file(
257 str(file_path),
258 allowed_root=allowed_root,
259 )
260 )
261 elif file_path: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 logger.error(
263 "Refusing to delete filesystem file: "
264 "could not resolve library root for "
265 "containment check"
266 )
267 except Exception:
268 logger.exception("Failed to delete filesystem file")
270 # 5. Update DownloadTracker
271 CascadeHelper.update_download_tracker(session, document)
273 # 6. Delete the document and all related records (leaves the
274 # DocumentChunk rows for the post-commit cleanup phase).
275 # The helper's row count is authoritative: a concurrent
276 # cross-process request can still win after our initial read
277 # because the keyed lock is intentionally process-local.
278 # Never report success or purge RAG state when this DELETE
279 # affected no document row.
280 actually_deleted = CascadeHelper.delete_document_completely(
281 session, document_id
282 )
283 if not actually_deleted:
284 logger.info(
285 f"Document {document_id[:8]}... was already removed "
286 "by a concurrent delete request"
287 )
288 session.rollback()
289 return {
290 "deleted": False,
291 "document_id": document_id,
292 "error": "Document not found",
293 }, []
295 session.commit()
297 result["deleted"] = True
298 logger.info(
299 f"Deleted document {document_id[:8]}... ({title}): "
300 f"{result['chunks_deleted']} chunks, "
301 f"{result['blob_size']} bytes blob"
302 )
303 return result, rag_collection_ids
305 except Exception:
306 logger.exception(f"Failed to delete document {document_id}")
307 session.rollback()
308 return {
309 "deleted": False,
310 "document_id": document_id,
311 "error": "Failed to delete document",
312 }, []
314 def _purge_document_rag(
315 self,
316 document_id: str,
317 collection_ids: List[str],
318 *,
319 full_delete: bool,
320 ) -> None:
321 """Remove a document's FAISS vectors and chunk rows post-commit.
323 Called after the deletion/unlink transaction has committed —
324 releasing SQLite's single write lock — so the slow vector-store IO
325 here never blocks another writer. For each collection the vectors
326 are purged BEFORE their chunk rows (``purge_document_vectors``
327 resolves the FAISS int ids from those rows), then the rows are
328 deleted.
330 A final sweep then guarantees no chunk row survives even when the
331 per-collection purge could not run — e.g. the DB password is
332 unavailable on an encrypted-DB install, or a collection has no
333 current FAISS index. ``full_delete`` selects the sweep scope: True
334 (the document is gone) sweeps every remaining chunk row for the
335 document; False (a single unlink) sweeps only the listed
336 collections, leaving the document's chunks in its other collections
337 intact.
339 Best-effort: the DB delete already committed, so failures here are
340 logged, not surfaced.
341 """
342 if collection_ids:
343 dbpw = capture_request_db_password(self.username)
344 from ...services.rag_service_factory import get_rag_service
346 for collection_id in collection_ids:
347 try:
348 # A fresh service per collection so the factory resolves
349 # THAT collection's stored embedding model/provider — a
350 # shared instance would compute the wrong index hash and
351 # purge nothing for non-default-embedding collections.
352 with get_rag_service(
353 self.username,
354 collection_id=collection_id,
355 db_password=dbpw,
356 ) as rag:
357 rag.purge_document_vectors(document_id, collection_id)
358 rag.purge_document_chunks(document_id, collection_id)
359 except Exception:
360 # exception=True keeps the stack trace. dbpw is a live
361 # local here, but frame-local values only render under
362 # the opt-in LDR_LOGURU_DIAGNOSE dev flag — every
363 # persistent sink sets diagnose=False — so the password
364 # cannot reach a log in normal operation.
365 logger.opt(exception=True).error(
366 "Failed to remove RAG entries for document {} "
367 "from collection {}",
368 document_id,
369 collection_id,
370 )
372 # Guaranteed backstop: remove any chunk rows the RAG purge could not
373 # reach. Their vectors can't be resolved without the rows, but the
374 # collection-scoped rehydration filter already stops an orphaned
375 # vector from ever surfacing another document's text, so dropping the
376 # rows here keeps the deletion complete and safe.
377 try:
378 with get_user_db_session(self.username) as session:
379 if full_delete:
380 CascadeHelper.delete_document_chunks(session, document_id)
381 else:
382 for collection_id in collection_ids:
383 CascadeHelper.delete_document_chunks(
384 session,
385 document_id,
386 f"collection_{collection_id}",
387 )
388 session.commit()
389 except Exception:
390 logger.opt(exception=True).error(
391 "Failed to sweep residual chunk rows for document {}",
392 document_id,
393 )
395 def delete_blob_only(self, document_id: str) -> Dict[str, Any]:
396 """
397 Delete PDF binary but keep document metadata and text content.
399 This saves database space while preserving searchability.
401 Args:
402 document_id: ID of the document
404 Returns:
405 Dict with deletion details:
406 {
407 "deleted": True/False,
408 "document_id": str,
409 "bytes_freed": int,
410 "storage_mode_updated": bool,
411 "error": str (if failed)
412 }
413 """
414 with get_user_db_session(self.username) as session:
415 try:
416 # Get document
417 document = session.get(Document, document_id)
418 if not document:
419 return {
420 "deleted": False,
421 "document_id": document_id,
422 "bytes_freed": 0,
423 "error": "Document not found",
424 }
426 # Notes are not mutable via the generic blob-delete API.
427 # Without this guard, a note's storage_mode/file_path can
428 # be overwritten with the PDF-blob-deletion sentinel via
429 # either DELETE /library/api/document/<id>/blob OR the
430 # bulk DELETE /library/api/documents/blobs path (which
431 # loops over this method per-id).
432 if _is_note_document(session, document):
433 return {
434 "deleted": False,
435 "document_id": document_id,
436 "bytes_freed": 0,
437 "error": (
438 "Notes cannot be modified via the document API. "
439 "Use the notes API instead."
440 ),
441 "is_note": True,
442 }
444 result = {
445 "deleted": False,
446 "document_id": document_id,
447 "bytes_freed": 0,
448 "storage_mode_updated": False,
449 }
451 # Handle based on storage mode
452 if document.storage_mode == "database":
453 # Delete blob from database
454 result["bytes_freed"] = CascadeHelper.delete_document_blob(
455 session, document_id
456 )
458 elif document.storage_mode == "filesystem":
459 # Delete filesystem file
460 from ...utils import get_absolute_path_from_settings
462 if document.file_path:
463 try:
464 # Own-root only: never unlink via the legacy
465 # shared root (cross-tenant collision, #5521).
466 file_path = get_absolute_path_from_settings(
467 document.file_path,
468 self.username,
469 allow_legacy_fallback=False,
470 )
471 # See the fail-closed comment on the equivalent
472 # guard in _delete_document_locked() above:
473 # allowed_root should never be None here, but a
474 # destructive unlink must not silently fall back
475 # to a boundary-less containment check if it
476 # ever were.
477 allowed_root = get_absolute_path_from_settings("")
478 if (
479 file_path
480 and file_path.is_file()
481 and allowed_root is not None
482 ):
483 result["bytes_freed"] = file_path.stat().st_size
484 CascadeHelper.delete_filesystem_file(
485 str(file_path),
486 allowed_root=allowed_root,
487 )
488 elif file_path and file_path.is_file(): 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true
489 logger.error(
490 "Refusing to delete filesystem file: "
491 "could not resolve library root for "
492 "containment check"
493 )
494 except Exception:
495 logger.exception("Failed to delete filesystem file")
497 else:
498 # No blob to delete
499 return {
500 "deleted": False,
501 "document_id": document_id,
502 "bytes_freed": 0,
503 "error": "Document has no stored PDF (storage_mode is 'none')",
504 }
506 # Update document to indicate blob is deleted
507 document.storage_mode = "none"
508 document.file_path = FILE_PATH_BLOB_DELETED
509 result["storage_mode_updated"] = True
511 session.commit()
512 result["deleted"] = True
514 logger.info(
515 f"Deleted blob for document {document_id[:8]}...: "
516 f"{result['bytes_freed']} bytes freed"
517 )
519 return result
521 except Exception:
522 logger.exception(
523 f"Failed to delete blob for document {document_id}"
524 )
525 session.rollback()
526 return {
527 "deleted": False,
528 "document_id": document_id,
529 "bytes_freed": 0,
530 "error": "Failed to delete document blob",
531 }
533 def remove_from_collection(
534 self,
535 document_id: str,
536 collection_id: str,
537 ) -> Dict[str, Any]:
538 """
539 Remove document from a collection.
541 If the document is not in any other collection after removal,
542 it will be completely deleted.
544 Args:
545 document_id: ID of the document
546 collection_id: ID of the collection
548 Returns:
549 Dict with operation details:
550 {
551 "unlinked": True/False,
552 "document_deleted": bool,
553 "document_id": str,
554 "collection_id": str,
555 "chunks_deleted": int,
556 "error": str (if failed)
557 }
558 """
559 with get_user_db_session(self.username) as session:
560 try:
561 # Verify document exists
562 document = session.get(Document, document_id)
563 if not document:
564 return {
565 "unlinked": False,
566 "document_deleted": False,
567 "document_id": document_id,
568 "collection_id": collection_id,
569 "error": "Document not found",
570 }
572 # Verify collection exists and document is in it
573 doc_collection = (
574 session.query(DocumentCollection)
575 .filter_by(
576 document_id=document_id, collection_id=collection_id
577 )
578 .first()
579 )
581 if not doc_collection:
582 return {
583 "unlinked": False,
584 "document_deleted": False,
585 "document_id": document_id,
586 "collection_id": collection_id,
587 "error": "Document not in this collection",
588 }
590 # Permanent-home guard: the Notes collection is every
591 # note's home (_get_or_create_notes_collection); nothing
592 # re-links a note after an unlink, so removal here would
593 # silently drop the note from semantic search, the
594 # collection view, and the auto-reindex worker — while
595 # list_notes still shows it. NoteService.remove_from_collection
596 # enforces this for the notes API; this mirrors it for the
597 # generic collection-document API (single + bulk).
598 collection = session.get(Collection, collection_id)
599 if (
600 collection is not None
601 and collection.collection_type == "notes"
602 and _is_note_document(session, document)
603 ):
604 return {
605 "unlinked": False,
606 "document_deleted": False,
607 "document_id": document_id,
608 "collection_id": collection_id,
609 "protected": True,
610 "error": (
611 "Cannot remove a note from its notes "
612 "collection — it is the note's permanent "
613 "home. Use DELETE /api/notes/<id> to delete "
614 "the note itself."
615 ),
616 }
618 result = {
619 "unlinked": False,
620 "document_deleted": False,
621 "document_id": document_id,
622 "collection_id": collection_id,
623 "chunks_deleted": 0,
624 }
626 # Count chunks for this document in this collection (for
627 # stats). Their rows and FAISS vectors are removed in the
628 # post-commit RAG cleanup phase — purge_document_vectors
629 # resolves the FAISS int ids from these rows, so they must
630 # outlive this transaction.
631 collection_name = f"collection_{collection_id}"
632 result["chunks_deleted"] = (
633 session.query(DocumentChunk)
634 .filter(
635 DocumentChunk.source_id == document_id,
636 DocumentChunk.source_type == "document",
637 DocumentChunk.collection_name == collection_name,
638 )
639 .count()
640 )
642 # Remove the link
643 session.delete(doc_collection)
644 # Clear the RagDocumentStatus row for this (document, collection)
645 # — the "indexed" marker read by get_rag_stats / the RAG status
646 # route. Its FK cascades fire only when the Document, Collection,
647 # or RAGIndex is deleted; a mere unlink deletes none of those, so
648 # a leftover row reports the just-removed document as still
649 # indexed in this collection.
650 session.query(RagDocumentStatus).filter_by(
651 document_id=document_id, collection_id=collection_id
652 ).delete(synchronize_session=False)
653 session.flush()
655 # Check if document is in any other collection
656 remaining_count = CascadeHelper.count_document_in_collections(
657 session, document_id
658 )
660 # Default (unlink, and note-orphan): purge only THIS
661 # collection's vectors/chunks; the document (or note) and its
662 # chunks in other collections stay intact.
663 full_delete = False
664 rag_collection_ids: List[str] = [collection_id]
666 if remaining_count == 0 and _is_note_document( 666 ↛ 676line 666 didn't jump to line 676 because the condition on line 666 was never true
667 session, document
668 ):
669 # Note that was orphaned by this removal: skip the
670 # destructive delete_document_completely path. Notes
671 # live behind their own deletion API (DELETE /api/notes/<id>)
672 # and are still discoverable via list_notes() which
673 # filters by source_type_id, not collection membership.
674 # The Notes collection itself acts as the persistent
675 # home for every note via _get_or_create_notes_collection.
676 logger.info(
677 f"Note {document_id[:8]}... orphaned from collection — "
678 "skipping orphan delete; use DELETE /api/notes/<id> to remove."
679 )
680 elif remaining_count == 0:
681 # Document is orphaned - delete it completely
682 # Note: We're already in a session, so we need to do this
683 # directly rather than calling delete_document()
684 logger.info(
685 f"Document {document_id[:8]}... is orphaned, deleting"
686 )
688 # Full delete: purge vectors/chunks across EVERY collection
689 # that still holds chunk rows for this document, not just
690 # the one being removed (a partial reindex can strand
691 # chunks elsewhere). Rows are deleted in the post-commit
692 # phase so purge_document_vectors can resolve their ids.
693 chunk_collection_rows = (
694 session.query(DocumentChunk.collection_name)
695 .filter_by(
696 source_type="document", source_id=document_id
697 )
698 .distinct()
699 .all()
700 )
701 full_delete = True
702 rag_collection_ids = list(
703 {collection_id}
704 | {
705 row[0].removeprefix("collection_")
706 for row in chunk_collection_rows
707 if row[0]
708 }
709 )
711 # Update DownloadTracker
712 CascadeHelper.update_download_tracker(session, document)
714 # Delete filesystem file if applicable
715 if (
716 document.storage_mode == "filesystem"
717 and document.file_path
718 ):
719 from ...utils import get_absolute_path_from_settings
721 try:
722 # Own-root only: never unlink via the legacy
723 # shared root (cross-tenant collision, #5521).
724 file_path = get_absolute_path_from_settings(
725 document.file_path,
726 self.username,
727 allow_legacy_fallback=False,
728 )
729 # See the fail-closed comment on the equivalent
730 # guard in _delete_document_locked() above:
731 # allowed_root should never be None here, but a
732 # destructive unlink must not silently fall back
733 # to a boundary-less containment check if it
734 # ever were.
735 allowed_root = get_absolute_path_from_settings("")
736 if file_path and allowed_root is not None:
737 CascadeHelper.delete_filesystem_file(
738 str(file_path),
739 allowed_root=allowed_root,
740 )
741 elif file_path: 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true
742 logger.error(
743 "Refusing to delete filesystem file: "
744 "could not resolve library root for "
745 "containment check"
746 )
747 except Exception:
748 logger.exception("Failed to delete filesystem file")
750 # Delete document and all related records (leaves the
751 # DocumentChunk rows for the post-commit cleanup phase).
752 CascadeHelper.delete_document_completely(
753 session, document_id
754 )
755 result["document_deleted"] = True
757 session.commit()
758 result["unlinked"] = True
760 logger.info(
761 f"Removed document {document_id[:8]}... from collection "
762 f"{collection_id[:8]}... "
763 f"(deleted={result['document_deleted']})"
764 )
766 except Exception:
767 logger.exception(
768 f"Failed to remove document {document_id} "
769 f"from collection {collection_id}"
770 )
771 session.rollback()
772 return {
773 "unlinked": False,
774 "document_deleted": False,
775 "document_id": document_id,
776 "collection_id": collection_id,
777 "error": "Failed to remove document from collection",
778 }
780 # Post-commit FAISS + chunk-row cleanup, after the transaction has
781 # committed and released SQLite's write lock.
782 self._purge_document_rag(
783 document_id, rag_collection_ids, full_delete=full_delete
784 )
786 return result
788 def get_deletion_preview(self, document_id: str) -> Dict[str, Any]:
789 """
790 Get a preview of what will be deleted.
792 Useful for showing the user what will happen before confirming.
794 Args:
795 document_id: ID of the document
797 Returns:
798 Dict with preview information
799 """
800 with get_user_db_session(self.username) as session:
801 document = session.get(Document, document_id)
802 if not document:
803 return {"found": False, "document_id": document_id}
805 collections = CascadeHelper.get_document_collections(
806 session, document_id
807 )
809 # Count chunks
810 total_chunks = (
811 session.query(DocumentChunk)
812 .filter(
813 DocumentChunk.source_id == document_id,
814 DocumentChunk.source_type == "document",
815 )
816 .count()
817 )
819 blob_size = CascadeHelper.get_document_blob_size(
820 session, document_id
821 )
823 return {
824 "found": True,
825 "document_id": document_id,
826 "title": document.title or document.filename or "Untitled",
827 "file_type": document.file_type,
828 "storage_mode": document.storage_mode,
829 "has_blob": blob_size > 0,
830 "blob_size": blob_size,
831 "has_text": bool(document.text_content),
832 "collections_count": len(collections),
833 "chunks_count": total_chunks,
834 }