Skip to content

feat(media): warn before permanently deleting a file the site still uses - #507

Open
mostafasadeghidev wants to merge 8 commits into
CoreBunch:mainfrom
mostafasadeghidev:feat/media-usage-warning
Open

feat(media): warn before permanently deleting a file the site still uses#507
mostafasadeghidev wants to merge 8 commits into
CoreBunch:mainfrom
mostafasadeghidev:feat/media-usage-warning

Conversation

@mostafasadeghidev

@mostafasadeghidev mostafasadeghidev commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #505, which records what depends on a media asset and builds the sentence. This is the part that puts it in front of the operator.

Important

Depends on #505 and #497. GitHub cannot base a cross-fork PR on another fork branch, so this targets main and the diff carries both of those PRs until they land. The new work is the last commit onlye0ea3a8 — everything before it belongs to #505 (the recording and the wording) and #497 (the confirmation this decorates). Merge those two first and this diff collapses to that one commit.

What it does

media_usage_refs and buildUsageWarning shipped in #505 without anything that shows them, so a permanent delete still said nothing about an avatar in the selection:

Delete "flowing.png" permanently?

This removes the file and every generated size from disk. Any page
still referencing it will render a broken image.

⚠ This file is still in use:
   profile picture — Ada Lovelace

[Cancel]  [Delete permanently]

Both surfaces that offer a permanent delete are wired — the viewer window and the grid's context menu — and each owns its own dialog, so each was wired separately. There is a test per surface. The bulk window is a third such surface; its permanent delete arrives with #500, so its wiring belongs in whichever of the two lands second.

Page content is computed, not recorded

Avatars could be recorded (#505) because a profile picture has one writer and an explicit set/unset. Page content has neither: it is written continuously by the collab relay, and taking an image off a page emits no event at all. A table fed by that fills with references to nodes that no longer exist, and the warning starts naming pages that are fine — and a warning that is sometimes wrong is worse than none, because it gets ignored.

So collectContentUsageRefs computes the answer from the draft site document when it is asked. It cannot drift, because there is nothing to keep in sync. The test that matters pins exactly that: remove the image, ask again, the reference is gone.

The walk already existed. mediaPrefetch has resolved image/media props on every publish for a long time — including background images and the definition tree of every Visual Component a page references. It splits into a page-scoped half this reuses; publish behaviour is unchanged. The module schema is already the registry of which props hold a media reference, so there is no second list to keep honest.

The cost lands on permanent delete and nowhere else — never a page load, never a trash. Walking every page tree is O(site) on an action that is about to be irreversible. If a site ever outgrows that, the fix is to cache it with the walk still the source of truth, so the cache can be checked against it.

It reads the draft, not the published artefacts. An image on an unpublished page is still in use — a check that only knew about live pages would let a delete quietly break the next publish.

One detail worth flagging in review: contentUsage.ts imports @modules/base itself. Without the registry populated the walk matches nothing and reports no usage — a warning that is silently always empty, which is the one failure mode worse than not having it.

Three decisions worth stating

The lookup is awaited before the dialog opens. A warning that arrives while the operator is already reading the dialog is a warning they will not see. It costs one round trip on a click that is about to be irreversible.

resolveUsageWarning is a plain async function, not a hook holding state. The state version cannot work here: every caller opens its dialog in the same closure that requested the lookup, where a setState is not yet visible — the warning would always have been one delete behind. Callers that keep a dialog open across renders store the returned value themselves.

It is also where "never rejects" lives, rather than in each lookupUsage. Every dialog opens from a floating void (async () => …)(), which drops a rejection silently — a failed lookup would have produced a delete button that does nothing at all, which is worse than one that deletes without the extra warning. One door, one guarantee, and the two hooks lose their duplicated try/catch.

The shared dialog stays generic

ConfirmDeleteRequest gains details?: ReactNode — caller-owned content below the description. Media passes the warning; nothing else passes anything. The alternative was teaching a dialog that also confirms page and layer deletes what a media asset is.

The warning now counts per asset but names per place: one file on three pages is one file lost — "3 of 11" would overstate the damage — but it is three pages to go and fix, and naming one sends the operator to repair a third of the breakage.

bun test src/__tests__/media/                    62 pass
bun test src/__tests__/server/mediaContentUsage   8 pass
tsc -b, bun run build, eslint                    clean

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

mostafasadeghidev and others added 7 commits September 5, 2026 13:12
Purging is the only media action with no undo. It removes the original
binary AND every generated size from the storage adapter — `handleMediaItem`
sweeps `existing.variants` alongside the original — and it fired on a single
unguarded click.

The button sits in the trash preview beside Restore, same row, same size,
one colour apart, so the two read as a pair of equally reversible choices.
The same applies to the trash view's right-click Delete.

Both paths now confirm, naming the file and saying what goes with it. Two
details worth flagging for review:

`alwaysConfirm` on the context-menu path. The `confirmBeforeDelete`
preference defaults off, and someone who turned it off was opting out of
confirming a TRASH — a reversible move — not a purge. Without the flag this
change would be a no-op for exactly the operators most likely to hit it.

A local `Dialog` rather than `useConfirmDelete` in the viewer window. That
hook falls back to running `commit()` immediately when no provider is
mounted (confirmDeleteHook.ts:53-56), and this window also renders from the
dashboard media widget, which mounts none. Routing it through the hook would
have left one surface unguarded while reading as covered on all five.

Also worth a maintainer's eye: `capabilityMeta.ts:87-90` tells operators that
hard-purge "also requires step-up", but the route only calls
`requireCapability(req, db, 'media.delete')` — no `requireStepUp`, unlike
`data/tables.ts:169`. Left alone here because a server-side gate is a
behaviour change that deserves its own PR, but the docs and the code
currently disagree.

Four tests pin the behaviour: no purge on first click, the dialog names the
asset, Cancel is inert, and the second click is what commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`media_usage_refs` has existed since the media schema landed and nothing has
ever written to it. So the library cannot tell a decorative upload from an
asset the product depends on, and one case of that loses data quietly.

A profile picture is stored as an ordinary `media_assets` row. Nothing marks
it: no `role` column, no filter hiding it, nothing in the grid to distinguish
it from any other image. Tidying the library sweeps it into the trash,
purging hard-deletes the row, and `users.avatar_media_id` goes to NULL
through its `on delete set null` foreign key. The profile falls back to a
Gravatar identicon and nothing anywhere says why. On one install it happened
three times before anyone connected the two.

This wires the table that was already designed for it. `setMediaUsageRef`
points a `(kind, id)` pair at an asset, deleting the previous row first so a
reference MOVES rather than accumulating — four avatar changes leave one row,
not four, or the fifth deletion would warn about pictures replaced months
ago and the warning becomes noise. Clearing an avatar clears the reference,
because the asset deliberately stays in the library but nothing depends on it
any more.

`listMediaUsageRefs` answers for a whole selection in one query, since the
question is always asked about a selection, and resolves a label an operator
can act on — "Ada Lovelace", not "u1". `POST /media/usage` exposes it: a POST
because a hundred ids is the wrong shape for a query string.

`ref_kind` namespaces the source, so favicons, page nodes and CMS cells can
register without touching consumers. This change only registers avatars and
only reads them back; the confirmation copy that consumes it is separate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s in use

`buildUsageWarning` turns the reference rows into the sentence a destructive
confirmation puts above its buttons, and `useMediaWorkspace.lookupUsage`
fetches them. Kept as a pure function with its own tests because the rules
are judgement, not mechanics, and each one came from imagining the actual
moment: eleven files selected, one of them a profile picture.

SEPARATE. "1 of 11 is still in use" lets the operator see the other ten are
safe. A blanket "some of these are in use" is the kind of warning people
learn to click past, because it never says which.

NAME IT. "profile picture — Ada Lovelace", not "has a reference". They have
to recognise what they are about to lose.

DO NOT BLOCK. Deleting an in-use asset is a legitimate thing to want —
replacing an avatar begins exactly that way. The confirmation informs; the
operator still decides.

Past three named items it summarises, or the dialog becomes a wall of text
nobody reads. An asset two things depend on counts once, because one file is
what disappears. And the lookup never throws: it decorates a confirmation
that must still appear if the request fails, so a network blip degrades to
the plain warning rather than blocking the delete.

The confirmation itself lives in a separate PR — this is the data and the
copy it will render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t one

Registering a usage reference on upload leaves every existing install in
the one state the feature was built for and silent about it: an avatar set
before this code shipped has no reference, so the first confirmation that
warns before a permanent delete would say nothing about it. The operator
would have to re-upload the same picture to be told it matters.

The backfill inserts a `user.avatar` reference for every user who has one.
Idempotent by construction — `not exists` on the same key
`setMediaUsageRef` writes, which is also what makes the backfilled row
indistinguishable from an app-written one, so a later avatar change MOVES
it instead of leaving the old picture warning forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…leting

The recording and the wording shipped without the thing that shows them, so
a permanent delete still said nothing about an avatar in the selection. This
connects the two, on both surfaces that offer that delete: the viewer window
and the grid's context menu. Each owns its own dialog, so each is wired
separately — and there is a test per surface, because one being left behind
is exactly the failure this shape invites.

The lookup is awaited BEFORE the dialog opens. A warning that arrives while
the operator is already reading the dialog is a warning they will not see.

Two things fell out of building it:

`resolveUsageWarning` is a plain async function, not a hook holding state.
The state version could not work: every caller opens its dialog in the same
closure that requested the lookup, where a `setState` is not yet visible, so
the warning would always have been one delete behind.

It is also where "never rejects" now lives, instead of in each `lookupUsage`.
Every dialog opens from a floating `void (async () => …)()`, which drops a
rejection silently — so a failed lookup would have produced a delete button
that did nothing at all, which is worse than one that deletes without the
extra warning. One door, one guarantee.

`ConfirmDeleteRequest` gains a generic `details` slot rather than a media
concept: the shared dialog also confirms page and layer deletes and should
not learn what a media asset is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spacing gate caught a `margin-top: 1px` optical alignment on the
warning icon. A 1px nudge is not worth an exception to the token scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 6, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Avatars could be RECORDED because a profile picture has one writer and an
explicit set/unset. Page content has neither: it is written continuously by
the collab relay, and taking an image off a page emits no event at all. A
table fed by that would slowly fill with references to nodes that no longer
exist, and the delete warning would start naming pages that are fine.

A warning that is sometimes wrong is worse than none — it gets ignored. So
content usage is computed when it is asked for, from the draft site
document, which cannot drift because there is nothing to keep in sync. The
test that matters pins exactly that: remove the image, and the reference is
gone on the next question.

The walk already existed. `mediaPrefetch` has resolved image/media props on
every publish for a long time, including background images and the
definition tree of every Visual Component a page references; it splits into
a page-scoped half this can reuse, with publish behaviour unchanged. The
module schema is already the registry of which props hold a reference, so
there is no second list to keep honest.

The cost lands on permanent delete and nowhere else — never a page load,
never a trash. Reading the DRAFT document rather than published artefacts is
deliberate: an image on an unpublished page is still in use, and a check
that only knew about live pages would let a delete quietly break the next
publish.

Two things worth stating:

`contentUsage.ts` imports `@modules/base` itself. Without the registry
populated the walk matches nothing and reports NO usage — a warning that is
silently always empty, which is the one failure mode worse than not having
it.

The warning now counts per asset but names per place. One file used on
three pages is one file lost — "3 of 11" would overstate the damage — but it
is three pages to go and fix, and naming one of them sends the operator to
repair a third of the breakage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mostafasadeghidev mostafasadeghidev changed the title feat(media): warn before permanently deleting a file something still depends on feat(media): warn before permanently deleting a file the site still uses Sep 6, 2026
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