Skip to content

Commit a27a5cc

Browse files
Add media usage read APIs (#2092)
* feat(core): add media usage read queries * feat(core): add coverage-aware media usage summaries * feat(core): add media Used in details endpoint * test(core): consolidate media usage read coverage * perf(core): remove redundant media usage join * feat(core): add media usage client methods * docs(core): document media usage read APIs * perf(core): skip empty media usage queries
1 parent 6c50eb8 commit a27a5cc

22 files changed

Lines changed: 2183 additions & 30 deletions

File tree

.changeset/media-usage-reads.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": minor
3+
---
4+
5+
Adds coverage-aware media usage counts and read-only Used in details to the REST API and core client.

docs/src/content/docs/reference/rest-api.mdx

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -178,22 +178,23 @@ DELETE /_emdash/api/content/:collection/:id
178178
### List Media
179179

180180
```http
181-
GET /_emdash/api/media
181+
GET /_emdash/api/media?includeUsage=1
182182
```
183183

184184
#### Parameters
185185

186-
| Parameter | Type | Description |
187-
| ---------- | -------- | ---------------------------- |
188-
| `cursor` | `string` | Pagination cursor |
189-
| `limit` | `number` | Items per page (default: 20) |
190-
| `mimeType` | `string` | Filter by MIME type prefix |
186+
| Parameter | Type | Description |
187+
| -------------- | -------- | -------------------------------------------------------------- |
188+
| `cursor` | `string` | Opaque pagination cursor |
189+
| `limit` | `number` | Items per page, from 1 to 100 (default: 50) |
190+
| `mimeType` | `string` | Filter by one or more comma-separated MIME types |
191+
| `q` | `string` | Case-insensitive filename search |
192+
| `includeUsage` | `1` | Include a coverage-aware `usage` summary on every returned item |
191193

192194
#### Response
193195

194196
```json
195197
{
196-
"success": true,
197198
"data": {
198199
"items": [
199200
{
@@ -203,8 +204,15 @@ GET /_emdash/api/media
203204
"size": 102400,
204205
"width": 1920,
205206
"height": 1080,
206-
"url": "https://cdn.example.com/photo.jpg",
207-
"createdAt": "2025-01-24T12:00:00Z"
207+
"url": "/_emdash/api/media/file/uploads/photo.jpg",
208+
"createdAt": "2025-01-24T12:00:00Z",
209+
"usage": {
210+
"count": 3,
211+
"coverage": {
212+
"scope": "all_content_collections",
213+
"status": "complete"
214+
}
215+
}
208216
}
209217
],
210218
"nextCursor": "eyJpZCI6..."
@@ -215,9 +223,99 @@ GET /_emdash/api/media
215223
### Get Media
216224

217225
```http
218-
GET /_emdash/api/media/:id
226+
GET /_emdash/api/media/:id?includeUsage=1
227+
```
228+
229+
`includeUsage` is optional on both list and get. Its only accepted value is `1`. When omitted,
230+
the `usage` property is omitted and the server does not run usage queries.
231+
232+
### Usage Summaries
233+
234+
`usage.count` is the number of distinct active EmDash content rows or locales whose selected
235+
current indexed source references the media item. Repeated references and multiple source
236+
variants for the same content entry count once. Trashed content does not count.
237+
238+
A numeric count can reveal draft-like content. It is returned only when a session user has
239+
`content:read_drafts`, or when an API token has `admin` scope and its associated user also has
240+
that permission. Other media readers receive `usage.count: null`; this is a successful redacted
241+
response, not an error.
242+
243+
Every requested summary includes aggregate coverage for all currently registered content
244+
collections:
245+
246+
| Status | Meaning |
247+
| ---------- | --------------------------------------------------------------------- |
248+
| `complete` | Every registered collection has current, completed usage coverage |
249+
| `never` | No registered collection has completed an initial usage repair |
250+
| `running` | A usage repair is currently running |
251+
| `partial` | Coverage is mixed or only part of the registered scope was indexed |
252+
| `failed` | Coverage failed across the registered scope |
253+
| `stale` | Indexed coverage is outdated |
254+
| `unknown` | Stored coverage contains a state this version does not recognize |
255+
256+
Only `complete` supports a scoped complete-zero statement within the EmDash-managed fields
257+
described below. Counts with any other status are indexed projections and may over-report or
258+
under-report. Even complete results are advisory during concurrent writes; usage reads are not a
259+
transactional lock and must not be used as a deletion guarantee.
260+
261+
### Get Media Usage Details
262+
263+
```http
264+
GET /_emdash/api/media/:id/usage?limit=50&cursor=...
265+
```
266+
267+
This endpoint requires `media:read` and `content:read_drafts`. Token-authenticated callers also
268+
require `admin` scope; token scope does not bypass the associated user's permissions.
269+
270+
`limit` controls content entry groups per page, from 1 to 100 (default: 50). Pagination never
271+
splits the sources or occurrences for one returned entry group.
272+
273+
```json
274+
{
275+
"data": {
276+
"items": [
277+
{
278+
"collection": "posts",
279+
"contentId": "01CONTENT...",
280+
"title": "Launch notes",
281+
"slug": "launch-notes",
282+
"locale": "en",
283+
"status": "published",
284+
"scheduledAt": null,
285+
"deletedAt": null,
286+
"sources": [
287+
{
288+
"variant": "columns",
289+
"occurrences": [
290+
{
291+
"fieldSlug": "hero",
292+
"fieldPath": "hero",
293+
"occurrenceIndex": 0,
294+
"referenceType": "image_field"
295+
}
296+
]
297+
}
298+
]
299+
}
300+
],
301+
"nextCursor": "eyJvcmRlclZhbHVlIjoicG9zdHMiLCJpZCI6IjAxLi4uIn0",
302+
"coverage": {
303+
"scope": "all_content_collections",
304+
"status": "complete"
305+
}
306+
}
307+
}
219308
```
220309

310+
Authorized details include active and trashed entries. A non-null `deletedAt` identifies a
311+
trashed entry. Sources are `columns` or `draft_overlay`; occurrences identify the supported field
312+
and path without exposing internal index metadata.
313+
314+
Media usage covers local media references in top-level image and file fields, repeater image
315+
fields, and Portable Text image blocks managed by EmDash content collections. It does not scan
316+
custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or
317+
provider-only assets.
318+
221319
### Create Media
222320

223321
```http

packages/core/src/api/authorize.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import type { Permission, RoleLevel } from "@emdash-cms/auth";
9-
import { hasPermission, canActOnOwn } from "@emdash-cms/auth";
9+
import { hasPermission, canActOnOwn, hasScope } from "@emdash-cms/auth";
1010

1111
import { apiError } from "./error.js";
1212

@@ -15,6 +15,15 @@ interface UserLike {
1515
role: RoleLevel;
1616
}
1717

18+
export function canReadMediaUsageCount(
19+
user: UserLike | null | undefined,
20+
tokenScopes: string[] | undefined,
21+
): boolean {
22+
return (
23+
hasPermission(user, "content:read_drafts") && (!tokenScopes || hasScope(tokenScopes, "admin"))
24+
);
25+
}
26+
1827
/**
1928
* Check if user has a permission. Returns a 401/403 Response if not, or null if authorized.
2029
*

packages/core/src/api/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const ErrorCode = {
8585
MEDIA_CREATE_ERROR: "MEDIA_CREATE_ERROR",
8686
MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR",
8787
MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR",
88+
MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR",
8889
MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR",
8990
NO_STORAGE: "NO_STORAGE",
9091
NO_FILE: "NO_FILE",

packages/core/src/api/handlers/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,19 @@ export {
6262
} from "./media.js";
6363

6464
export {
65+
aggregateMediaUsageCoverageStatus,
66+
handleMediaUsageDetails,
67+
handleMediaUsageSummaries,
6568
handleMediaUsageRepair,
6669
toMediaUsageRepairResponse,
70+
type MediaUsageCoverage,
71+
type MediaUsageCoverageStatus,
72+
type MediaUsageDetailsResponse,
73+
type MediaUsageEntryDetail,
74+
type MediaUsageOccurrenceDetail,
6775
type MediaUsageRepairResponse,
76+
type MediaUsageSourceDetail,
77+
type MediaUsageSummary,
6878
} from "./media-usage.js";
6979

7080
// Schema handlers

0 commit comments

Comments
 (0)