Repository navigation
fix(trash): never sweep a note Obsidian trashed under a name the server recorded - #665
Conversation
1481a8e to
c904ddb
Compare
|
Document that trash recorded before this change is no longer auto-cleaned
The upgrade transition this change creates — rows recorded before the identity column keep their files forever (the sweep drops each row with reason unrecorded) — is stated in the PR description's Upgrade note but in no committed user-facing doc: deploy/local/README.md still says the server cleans up the notes it put there after 30 days, and README.md's TRASH_RETENTION_DAYS row says only notes the server itself moved there are cleaned up. A user upgrading with an expired-trash backlog keeps those notes on disk indefinitely, with only the section's existing manual-emptying advice as recovery. Failure scenario: A user upgrades a deployment that has notes sitting in .trash/ recorded by the previous version; when their retention window passes, the sweep logs reason unrecorded, keeps each file, and drops its row, so notes the README promises are cleaned after 30 days stay in .trash/ until the user empties the folder by hand. Suggested fixAdd a one-line upgrade note to deploy/local/README.md's Deleting notes section (mirrored in README.md's TRASH_RETENTION_DAYS row): trash recorded by earlier versions is kept and its row dropped at the next sweep with reason "unrecorded" — empty .trash/ by hand to remove it.umm-actually · z-ai/glm-5.3-flash |
|
umm-actually re-reviewed at No new findings (16 tracked finding(s) across all runs). Context notes
umm-actually · z-ai/glm-5.3-flash |
|
Dispositions for the umm-actually comments outside inline threads at
🔍 ship-check · pr-monitor · claude-opus-5-5 |
|
Complete the truncated template-sync rule sentence
The template-sync rule ends mid-clause (holds verbatim copies of) and runs into the next paragraph, so the sentence names no files and a contributor cannot learn what cli/templates/ must mirror. The dangling object is on main, alongside the env-block rule this PR just satisfied. Failure scenario: A contributor editing deploy/local/docker-compose.yml or a deploy/*/.env.example reads the rule, cannot tell which files cli/templates/ must keep in sync, and ships the change without the mirrored copy — the two surfaces drift until a drift test or a user hits the stale template. Suggested fixRestore the missing object so the rule names the files it covers, e.g. "**Template sync rule:** `cli/templates/` holds verbatim copies of the quickstart files under `deploy/`; change the copy in the same PR as the source." — or delete the sentence if `cli/templates/` no longer exists.umm-actually · deepseek/deepseek-v4.1-flash |
|
Disposition for the umm-actually comment outside inline threads at
🔍 ship-check · pr-monitor · claude-opus-5-5 |
|
Guard the orphan purge's row delete against store throws
Pre-existing: the boot-time orphan purge calls the store's row delete without the per-row containment this PR gave the retention sweep (dropTrashEntry), so one failing delete rejects the whole purge and the remaining orphan rows wait for the next restart instead of the next row. The sweep treats the identical failure per row and keeps going. Failure scenario: The index database hits SQLITE_FULL when the boot purge's deleteTrashEntry fires: the error escapes purgeOrphanedTrashEntries (server.ts logs 'orphaned trash entry purge failed') and every orphan row after the failing one stays unpurged until the next container restart, while the sweep's equivalent drop survives the same failure row by row. Suggested fixWrap the delete in a try/catch that logs the failure with describeError and counts the row as not purged so the loop continues to the remaining rows — the sweep's dropTrashEntry in this same file is the template.umm-actually · z-ai/glm-5.3-flash |
|
Disposition for the umm-actually comment outside inline threads at
🔍 ship-check · pr-monitor · claude-opus-5-5 |
|
Correct the delete tool's unconditional retention claim
The delete tool's model-facing description still promises that a note the server moved to .trash/ �bis deleted after its retention period�, but the identity/change-time gate this PR adds keeps � and unrecords � any such file that was replaced, rewritten, or touched after the 60-second allowance (and every pre-identity row), so the promise the model repeats to users no longer holds. Failure scenario: A user deletes a note under the default system-trash setting; a backup or sync process rewrites the copy in .trash/, so the sweep classifies it Suggested fixState the condition where the description names the outcome, e.g. "Notes this server moved there are deleted after its retention period unless the copy in .trash/ was replaced or changed, in which case it is kept and no longer tracked."umm-actually · deepseek/deepseek-v4.1-flash |
|
Disposition for the umm-actually comment outside inline threads at
🔍 ship-check · pr-monitor · claude-opus-5-5 |
The retention sweep deleted whatever file sat at an expired trash_entries row's path. Emptying .trash/ by hand while the server runs leaves the row behind, so a note Obsidian later trashed under the same name was deleted when the row expired. Each recorded move now stores the landed file's identity (inode number, size and modification time, without the device number, which changes across container restarts on Docker Desktop). The sweep re-reads the identity under the trash lock and unlinks only on a match; a different file is kept and its row dropped. Rows recorded before this change carry no identity and are treated the same way, so files the server trashed before upgrading are no longer cleaned up automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts warn for unrecorded rows SECURITY.md still said every sweep removal passes two gates; it now lists the identity check as the third, matching ARCHITECTURE.md. The kept-file warn claimed the entry 'no longer holds the file the server trashed', which is false for a row recorded before identities were kept; it now says the entry cannot be matched. The sweepExpiredTrashEntries docstring now says only matching files are removed. Ship-Check: pr-review · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device number inside a Docker Desktop bind mount is assigned when the VM mounts the share, so it can change when Docker Desktop restarts; it did not change between two fresh containers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A trash move created its .trash/ folder before taking the lock it shares with the retention sweep. A sweep row that unlinked the folder's last expired file could then remove the still-empty folder before the move's claim, so the delete failed with "cannot move to trash". The folder is now created inside the lock. Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Name the trash move's 100-name bound and build its candidate paths one way; replace a floating .catch with try/catch. - Rename the sweep's "purged" row outcome to "unlinked", and the orphan purge's rowWasRefreshed and entryExists to rowChanged and entryStats; split the sweep's re-read guard into named steps. - Say what a file identity is, why the identity string's format is persisted, why the lock key cannot collide, and why the case-folded key needs a concrete example; give the restore-by-hand rationale one home with pointers. - ARCHITECTURE.md: split the trash claim loop into sub-bullets, mention that unrecorded moves drop stale rows, and use "gates" and "deletes". - SECURITY.md: move the identity gate from Path traversal to Data corruption prevention. Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A note the server trashed, restored out of .trash/ by hand, and trashed again keeps its inode, size and modification time, so the sweep still deleted it when the original row expired. Every rename moves the inode change time, so the identity now includes it. Attribute writes move it too; the sweep then keeps that file past retention and logs a warning, which is the safe failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ach identity field Coverage the identity gate left without a test: - A file that vanishes between the identity check and the unlink (the unlink ENOENT branch) drops its row and counts as missing. - A file standing where an entry's folder was (ENOTDIR on the identity read) drops the row without a warning. - A listed row cleared by a concurrent delete before its turn is skipped and its file kept. Test quality: - The inode, modification-time, and size identity tests compare the identity without its change time, so each fails when its own field is dropped; before, the change time alone made all three pass. - The already-gone and refreshed-row sweep tests assert the sweep's outcome counts, and the refreshed-row test asserts the in-lock re-read. - EACCES warnings and the missing-file rejection assert the exact message, path, and syscall. - The migration test asserts the whole new row under a fixed clock. - Sweep test titles say "unlink", matching the sweep's outcome name. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing it Inside a Docker Desktop bind mount the container keeps reporting a file's old change time after its own rename while the host's value moves, so a stored change time stopped matching after any container restart and the sweep kept every file. The identity is back to inode number, size and modification time. The sweep now also keeps a file whose change time runs more than 60 seconds past the moment its row was recorded, which still catches a note restored by hand and trashed again later, and attribute writes after trashing. The warning names why each file was kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The purge treated a row as unchanged when only its trashedAt matched, so a row replaced within the same second by another file's record, or by a case alias's record, could be dropped and leave that file unrecorded. It now also compares the path spelling and the recorded identity. Two comments are corrected: a rename moves the change time on most file systems, not all, and the "replaced" reason also covers a recorded file that was rewritten. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng to Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The exact-boundary test recorded its row at the real moment, so the file's real change time already met the limit and the test passed even when its reported change time never reached the identity read. The row is now recorded two minutes back, so only the reported change time can unlink the file. - New test: a change time well before the row's trashedAt still unlinks the file, which pins that the limit has no lower bound. - Test titles name trashedAt instead of "its row's moment". - recordEntryAt restores real timers in a finally block, so a throwing record cannot leave Date frozen for later tests. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prune that throws on a hidden path segment, which only a corrupted or hand-edited row can hold, now logs a warning and the sweep moves on to the next row. The local deploy guide and .env.example now say that notes trashed by version 0.54.11 or earlier are kept and need emptying by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring covered only the logged folder-removal failures, so a caller could read it as never throwing. It now names the resolveSafePath error an unsafe path raises before any folder is touched, which the retention sweep's catch relies on. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the change time Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cli/templates/ no longer exists, and the sentence about it stopped mid-clause and ran into the env-block sync rule that follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ensitive mount Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A store failure on a row delete (a full disk fails the delete but not the read) now keeps that row for the next sweep and moves on to the next row, the way a failed realpath or unlink already did. The module docstring now names the restore-and-retrash case the change-time limit cannot catch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The boot-time purge now drops rows through the same guard as the sweep, so one failed delete keeps that row and the purge moves on. The architecture note's attribute-change bullet now carries the one-minute allowance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the row drop failing, a file kept as unmatched leaves the same row, warning, and counts as a file that vanished at the unlink, so the test now asserts the file is gone. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row left behind after a successful unlink is dropped as missing, or as unmatched when another note has since been trashed under that name. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the sweep's comments a shape - tryDropTrashEntry: the try prefix (as in tryClearStaleTrashEntry) tells a reader at each call site that the boolean is whether the drop succeeded; its doc drops the undefined "finished row" - module docstring: the two unlink conditions as a list, and the re-trash exception no longer reads as the server restoring the note by hand - prune comment: one bullet per concern, using the file's "one bad row never aborts the sweep" wording - the case-only sibling note says the two files share one key, not one row (schema comment and ARCHITECTURE.md) - SECURITY.md: comma so "on a file system" no longer attaches to "that minute" - CONTRIBUTING.md: the env-block paragraph gets back a bold label like its Publishing and Beta testing siblings Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The failed-prune test now checks the warning count like its failed-drop siblings, so a second warning on that path fails it. Its comment names the row that must not end the sweep instead of a bare "it". Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as changed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…file Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5d9cab6 to
9d1adc8
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…md to the design Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…terms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds a reader needs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What does this PR do?
The trash retention sweep deleted whatever file sat at an expired
trash_entriesrow's path. Emptying.trash/by hand while the server runs leaves the row behind (the orphan purge runs only at boot), so a note Obsidian later trashed under the same name was deleted once the row expired. The local deploy guide tells users to empty.trash/by hand to reclaim space, and promises that anything Obsidian puts there is never touched.The sweep now deletes a file only while it is still, unchanged, the file the server trashed. Every file it keeps is logged with a warning naming the path and the reason.
Type of change
Checklist
npm testpasses (5321 passed, 2 skipped, macOS, Node 24.18.0)npm run buildsucceedsWhat changed
Recording (
moveNoteToTrash): a recorded trash move stores the landed file's identity next to its path: inode number, size and modification time in nanoseconds, from abigintlstat (readTrashFileState). A failed identity read takes the existing fail-open path: warn, clear any stale row, and the delete still succeeds.Sweep (
sweepOneEntry): after the two containment gates, the sweep reads the file at the row's path under the trash lock and unlinks it only when both hold:Anything else is kept and its row dropped, with a warning carrying
reason:unrecorded(row from before this change),replaced(a different file at the path) orchanged(renamed or attributes written after trashing). The sweep summary counts these asdroppedUnmatched.Store (
search-index.ts):trash_entriesgains a nullablefile_identitycolumn, added to existing databases by the same idempotentALTER TABLEpattern as the other columns.Trash folder race:
moveNoteToTrashcreated its.trash/folder before taking the lock it shares with the sweep, so a sweep pruning that empty folder in between made the delete fail withcannot move to trash. The folder is now created under the lock; a new test failed on the old code.Orphan purge re-read: the purge's in-lock re-read compared only the row's timestamp, so a row replaced within the same second (by a case-variant spelling or another file's record) counted as unchanged and could be dropped, leaving that file unrecorded. It now compares the whole row.
Folder prune: the sweep's prune of the folders an unlink emptied now runs inside its own
try/catch. It throws on a hidden path segment, which only a corrupted or hand-edited row can hold, and that throw used to end the sweep for every row after it.Failed row read or drop: a row re-read or delete that fails (an I/O error, or a full disk on a delete) used to end the sweep, or the boot-time orphan purge, for every row after it. It is now logged, the row is kept for the next run, and the run moves on.
Unrelated doc fix:
CONTRIBUTING.mdlost an unfinished sentence aboutcli/templates/, a folder that no longer exists, which ran into the env-block sync rule after it. The review bot raised it on this PR; it is its own commit.Scheduler:
startTrashSweepScheduleusesasync/awaitwithtry/catch/finallyinstead of a.catch().finally()chain. Behaviour is unchanged; the existing re-arm test covers it.Why these fields
.trash/by hand and put back later, which keeps the other three values.bigintkeeps every digit of the inode number, which can exceed 2^53 for a file ID seen through a Windows bind mount.Why the change time is a limit, not a stored value
A rename updates a file's change time but leaves its inode number, size and modification time alone. So of the four values, only the change time shows that a note was taken out of
.trash/and later put back.Storing the change time and requiring an exact match would make the sweep keep every file on Docker Desktop for macOS, untouched ones included. Measured there:
.trash/, the container keeps reporting the note's change time from before the move. It had not updated 8.5 seconds later, and opening the file or listing the folder did not update it. The Mac had the new value, and a container started later read that new value. So a change time stored at the move would differ from the one read after any container restart.So the server stores no change time. The row already records the moment of the move, written right after the rename, and the sweep deletes a file only when its change time is no later than 60 seconds after that moment. An untouched note passes whichever change time the container reports, the stale one or the real one, because both come before that limit. A note restored and put back later carries the change time of that later move, so it is kept.
The 60-second allowance
The row's moment is stored in whole seconds, and the Mac's clock can run slightly ahead of the container's, so an untouched note's change time can land a little after the recorded moment. The 60 seconds absorb both. The cost is one gap: a note the server trashed, restored by hand and trashed again within 60 seconds of the server's delete still passes, and is deleted when its row expires. A test pins that behaviour so a change to it is deliberate.
Upgrade note
Rows recorded before this change have no identity. The sweep cannot show that such a row still points at the file the server trashed, so it keeps the file and drops the row with a warning (
reason: unrecorded). Notes the server moved to.trash/before upgrading are therefore no longer cleaned up automatically, including a backlog kept underTRASH_RETENTION_DAYS=noneand switched to a number after upgrading; emptying.trash/by hand removes them. The local deploy guide anddeploy/local/.env.examplesay so for version 0.54.11 or earlier.Tests
mainwith the sweep reportingpurged: 1.Datemoved 31 days ahead, as production does, so each file's real change time meets the limit.>=), the allowance (59 or 61 s), the migration, the identity write, the fail-open placement, or the stale-row clear each fails the intended test.replaced), and a note restored and re-trashed on the host 61 seconds later was kept (changed).Live Test Results
Head: 9d1adc8
Image:
ghcr.io/aliasunder/vault-cortex:test(revision label9d1adc8e41bfa70df2886e4904e53cd28e462b53, imagesha256:c65ee78e346e731cff0ecff1563acf8e13fc5e703e444dfdb2cf093e8ec5c16e), built by Test Deploy withoutskip_build. This head is the branch rebased onto main at b9e14e0, so the image runs this PR together with the tag changes main gained.On the deployed instance (Obsidian Sync on, so trash bookkeeping is off there):
:testat 2026-10-10 02:46 UTC with that revision label. It is healthy with 0 restarts, and its boot logs show no warnings or errors.trash_entrieson the instance's existing index still hastrash_key, trash_path, trashed_at, file_identitywith 0 rows, so the migration ran cleanly on an index that already had the column.Probe-665-Casewas written and deleted. A lowercaseprobe-665-casetag search found it first. The server loggeddeleted notewithtrashOption: "none", the note is gone, and no.trash/folder was created.The retention sweep itself, with the same image on the instance host in a separate container with a throwaway vault and index, Obsidian Sync off:
Moved <note> to trash (.trash/<note>), and each row stored an identity, for example.trash/control.md 258729:24:1791600484434668468..trash/recycled.mdwas emptied by hand and a new note was moved in under that name. A minute later.trash/restored.mdwas restored by hand and trashed again.trash retention sweep completewithexpired: 3, purged: 1, droppedMissing: 0, droppedUnmatched: 2, and kept-file warnings for.trash/recycled.md(replaced) and.trash/restored.md(changed)..trash/control.mdwas purged,.trash/recycled.mdwas kept with its new content ("obsidian trashed this"),.trash/restored.mdwas kept, and 0 rows were left.The commits after this head (413b538, a6318b2, 25c13c6, 18b9679, 14a6459, c958ff5) rewrite code comments in
vault-crud-tools.tsandvault-filesystem.tsand trim and reword the trash sections of ARCHITECTURE.md and SECURITY.md; no line that runs changed.🤖 Generated with Claude Code