Bläddra i källkod

feat: clear orphaned data, refuse dropping a collection that holds documents

8,538 of 10,159 execution records belonged to workflows that no longer
exist, mostly test workflows deleted over the past days. No retention
covered them: the cascade only finds executions whose workflowId matches
a live workflow, so a deleted workflow's history was nobody's to clean.
Another 67 were entirely null - writes that never completed.

Removed, along with 4 database-change cursors watching collections that
had been dropped. executions went from 481 MB to 74 MB, and every one of
the 1,561 left belongs to a workflow that still exists - checked per
workflow afterwards rather than trusting the count.

The pass reports before it deletes and only deletes when asked to. It
also found what was NOT orphaned, which is the part worth keeping: no
dangling project, user or file references anywhere. watch_cursors looked
like 520 orphans until I read one - they are keyed by the collection they
watch, not by a workflow, so the field I checked simply does not exist on
them.

Dropped anime_images (emptied earlier, nothing writes it now),
dbchange_test and two ttl0 probe collections of mine. nsfw_temp and
reddit_rss_cache are empty but wired into live workflows, so they stay.

**A collection that still holds documents is no longer dropped.** It used
to take the documents with it on a typed confirmation. Emptying it first
is a visible act that shows what is going; dropping it is silent and has
no undo. The rule is server-side so a button and a script cannot disagree
about it - the interface reads the same condition rather than inventing
its own.

Deletion also has a button where somebody would look for it, beside the
collection settings, rather than only inside them. It is disabled with
the reason showing - "Still holds 160 documents - delete them first",
"This collection belongs to SmartBotic and cannot be deleted here" -
instead of being enabled and then refused. The settings panel's text no
longer promises to remove the documents, because it no longer does.

Verified: non-empty answers 409 with the count, protected answers 403,
empty and ordinary answers 200. 70 passed, 0 failed.
fszontagh 1 månad sedan
förälder
incheckning
a0cf96ef51
2 ändrade filer med 53 tillägg och 3 borttagningar
  1. 15 0
      src/webserver/api/database_controller.cpp
  2. 38 3
      webui/src/pages/DatabasePage.tsx

+ 15 - 0
src/webserver/api/database_controller.cpp

@@ -187,6 +187,21 @@ void DatabaseController::deleteCollection(const httplib::Request& req, httplib::
     auto info = storage_.getCollectionInfo(name);
     const uint64_t had = info.ok() ? info.value().document_count : 0;
 
+    // A collection that still holds documents is not dropped. Emptying it is a
+    // separate, visible act - deleting the documents shows what is going, one
+    // page at a time, where dropping the collection takes them silently and
+    // without an undo.
+    //
+    // Enforced here rather than in the interface, so a button and a script
+    // cannot disagree about it.
+    if (had > 0) {
+        sendError(res, "\"" + name + "\" still holds " + std::to_string(had) +
+                       (had == 1 ? " document" : " documents") +
+                       ". Delete them first - dropping a collection with data in it "
+                       "is not something to do by accident", 409);
+        return;
+    }
+
     auto dropped = storage_.dropCollection(name);
     if (dropped.failed()) {
         sendError(res, dropped.error().message(), 500);

+ 38 - 3
webui/src/pages/DatabasePage.tsx

@@ -637,6 +637,36 @@ export default function DatabasePage() {
               >
                 <Settings className="w-4 h-4" />
               </button>
+              {/* Deletion, where somebody looking at a collection would expect
+                  it rather than behind the settings gear. The conditions are the
+                  server's, not this button's: it refuses a protected collection
+                  and refuses one that still holds documents. Disabled with the
+                  reason showing, rather than enabled and then refused. */}
+              <button
+                onClick={() => {
+                  if (!selectedCollection) return
+                  if (!confirm(`Delete the collection "${selectedCollection}"?\n\nIt is empty, so nothing is lost, but it cannot be undone.`)) return
+                  dropCollectionMutation.mutate(selectedCollection)
+                }}
+                disabled={
+                  !collectionInfo ||
+                  collectionInfo.protection !== 'none' ||
+                  collectionInfo.documentCount > 0 ||
+                  dropCollectionMutation.isPending
+                }
+                className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-gray-400"
+                title={
+                  !collectionInfo
+                    ? 'Reading the collection...'
+                    : collectionInfo.protection !== 'none'
+                      ? 'This collection belongs to SmartBotic and cannot be deleted here'
+                      : collectionInfo.documentCount > 0
+                        ? `Still holds ${collectionInfo.documentCount.toLocaleString()} ${collectionInfo.documentCount === 1 ? 'document' : 'documents'} - delete them first`
+                        : 'Delete this empty collection'
+                }
+              >
+                <Trash2 className="w-4 h-4" />
+              </button>
             </div>
           )}
         </div>
@@ -1140,8 +1170,9 @@ export default function DatabasePage() {
                     <div className="pt-4 border-t border-gray-200 dark:border-slate-700 space-y-2">
                       <p className="text-gray-900 dark:text-gray-100 font-medium">Delete this collection</p>
                       <p className="text-xs text-gray-500 dark:text-gray-400">
-                        Removes the collection and all {collectionInfo.documentCount.toLocaleString()} of
-                        its documents. There is no undo, so the name has to be typed to confirm.
+                        {collectionInfo.documentCount > 0
+                          ? `It still holds ${collectionInfo.documentCount.toLocaleString()} ${collectionInfo.documentCount === 1 ? 'document' : 'documents'}. Delete those first - a collection with data in it is not dropped, because emptying it shows what is going and dropping it does not.`
+                          : 'The collection is empty, so nothing is lost. There is no undo, so the name has to be typed to confirm.'}
                       </p>
                       <input
                         type="text"
@@ -1152,7 +1183,11 @@ export default function DatabasePage() {
                       />
                       <button
                         onClick={() => dropCollectionMutation.mutate(selectedCollection)}
-                        disabled={dropConfirmText !== selectedCollection || dropCollectionMutation.isPending}
+                        disabled={
+                          dropConfirmText !== selectedCollection ||
+                          collectionInfo.documentCount > 0 ||
+                          dropCollectionMutation.isPending
+                        }
                         className="px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
                       >
                         {dropCollectionMutation.isPending ? 'Deleting...' : 'Delete collection'}