Skip to content

Deleting a project or an org purges its files from storage - #166

Open
ssowonny wants to merge 2 commits into
mainfrom
delete-purges-storage
Open

Deleting a project or an org purges its files from storage#166
ssowonny wants to merge 2 commits into
mainfrom
delete-purges-storage

Conversation

@ssowonny

@ssowonny ssowonny commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • Deleting a project now also deletes its files from the hub's storage (S3, GCS, or the plain directory) — before, the objects stayed behind forever.
  • New 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.
  • Deletes are queryable and audited: the project row stays as a tombstone (deleted, deleted_by), listed via GET /api/projects?deleted=1, plus an audit: log line and a PostHog project_deleted / org_deleted event per delete.
  • Project delete also revokes the project's share links and evicts its cached volume, so dead public links don't linger.
  • Known gap: no frontend surface for org deletion or the deleted-projects list yet — both are API-only; the existing project-delete UI picks the purge up automatically.

What changed

internal/remote — new optional Deleter capability (Delete(ctx, key)), in the PutSigner mold, implemented by file:// (removes the object, then prunes now-empty parent directories), s3:// (DeleteObject), and gs:// (missing object is not an error). The https:// 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: Project gained Deleted/DeletedBy, ProjectDB.Delete(id, by) marks the row, and every live-project read path (Get, List, GetOrCreate name match, Update and its collision check) skips tombstones — so the name is immediately reusable and no content route ever answers for a deleted project. New GetDeleted/ListDeleted query the tombstones. ProjectRepo.Delete (the hard remove) is now uncalled; a future purge of old tombstones would be its caller.

internal/webapp/admin.goDELETE /api/projects/{id} routes through a new deleteProject helper: tombstone first, then share-link revocation, cached-volume eviction, and a purge of everything under the project's storage prefix. A HasPrefix guard makes sure purging p-abc/ can never touch a sibling p-abcd/ on backends whose List answers by string prefix. Each delete writes an audit: log line and captures a PostHog event through the existing Server.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 through deleteProject. OrgDB.Delete retires 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.

Directory interface — gained Delete(orgID). A managed provider now has to decide at compile time how to answer it (the read-only conformance fake answers ErrManagedElsewhere, and the refuses-writes test covers the new route).

SQL backendprojects gains deleted/deleted_by columns via the existing idempotent addColumns migration, with a guard message (a rolled-back deleted column would resurrect every deleted project as live, storage already purged). The file backend needs nothing — it stores the whole row.

TestsTestDeletePurgesStorage drives both flows over a real file:// root: deleted project's directory is gone, siblings and other orgs untouched, tombstone visible to an org member via ?deleted=1 and invisible to an outsider, non-owner gets 403. TestProjectLifecycle covers 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.Backend gained a second optional capability, Deleter (file/s3/gcs, not the https:// client); Directory and OrgDB gained Delete(orgID); Project gained the Deleted/DeletedBy tombstone fields and ProjectDB the tombstoning Delete plus GetDeleted/ListDeleted; a new deleteCascade seam on Server (admin.go) connects the DELETE project/org routes to ProjectDB.Delete, ShareDB.Revoke, Directory.Delete, the storage purge through Deleter, and PostHog via Server.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
Loading

🤖 Generated with Claude Code

ssowonny and others added 2 commits August 14, 2026 11:47
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>
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.

1 participant