
Retire files when their job is finished
Files can outlive the form that uploaded them or the record that used them. Wildo tracks those transitions so an abandoned upload, a detached attachment and a deleted file do not become the same unexplained storage object.
The field chooses what a parent deletion means. Background cleanup handles physical removal after the relevant lifecycle delay, keeping storage work separate from the record’s normal write.
Example — Clean up a form that was never submitted
A person uploads two documents and then closes the form. The unattached files become cleanup candidates after the relevant delay: from upload time for unscanned uploads, or from the latest state update after scanning. Cleanup claims each eligible file before removing its bytes, so saving the attachment in the meantime can protect it.
For engineers
Choose the parent-deletion behavior
A file field’s onParentDelete selects cascade, soft-delete or orphan behavior. Cascade is the default. The operation handler currently marks both cascade and soft-delete outcomes as deleted; orphan clears the binding and records the detached state. From file-operation-handler.backend.service.ts, comments are omitted.
switch (behavior) {
case 'cascade':
await this.filesService.deleteFile(fileId, executionContext, options);
break;
case 'soft-delete':
await this.filesService.deleteFile(fileId, executionContext, options);
break;
case 'orphan':
await this.filesService.unlinkFileFromResource(fileId, executionContext, options);
break;
default:
await this.filesService.deleteFile(fileId, executionContext, options);
}
Understand the cleanup windows
The cleanup batch defaults to marking pending uploads failed after one hour, removing eligible unlinked or orphaned files after 24 hours, and hard-deleting files marked deleted for 30 days. It also handles stale failed uploads that may already have left bytes in storage, plus infected or quarantined files past the detached-file threshold. These are batch eligibility thresholds, not exact-time deletion promises.
Pending-state changes use conditional writes so an upload that progressed after the batch listed it is not blindly marked failed. Detached cleanup also rechecks eligibility in an atomic write and marks the winning candidate DELETED before storage deletion. An attachment that wins first no longer matches; an attachment that arrives after the claim is refused. Status updates and unlinks cannot revive the deleted file.
Let the provider remove bytes before metadata
Hard deletion resolves the file’s recorded provider and asks it to delete the bytes before deleting the metadata row. An already-missing blob can proceed to metadata cleanup; other storage errors preserve the row for recovery. This ordering from files.backend.service.ts omits the earlier metadata read and the final result validation.
try {
const storageProvider = this.getStorageProviderForFile(currentFile, executionContext);
await storageProvider.delete(currentFile, executionContext);
} catch (error) {
if (!isWildoBackendError(error) || error.type !== ErrorType.NOT_FOUND) {
throw error;
}
this.logDebug('File blob already missing during hard delete, continuing metadata cleanup', {
fileId,
});
}
const deleteContext = await this.createInternalExecutionContext(
CoreResourceOperation.DELETE,
executionContext
);
const deletedFileId = await this.filesRepository.delete(
deleteContext,
{ _id: fileId },
options
);
Separate ordinary deletion from erasure
Normal deletion changes lifecycle state; privacy erasure additionally removes names and uploader attribution. Both can defer physical cleanup. A provider’s ownership contract still applies: Google Drive cleanup forgets the application’s reference and deliberately retains the person’s remote file.
Run the cleanup batch in the deployment and give it access to the same storage as uploads. Database writes and remote byte deletion are separate effects; the lifecycle and retryable cleanup step avoid pretending they share one atomic transaction.
Declare the lifecycle, then verify the engine job
These illustrative fields choose different outcomes when their parent is deleted:
import { z_file } from '@wildo-ai/zod-decorators';
const disposableAttachment = z_file({
onParentDelete: 'cascade',
});
const detachedAttachment = z_file({
onParentDelete: 'orphan',
});
Cascade marks the file deleted for later physical cleanup. Orphan removes the parent binding and leaves the file in the detached lifecycle; it is not a promise to retain the file indefinitely. The current soft-delete choice follows the same deletion call as cascade.
Standard engine startup already registers files-cleanup through this call. Application authors do not need a duplicate custom batch:
customBatches.set(FILES_CLEANUP_BATCH_REF, createFilesCleanupBatch());
Its engine manifest anchors the job to FILES / UPDATE and schedules 0 * * * * (hourly). Verify that the deployment’s batch execution service is running and can reach the same database and storage as the API. Registration alone does not prove a scheduled execution completed.
| Example at a cleanup run, using default thresholds | Eligibility |
|---|---|
PENDING, uploaded 61 minutes ago | Conditional transition to FAILED, provided it has not progressed since it was listed |
UPLOADED, no binding fields, uploaded 25 hours ago | Abandoned-upload cleanup candidate |
ORPHANED, updated 25 hours ago | Detached-file cleanup candidate; the age uses updatedAt |
CLEAN, no binding fields, last updated 25 hours ago | Scanned abandoned-upload cleanup candidate |
CLEAN, uploaded several days ago but scanned within the last day | Preserved: cleanup measures its latest update, not its upload start |
LINKED, uploaded several days ago | Not an abandoned-upload candidate merely because it is old |
DELETED, with deletedAt more than 30 days ago | Hard-deletion candidate |
The comparisons use strict age cutoffs: reaching exactly the threshold is not a guarantee of deletion at that instant. The next successful scheduled run must find the eligible state and complete storage cleanup. If storage deletion fails after a detached-file claim, its DELETED row remains with the real claim time in deletedAt; the ordinary 30-day retention query supplies the retry. The provider’s deletion contract determines whether bytes are removed or, for Drive, the application only forgets its reference.