Skip to content

feat(documents): delete documents for real - #137

Merged
hsinatfootprintai merged 1 commit into
mainfrom
feat/136-hard-delete
Aug 13, 2026
Merged

feat(documents): delete documents for real#137
hsinatfootprintai merged 1 commit into
mainfrom
feat/136-hard-delete

Conversation

@hsinatfootprintai

Copy link
Copy Markdown
Contributor

Closes #136. Option 1 from that issue: hard delete.

What was wrong

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:

  • 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
  • meanwhile it stayed in the database indefinitely, with no TTL and no sweep
  • DataMetadata._deletedAt is populated in the DTO but unreachable by any code path — the same fact seen from the other side

We paid soft delete's cost for none of its benefit.

What changes for callers: nothing

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 under "retention", and what #126 deliberately left open as the one irreversible decision in the area.

Both document delete paths are Unscoped:

Delete a single document
DeleteByCollection the cascade behind DeleteCollection(force=true) — exactly when a caller expects the payloads to be gone

Three 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 NULL constraint on restcol-collections-schema, orphaning schema rows. Separate change, separate consequences.

The gorm.DeletedAt fields stay on the models. 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.

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_documents erases 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=true never 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 reverted:
--- FAIL: TestDocumentCURD_Delete                          the row must be deleted, not tombstoned
--- FAIL: TestDocumentCURD_DeleteByCollection_IsPermanent  the cascade must remove the rows, not tombstone them

With the change: every package green against a real postgres — pkg/storage/*, pkg/app, integrationtest, and the rest — plus make gen-check clean (no generated code touched).

Refs: FootprintAI/grandturks#936, #126

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>
@hsinatfootprintai
hsinatfootprintai merged commit fa8f799 into main Aug 13, 2026
2 checks passed
@hsinatfootprintai
hsinatfootprintai deleted the feat/136-hard-delete branch August 13, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DeleteDocument is a soft delete with no way to undelete and no way to purge — decide which half to keep

1 participant