feat(documents): delete documents for real - #137
Merged
Conversation
DeleteDocument reported success and kept the data. ModelDocument carries gorm.DeletedAt, so the delete wrote a tombstone and left the row - including the `data` jsonb, which for inference sinks holds base64 image bytes. That was the worst of both halves. There is no Undelete RPC, no Unscoped() read anywhere, and every read path filters deleted_at IS NULL - so the retained data could never be recovered by anyone, while remaining in the database indefinitely. DataMetadata._deletedAt is populated in the DTO but unreachable by any code path, which is the same fact from the other side. We paid soft delete's cost for none of its benefit. Nothing observable to a client changes: the API already answered 200 and then 404, exactly as a hard delete does. What changes is that the answer is now true. This is what FootprintAI/grandturks#936 asked for and what #126 deliberately left open, being the one irreversible decision in the area. Both document delete paths are Unscoped: Delete a single document DeleteByCollection the cascade behind DeleteCollection(force=true), which is exactly when a caller expects payloads to be gone Collections are deliberately NOT changed. A collection row carries an id, a type and a summary rather than user payload, and hard-deleting one fires the OnDelete:SET NULL constraint on restcol-collections-schema, orphaning schema rows. That is a separate change with its own consequences. The gorm.DeletedAt fields stay on the models on purpose. Removing them would stop gorm filtering deleted_at IS NULL, and any tombstone already in a database would become visible again - resurrecting documents that callers were told were deleted. Keeping the field costs nothing now that nothing sets it. migrations/0002 erases the tombstones that already exist, since those rows are the pre-existing half of the same problem. Its down migration is deliberately a no-op with a comment saying so: nothing can restore them, and a down file that pretends otherwise is worse than one that admits it. Environments running --restcol_auto_migrate=true never apply SQL migrations, so their old tombstones persist until purged by hand. Tested against a real postgres, and confirmed the assertions fail without the change: both new checks count rows Unscoped, so a tombstone fails them where a scoped count would pass on one. Closes #136 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #136. Option 1 from that issue: hard delete.
What was wrong
DeleteDocumentreported success and kept the data.ModelDocumentcarriesgorm.DeletedAt, so the delete wrote a tombstone and left the row — including thedatajsonb, which for inference sinks holds base64 image bytes.That was the worst of both halves:
UndeleteRPC, noUnscoped()read anywhere, and every read path filtersdeleted_at IS NULL— so the retained data could never be recovered by anyoneDataMetadata._deletedAtis populated in the DTO but unreachable by any code path — the same fact seen from the other sideWe paid soft delete's cost for none of its benefit.
What changes for callers: nothing
The API already answered
200and then404, exactly as a hard delete does. What changes is that the answer is now true. This is what FootprintAI/grandturks#936 asked for under "retention", and what #126 deliberately left open as the one irreversible decision in the area.Both document delete paths are
Unscoped:DeleteDeleteByCollectionDeleteCollection(force=true)— exactly when a caller expects the payloads to be goneThree deliberate non-changes
Collections stay soft-deleted. A collection row carries an id, a type and a summary rather than user payload, and hard-deleting one fires the
OnDelete:SET NULLconstraint onrestcol-collections-schema, orphaning schema rows. Separate change, separate consequences.The
gorm.DeletedAtfields stay on the models. Removing them would stop gorm filteringdeleted_at IS NULL, and any tombstone already in a database would become visible again — resurrecting documents that callers were told were deleted. Keeping the field costs nothing now that nothing sets it.The down migration is a no-op, with a comment saying why. Nothing can restore purged rows; a down file that pretends otherwise is worse than one that admits it.
Existing tombstones
migrations/0002_purge_soft_deleted_documentserases the rows already soft-deleted, since they are the pre-existing half of the same problem — callers were told those documents were deleted, and this makes it true.Note the gap it cannot close: environments running
--restcol_auto_migrate=truenever apply SQL migrations, so their old tombstones persist until purged by hand.Verification
Test-first, and I checked the assertions actually catch it — both new checks count rows
Unscoped, so a tombstone fails them where a scoped count would pass on one:With the change: every package green against a real postgres —
pkg/storage/*,pkg/app,integrationtest, and the rest — plusmake gen-checkclean (no generated code touched).Refs: FootprintAI/grandturks#936, #126