Skip to content

feat(media): record what depends on an asset, starting with avatars - #505

Open
mostafasadeghidev wants to merge 5 commits into
CoreBunch:mainfrom
mostafasadeghidev:feat/media-usage-refs
Open

mostafasadeghidev wants to merge 5 commits into
CoreBunch:mainfrom
mostafasadeghidev:feat/media-usage-refs

Conversation

@mostafasadeghidev

@mostafasadeghidev mostafasadeghidev commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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.

How it goes wrong

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.

So tidying the library sweeps it into the trash. Purging hard-deletes the row. 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 the install where this was found it had happened three times before anyone connected the two — each time read as "the avatar disappears after an update".

What this adds

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 — otherwise the fifth deletion would warn about pictures replaced months ago and the warning becomes noise. Clearing an avatar clears the reference: 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 the 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.

buildUsageWarning is the sentence a confirmation shows. Kept pure and separately tested because the rules are judgement, not mechanics:

  • Separate. "1 of 11 is still in use" lets the operator see the other ten are safe. A blanket warning is one people learn to click past.
  • Name it. "profile picture — Ada Lovelace", not "has a reference".
  • Do not block. Deleting an in-use asset is legitimate — replacing an avatar begins exactly that way. The confirmation informs; the operator decides.

Migration 032 backfills a reference for every avatar already set. Without it the feature is silent on exactly the installs it was built for: an existing avatar carries no reference, so the first confirmation that warns before a permanent delete would say nothing about it, and the operator would have to re-upload the same picture to be told it matters. Idempotent by construction — not exists on the same key setMediaUsageRef writes, which is also what makes a backfilled row indistinguishable from an app-written one, so a later avatar change moves it instead of leaving the old picture warning forever.

It takes 032, between two ids other open PRs claim: 031 (#495, 031_installed_plugins_source) and 033 (#335). Site branches took 027030 since this was opened. Ids are only ever sorted, so the gap costs nothing, and whichever of the two lands first neither has to be renumbered. This one could move safely anyway — it inserts only where not exists, so an installation that recorded it under another number runs it again and adds nothing. An ALTER like #335's cannot.

Scope

ref_kind namespaces the source, so favicons, page nodes and CMS cells can register later without touching consumers. This PR registers avatars, reads them back, and builds the copy. The confirmation that renders it is a separate PR, so this one is reviewable as the data layer it is.

bun test src/__tests__/server/mediaUsageRefs.test.ts  11 pass
bun test src/__tests__/media/usageWarning.test.ts      8 pass
tsc -b, eslint                                         clean

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

mostafasadeghidev and others added 2 commits September 5, 2026 21:37
`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>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 6, 2026
…s about it

Wires the dormant `media_usage_refs` table so the library can tell a
decorative upload from an asset the product depends on. Avatars were
ordinary library rows with no marker — purging one hard-deleted it and
`users.avatar_media_id` went quietly to NULL through its foreign key.

Fork stack: PR CoreBunch#505.
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 6, 2026
…at had no gate

The fork gate stopped at CoreBunch#359. Everything merged onto the stack since —
CoreBunch#497 through CoreBunch#501, and now CoreBunch#505 — was carried with nothing to catch it
being dropped by a merge resolution, which is the exact failure the gate
exists for.

Pinning them turned one up immediately: CoreBunch#498's guard around
`removePluginVersionAssets(…, fromVersion)` is gone, because CoreBunch#359 removed
that call outright — an upgrade now leaves the old version's assets on
disk so published pages keep resolving. What survives of CoreBunch#498 is the
rollback guard, so that is what the row pins.

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>
Site branches took 027–029 and the ISO timestamp rewrite took 030, so the
avatar backfill moves from 028 to 032. It skips 031, which CoreBunch#335 now claims.

Renumbering this one is safe in a way renumbering an ALTER is not: the
backfill inserts only where `not exists`, so an installation that recorded
it under 028 runs it again under 032 and inserts nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
…ide its new tone

Brings in CoreBunch#505's renumbered backfill (032) and upstream main with it.

`ConfirmDeleteContext` was the one conflict: site branches added a `tone` to
the confirm request so a merge can ask without looking destructive, and this
branch added `details` so a purge can say what is still in use. They are
independent props, so the provider passes both.

Site branches also made every draft read branch-scoped. This commit only
supplies `MAIN_SCOPE` where the signatures now demand one — reading the
other branches is a behaviour change, and it gets its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
CoreBunch#498 landing

Twenty upstream commits, including two of this stack's own PRs (CoreBunch#359, CoreBunch#498)
merged exactly as submitted.

Resolved hunk by hunk, and each file checked against the resolution its PR
branch reached independently:

  - content.ts, import.ts — every handler gains `MAIN_SCOPE`; every
    `@own-created` access check stays on the resolved table. Identical to
    CoreBunch#335's branch.
  - ConfirmDeleteContext — upstream's `tone` beside this stack's `details`.
    Identical to CoreBunch#507's branch.
  - publishSite.ts — only an import collided; the file now matches upstream
    byte for byte, as it should once CoreBunch#359 has landed.

Migrations are the one place the stack deliberately differs from the PRs.
`025_data_tables_created_by_plugin` keeps its id: two installations recorded
it, and the ALTER cannot run twice. Its comment now says what to do when
CoreBunch#335 lands as `031_…`. The avatar backfill takes CoreBunch#505's `032` — safe to
renumber because it inserts only where `not exists`.

`contentUsage.ts` gets `MAIN_SCOPE` to type-check; reading every branch
arrives with CoreBunch#507's merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
… 032 renumber

The migration files already match CoreBunch#505's (the stack took 032 in the
upstream merge), so this brings only the test's id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
CoreBunch#495 claims `031_installed_plugins_source` and CoreBunch#505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note still said CoreBunch#335 claims 031. It now claims 033, because CoreBunch#495
took 031 — the backfill stays at 032 between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
… and 033 (CoreBunch#335)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
… and 033 (CoreBunch#335)

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

This branch has not been deployed

No deployments
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