Deleting a project or an org purges its files from storage - #166
Open
ssowonny wants to merge 2 commits into
Open
Deleting a project or an org purges its files from storage#166ssowonny wants to merge 2 commits into
ssowonny wants to merge 2 commits into
Conversation
Deleting a project used to retire only the registry row; its blobs and
journals stayed in the object store forever. Now DELETE /api/projects/{id}
also revokes the project's share links, evicts its cached volume, and
purges everything under its storage prefix through a new optional
remote.Deleter capability (file://, s3://, gs:// — never the https://
client, which must not delete remote objects). The purge is best effort:
registry first, so a storage error leaves orphaned objects (the old
status quo), never a half-deleted project.
Org deletion is new: DELETE /api/orgs/{org} (owners only) drops the org
row first — so an externally managed directory refuses with the usual
409 before anything irreversible happens — then cascades the same
project deletion over everything the org owned. Directory gained
Delete(orgID) so a managed provider has to answer it at compile time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deleting a project no longer removes its registry row: Delete(id, by) marks it with Deleted/DeletedBy and every live-project read path skips tombstones, so the name is immediately reusable, no content route answers for one, and who-deleted-what-when stays queryable — the row is the audit record. GET /api/projects?deleted=1 lists tombstones through the same permission resolver as the live list (hub admins additionally see tombstones of deleted orgs, the only audience left once the memberships are gone). Each delete also writes an audit: log line and a project_deleted / org_deleted PostHog event through the existing Server.capture — ids only, a no-op unless analytics is configured. The SQL backend grows deleted/deleted_by columns via the idempotent addColumns migration, guarded: a rolled-back deleted column would resurrect every deleted project as live with its storage already purged. The file backend stores the whole row and needs nothing. Co-Authored-By: Claude Fable 5 <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.
TL;DR
DELETE /api/orgs/{org}route (owners only): deletes the org, every project it owns, and all their storage; org deletion didn't exist at all before.deleted,deleted_by), listed viaGET /api/projects?deleted=1, plus anaudit:log line and a PostHogproject_deleted/org_deletedevent per delete.What changed
internal/remote— new optionalDeletercapability (Delete(ctx, key)), in thePutSignermold, implemented byfile://(removes the object, then prunes now-empty parent directories),s3://(DeleteObject), andgs://(missing object is not an error). Thehttps://client backend deliberately does not implement it: sync clients never delete remote objects — only the hub purges, and only its own root.internal/webapp/projects.go— deletion is a tombstone, not a row removal:ProjectgainedDeleted/DeletedBy,ProjectDB.Delete(id, by)marks the row, and every live-project read path (Get,List,GetOrCreatename match,Updateand its collision check) skips tombstones — so the name is immediately reusable and no content route ever answers for a deleted project. NewGetDeleted/ListDeletedquery the tombstones.ProjectRepo.Delete(the hard remove) is now uncalled; a future purge of old tombstones would be its caller.internal/webapp/admin.go—DELETE /api/projects/{id}routes through a newdeleteProjecthelper: tombstone first, then share-link revocation, cached-volume eviction, and a purge of everything under the project's storage prefix. AHasPrefixguard makes sure purgingp-abc/can never touch a siblingp-abcd/on backends whoseListanswers by string prefix. Each delete writes anaudit:log line and captures a PostHog event through the existingServer.capture(a no-op unless analytics is configured — ids only, no names or paths).DELETE /api/orgs/{org}— owners only. The org row goes first, which settles both authorization and whether this directory owns its orgs at all (ErrManagedElsewhere→ the usual 409, with nothing destroyed); then every project the org owned goes throughdeleteProject.OrgDB.Deleteretires the org's invites with the org.GET /api/projects?deleted=1— lists tombstones (who deleted what, when). Gated by the same permission resolver as the live list, so a tombstone is visible to exactly whoever could see the project alive; hub admins additionally see all tombstones, which is the only audience left once a whole org (and every membership the resolver could consult) is gone. The live listing's visibility is unchanged.Directoryinterface — gainedDelete(orgID). A managed provider now has to decide at compile time how to answer it (the read-only conformance fake answersErrManagedElsewhere, and the refuses-writes test covers the new route).SQL backend —
projectsgainsdeleted/deleted_bycolumns via the existing idempotentaddColumnsmigration, with a guard message (a rolled-backdeletedcolumn would resurrect every deleted project as live, storage already purged). The file backend needs nothing — it stores the whole row.Tests —
TestDeletePurgesStoragedrives both flows over a realfile://root: deleted project's directory is gone, siblings and other orgs untouched, tombstone visible to an org member via?deleted=1and invisible to an outsider, non-owner gets 403.TestProjectLifecyclecovers tombstone semantics (double delete refused, rename refused, name reusable with a fresh id). The MetaStore conformance suite verifies the tombstone survives a reload on every backend.Architecture changes
architecture/webapp-server.md:remote.Backendgained a second optional capability,Deleter(file/s3/gcs, not thehttps://client);DirectoryandOrgDBgainedDelete(orgID);Projectgained theDeleted/DeletedBytombstone fields andProjectDBthe tombstoningDeleteplusGetDeleted/ListDeleted; a newdeleteCascadeseam onServer(admin.go) connects the DELETE project/org routes toProjectDB.Delete,ShareDB.Revoke,Directory.Delete, the storage purge throughDeleter, and PostHog viaServer.capture.✅ added · ❌ removed (strikethrough) · unmarked = unchanged
flowchart TB Backend["<div style='text-align:left'><b>Backend</b> «interface»<br/>+Put +Get +List +Exists +Close</div>"] PutSigner["<div style='text-align:left'><b>PutSigner</b> «interface»<br/>+SignPut(ctx, key, size, ttl)</div>"] Deleter["<div style='text-align:left'><b>Deleter</b> «interface»<br/>+Delete(ctx, key)</div>"] DeleterNote["impls: file://, s3://, gs://<br/>NOT the https:// client —<br/>sync clients never delete remote objects.<br/>Deleting a missing key is not an error"] Directory["<div style='text-align:left'><b>Directory</b> «interface»<br/>+Role +Get +OrgsFor +ListInvites +ValidInvite +ManageURL<br/>+Create +Rename +AddMember +SetRole +RemoveMember<br/>+CreateInvite +RevokeInvite +Redeem<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Delete(orgID)</span></div>"] LocalDirectory["LocalDirectory"] OrgDB["<div style='text-align:left'><b>OrgDB</b><br/>-repo OrgRepo<br/>+EvictMember +SetSeniority<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Delete(orgID) — org row first, then its invites</span></div>"] Server["Server"] ProjectDB["<div style='text-align:left'><b>ProjectDB</b><br/>+Get +Update +Rename +List +SetPerm …<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Delete(id, by) — tombstones, never removes</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +GetDeleted +ListDeleted</span></div>"] Project["<div style='text-align:left'><b>Project</b><br/>+ID +Name +Org +Created +Perms …<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Deleted time, +DeletedBy email — the audit record,<br/>listed via GET /api/projects?deleted=1</span></div>"] ShareDB["ShareDB"] capture["<div style='text-align:left'><b>capture</b> «Server, analytics.go»<br/>one JSON POST to PostHog, no SDK</div>"] deleteCascade["<div style='text-align:left'><b>deleteCascade</b> «Server, admin.go»<br/>deleteProject: tombstone, shares, cached volume, storage purge<br/>handleOrgDelete: Dir.Delete first, then each project<br/>audit log line + PostHog event per delete</div>"] PutSigner -. "optional capability" .-> Backend Deleter -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ optional capability</span>" .-> Backend LocalDirectory -. implements .-> Directory LocalDirectory -- embeds --> OrgDB Server -- owns --> ProjectDB Server -- owns --> Directory Server -- owns --> ShareDB ProjectDB -.-> Project Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ DELETE project / org routes</span>" --> deleteCascade deleteCascade -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Delete(id, by)</span>" .-> ProjectDB deleteCascade -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Revoke</span>" .-> ShareDB deleteCascade -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Delete(orgID)</span>" .-> Directory deleteCascade -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ purge prefix</span>" .-> Deleter deleteCascade -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ project_deleted / org_deleted</span>" .-> capture Deleter -.- DeleterNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Deleter added class deleteCascade added class DeleterNote noteBox linkStyle 1 stroke:#22c55e,stroke-width:2px linkStyle 8 stroke:#22c55e,stroke-width:2px linkStyle 9 stroke:#22c55e,stroke-width:2px linkStyle 10 stroke:#22c55e,stroke-width:2px linkStyle 11 stroke:#22c55e,stroke-width:2px linkStyle 12 stroke:#22c55e,stroke-width:2px linkStyle 13 stroke:#22c55e,stroke-width:2px🤖 Generated with Claude Code