Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
d7aab5f
feat(core): expose media usage indexing progress
khoinguyenpham04 Aug 16, 2026
b22c22a
feat(admin): add guided media usage setup
khoinguyenpham04 Aug 16, 2026
5af9593
feat(admin): show media usage indexing progress
khoinguyenpham04 Aug 16, 2026
91eeaef
fix(core): start Node media usage reconciliation
khoinguyenpham04 Aug 17, 2026
851e082
feat(core): process one due media usage unit
khoinguyenpham04 Aug 17, 2026
a02ab2f
feat(core): continue media usage maintenance on Node
khoinguyenpham04 Aug 17, 2026
c62935a
feat(cloudflare): continue media usage work through queue wakeups
khoinguyenpham04 Aug 17, 2026
1fcbc86
fix(admin): format media usage progress fallback
khoinguyenpham04 Aug 18, 2026
64279d0
perf(core): enlarge media usage projection admission
khoinguyenpham04 Aug 18, 2026
b704b5a
perf(cloudflare): drain media usage work per queue delivery
khoinguyenpham04 Aug 18, 2026
826de42
docs(media-usage): document paid backfill requirements
khoinguyenpham04 Aug 18, 2026
e2d516b
feat(core): continue media usage activation in background
khoinguyenpham04 Aug 18, 2026
1d0ddd7
feat(admin): show automatic media usage setup progress
khoinguyenpham04 Aug 18, 2026
11361ec
docs(media-usage): document one-action setup
khoinguyenpham04 Aug 18, 2026
f46d11e
fix(core): keep media usage maintenance work-conserving
khoinguyenpham04 Aug 19, 2026
df80594
feat(media-usage): show finalizing setup progress
khoinguyenpham04 Aug 19, 2026
880b4c5
fix(core): wake media usage maintenance after activation
khoinguyenpham04 Aug 19, 2026
28dd347
perf(core): process media usage work in bulk
khoinguyenpham04 Aug 20, 2026
72517f2
perf(media-usage): run work-conserving bulk jobs
khoinguyenpham04 Aug 20, 2026
fae21f9
fix(media-usage): preserve bounded bulk recovery
khoinguyenpham04 Aug 20, 2026
2585ebb
perf(media-usage): sweep reconciliation sources in bulk
khoinguyenpham04 Aug 20, 2026
fa6fbf2
perf(media-usage): reduce maintenance round trips
khoinguyenpham04 Aug 20, 2026
2cdb2ad
fix(media-usage): bound D1 repair source lookups
khoinguyenpham04 Aug 20, 2026
30b6136
fix(cloudflare): keep media recovery independent
khoinguyenpham04 Aug 20, 2026
3b485c0
refactor(media-usage): remove superseded single-row work paths
khoinguyenpham04 Aug 20, 2026
ccecc1e
refactor(media-usage): remove scheduled recovery paths
khoinguyenpham04 Aug 20, 2026
e66bba6
test(media-usage): remove redundant coverage
khoinguyenpham04 Aug 20, 2026
879e637
docs(media-usage): correct continuation guidance
khoinguyenpham04 Aug 21, 2026
1b46107
Merge chore/media-usage-dev-seed-readiness into feature/media-usage-a…
khoinguyenpham04 Aug 21, 2026
22ed4ed
fix(admin): simplify Media Usage confirmation
khoinguyenpham04 Aug 21, 2026
c9083f4
docs(media-usage): clarify setup and release notes
khoinguyenpham04 Aug 21, 2026
ccbe88b
fix(admin): simplify Media Usage progress copy
khoinguyenpham04 Aug 24, 2026
31a5cba
docs(templates): clarify media usage scheduling
khoinguyenpham04 Aug 24, 2026
3001c0d
feat(media-usage): add browser-driven progress steps
khoinguyenpham04 Aug 24, 2026
578d081
feat(media-usage): drive setup from the visible page
khoinguyenpham04 Aug 24, 2026
ff90bce
refactor(media-usage): remove background continuation drivers
khoinguyenpham04 Aug 24, 2026
e37cdd2
fix(media-usage): keep browser progress within runtime bounds
khoinguyenpham04 Aug 24, 2026
5efcb21
fix(admin): format Media Usage progress counts
khoinguyenpham04 Aug 25, 2026
6603034
fix(admin): preserve ready Media Usage status
khoinguyenpham04 Aug 25, 2026
871067c
fix(admin): use media icon for Media Usage settings
khoinguyenpham04 Aug 25, 2026
18afe44
fix(admin): simplify Media Usage status
khoinguyenpham04 Aug 25, 2026
5dc3741
fix(admin): refine Media Usage ready badge
khoinguyenpham04 Aug 25, 2026
3178c18
fix(media-usage): keep visible recovery progressing
khoinguyenpham04 Aug 25, 2026
6ec1ce0
refactor(media-usage): remove superseded driver scaffolding
khoinguyenpham04 Aug 25, 2026
f63d63a
fix(admin): keep Media Usage progress catalog-free
khoinguyenpham04 Aug 25, 2026
263d780
docs: clarify media usage tracking setup
khoinguyenpham04 Aug 25, 2026
9e17069
refactor(admin): clarify media usage tracking copy
khoinguyenpham04 Aug 25, 2026
f0df0dc
fix(admin): confirm media usage writer drain
khoinguyenpham04 Aug 25, 2026
5f12006
fix(media-usage): isolate collection batch failures
khoinguyenpham04 Aug 25, 2026
6327aa7
fix(media-usage): bound bulk projection memory
khoinguyenpham04 Aug 25, 2026
0a6b6bc
feat(setup): enable media usage before fresh seeds
khoinguyenpham04 Aug 25, 2026
8bbf982
refactor(admin): clarify media usage setup steps
khoinguyenpham04 Aug 26, 2026
2428be4
fix(setup): gate pre-seed media tracking
khoinguyenpham04 Aug 26, 2026
98d3b6d
test(media-usage): allow heavy parity coverage
khoinguyenpham04 Aug 26, 2026
666be98
Merge branch 'chore/media-usage-dev-seed-readiness' into feature/medi…
khoinguyenpham04 Aug 26, 2026
2378332
fix(setup): enable media tracking for new sites
khoinguyenpham04 Aug 26, 2026
2d60a8a
test(media-usage): model existing-site activation
khoinguyenpham04 Aug 26, 2026
5c4c0aa
test(migrations): allow full executor runs in CI
khoinguyenpham04 Aug 26, 2026
11f593b
Merge updated media usage seed readiness
khoinguyenpham04 Aug 27, 2026
efcc74c
test(admin): keep media library tests in place
khoinguyenpham04 Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fast-media-usage-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Speeds up the initial scan when administrators turn on media usage tracking on Cloudflare Workers and Node.js.
6 changes: 6 additions & 0 deletions .changeset/media-usage-activation-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds a media usage tracking setting. Tracking is enabled during initial setup. Existing sites enable it from Settings, keep the page open while EmDash scans existing content, and can return later to continue from saved progress.
14 changes: 14 additions & 0 deletions .changeset/remove-media-usage-scheduled-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
---

Removes scheduled media usage recovery. Media usage tracking now advances only while an administrator keeps its settings page visible. This breaks Cloudflare deployments that configure `mediaUsageCron` and Node.js integrations that provide a custom `CronScheduler`.

#### What should I do?

On Cloudflare, remove the dedicated media usage Cron Trigger and the `mediaUsageCron` option. Keep the general Cron Trigger unchanged; no replacement trigger is required.

If you provide a custom Node.js scheduler, remove `setMediaUsageMaintenance()`. A custom `CronScheduler` now implements only `start()`, `stop()`, `reschedule()`, and `setSystemCleanup()`.

Keep **Settings → Media usage tracking** open until it shows **Ready**. If the page closes, return to continue from saved progress.
4 changes: 2 additions & 2 deletions demos/cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
"bucket_name": "emdash-media",
},
],
// Cron trigger drives the AI Search reindex queue flush.
// Cron triggers drive general maintenance.
"triggers": {
"crons": ["* * * * *", "*/2 * * * *"],
"crons": ["* * * * *"],
},
// Observability
"observability": {
Expand Down
24 changes: 8 additions & 16 deletions docs/src/content/docs/deployment/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ If the database is empty (no collections) and the setup wizard hasn't been compl

To change the schema or content model of a site that is already deployed, see [Evolving a Deployed Site](/deployment/schema-evolution/).

## Scheduled Publishing
## Scheduled tasks

On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from Worker Cron Triggers. New Cloudflare templates include both required schedules automatically. When updating an existing project, configure distinct general and Media Usage lanes:
Cloudflare runs scheduled publishing, plugin tasks, and general maintenance from one Cron Trigger.

Use the standard Worker entry point:

```ts title="src/worker.ts"
import handler, {
Expand All @@ -112,28 +114,18 @@ export default {
} satisfies ExportedHandler;
```

By default, `*/2 * * * *` runs Media Usage maintenance and every other expression runs general maintenance. Then add both Cron Triggers to `wrangler.jsonc`:
Configure one Cron Trigger for general maintenance in `wrangler.jsonc`:

```jsonc title="wrangler.jsonc"
{
"triggers": {
"crons": ["* * * * *", "*/2 * * * *"],
"crons": ["* * * * *"],
},
}
```

To use different schedules, set the corresponding `generalCron` or `mediaUsageCron` option in `createScheduledHandler()` and use the same expression in `wrangler.jsonc`.

<Aside type="caution">
Without the general trigger, scheduled publishing and plugin cron do not run. Without the dedicated Media Usage trigger, automatic historical reconciliation cannot progress. Local `astro dev` still uses the in-process scheduler.
</Aside>

### Enable automatic media usage indexing

Keep `mediaUsageCron` running while you enable automatic media usage indexing. Pause all application
and direct database writes, follow [Enable automatic media usage
indexing](/reference/rest-api/#enable-automatic-media-usage-indexing), then resume writes when the
endpoint returns `active`. Existing content is indexed in the background.
To use a different general maintenance schedule, set `generalCron` in `createScheduledHandler()` and
use the same expression in `wrangler.jsonc`.

## Deploy

Expand Down
15 changes: 3 additions & 12 deletions docs/src/content/docs/deployment/nodejs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,11 @@ export default defineConfig({

The server runs on `http://localhost:4321` by default. Migrations are applied on the first request. If the database is empty and setup hasn't been completed, your seed file (or the built-in default if you don't have one) is also applied on that first request.

## Scheduled Tasks
## Scheduled tasks

The built-in scheduler runs only while a Node process is running. It handles scheduled publishing,
plugin tasks, and background media indexing.
The built-in scheduler runs only while a Node.js process is running. It handles scheduled publishing, plugin tasks, and general maintenance.

Keep at least one Node process running continuously in production. If all processes stop or sleep,
scheduled tasks pause.

### Enable automatic media usage indexing

Keep at least one Node process running while you enable automatic media usage indexing. Pause all
application and direct database writes, follow [Enable automatic media usage
indexing](/reference/rest-api/#enable-automatic-media-usage-indexing), then resume writes when the
endpoint returns `active`. Existing content is indexed in the background.
Keep at least one Node.js process running continuously in production. Scheduled tasks pause when every process stops or sleeps.

## Production Storage

Expand Down
20 changes: 20 additions & 0 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ and files that are not assigned to a folder. Open a folder to see its files.

<img src={mediaLibraryImg.src} alt="EmDash media library showing image grid with upload button" />

## Used in

Open a file in EmDash's media library to see the content entries that reference it. While EmDash scans existing content, the list includes the references found so far and may be incomplete.

### Turn on media usage tracking

If media usage tracking is off, an administrator can turn it on:

1. Finish any content edits. If another application writes directly to the content database, pause it and wait for any writes in progress to finish.
2. Open **Settings → Media usage tracking**, select **Enable tracking**, then confirm.
3. When the page shows **Indexing existing content**, editing and other database writes can resume.
4. Keep the page open until it shows **Ready**. If you leave, return to continue from saved progress.

Once media usage tracking is on, it cannot be turned off.

<Aside type="caution">
The **Used in** list checks supported references in EmDash content. It does not inspect custom code
or external sites, so an empty list does not prove that a file is safe to delete.
</Aside>

## Uploading Files

### From the Media Library
Expand Down
112 changes: 76 additions & 36 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,12 @@ collections:
| `stale` | Indexed coverage is outdated |
| `unknown` | Stored coverage contains a state this version does not recognize |

Only `complete` supports a scoped complete-zero statement within the EmDash-managed fields
Only `complete` supports a scoped complete-zero statement within the supported fields
described below. Counts with any other status are indexed projections and may over-report or
under-report. Even complete results are advisory during concurrent writes; usage reads are not a
transactional lock and must not be used as a deletion guarantee.

### Get Media Usage Details
### Get media usage details

```http
GET /_emdash/api/media/:id/usage?limit=50&cursor=...
Expand Down Expand Up @@ -320,7 +320,7 @@ trashed entry. Sources are `columns` or `draft_overlay`; occurrences identify th
and path without exposing internal index metadata.

Media usage covers local media references in top-level image and file fields, repeater image
fields, and Portable Text image blocks managed by EmDash content collections. It does not scan
fields, and Portable Text image blocks stored in EmDash content collections. It does not scan
custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or
provider-only assets.

Expand Down Expand Up @@ -606,13 +606,14 @@ IDs or URLs, or change media usage records.
DELETE /_emdash/api/media/:id
```

### Enable automatic media usage indexing
### Enable media usage tracking

Automatic media usage indexing must be enabled once for each production site. Writes must be
paused while EmDash prepares each collection so that no changes are missed.
Sites with media usage tracking off must turn it on once. Pause direct database writes while EmDash prepares each collection. EmDash temporarily blocks its own content and schema writes during this step.

Both endpoints require `schema:manage`. Bearer tokens also require the `admin` scope.

For the admin procedure, see [Turn on media usage tracking](/guides/media-library/#turn-on-media-usage-tracking). The endpoints below provide the same procedure for API operators.

#### Check the current state

```http
Expand All @@ -621,57 +622,97 @@ GET /_emdash/api/admin/media-usage/activation

This request does not change anything. It returns one of these states:

- `expanded`: automatic indexing is not enabled.
- `expanded`: media usage tracking is off.
- `activating`: EmDash is preparing the site's collections.
- `active`: EmDash tracks changes to media references in content.

Status responses do not include internal lock data or raw database errors.

#### Prepare the next collection
#### Start activation

```http
POST /_emdash/api/admin/media-usage/activation
Content-Type: application/json
X-EmDash-Request: 1

{
"writersDrained": true,
"maintenanceReady": true
"writersDrained": true
}
```

Each request prepares one collection. Send one request at a time until the state becomes `active`.

Set both fields to `true`:
The request prepares at most one collection. After it succeeds, advance setup and historical indexing through the progress endpoint below.

- `writersDrained`: Application and direct database writes have stopped, and any writes already in
progress have finished.
- `maintenanceReady`: Scheduled background tasks are running through `mediaUsageCron` on Cloudflare
or the built-in scheduler on Node.js.
Set `writersDrained` to `true` after application and direct database writes have stopped and any writes already in progress have finished.

#### Enable indexing in production
#### Enable tracking with the API

1. Confirm that scheduled background tasks are running: `mediaUsageCron` on Cloudflare, or the
built-in scheduler on Node.js.
2. Stop all application and direct database writes. Wait for writes already in progress to finish.
3. Call `GET` to check the current state.
4. Call `POST` one request at a time until the state becomes `active`.
5. Resume writes.
6. Keep scheduled background tasks running while EmDash indexes existing content.
1. Stop all direct database writers. Wait for writes already in progress to finish. EmDash fences its own writes during setup.
2. Call the activation `GET` endpoint to check the current state.
3. Call the activation `POST` endpoint once with `writersDrained: true`.
4. Call the progress `POST` endpoint serially, following `nextRequestInMs`, until activation becomes `active`.
5. Resume direct database writes after activation becomes `active`.
6. Continue progress requests until historical indexing reports `ready` and `nextRequestInMs` is `null`.

If `POST` times out or returns `409` or `500`, call `GET` before sending another request. If
`lastErrorCode` is set, keep writes stopped, check the application logs, fix the problem, and try
again. Do not edit EmDash's internal database tables.
If `POST` times out or returns `409` or `500`, call `GET` before deciding what to do. If the state is
still `activating` without `lastErrorCode`, another request may own the current batch. If `lastErrorCode` is set, keep writes stopped, check the application logs, fix the
problem, and send one confirmed POST to retry. Do not edit EmDash's internal database tables.

<Aside type="caution">
After this process starts, it cannot be cancelled or reset. Test it in a staging environment and
follow your normal database backup policy before using it in production.
</Aside>

When the state is `active`, EmDash tracks changes to media references in content. Existing content
may still be indexing in the background.
When the state is `active`, EmDash tracks changes to media references in content. Existing content may still need progress requests before historical indexing is ready.

#### Check historical indexing progress

```http
GET /_emdash/api/admin/media-usage/progress
```

After activation is active, this returns `indexing`, `ready`, or `needs_attention` together with the
number of ready and total current content types. The endpoint does not inspect content rows or return
work-item details. It requires `schema:manage`; bearer tokens also require the `admin` scope.

#### Advance setup and historical indexing

```http
POST /_emdash/api/admin/media-usage/progress
X-EmDash-Request: 1
```

The request has no body. It runs one bounded maintenance step and returns the stored activation and progress state after that step.

```json
{
"success": true,
"data": {
"activation": {
"state": "active",
"collectionCursor": null,
"attemptCount": 2,
"drainConfirmedAt": "2026-08-24T12:00:00.000Z",
"lastAttemptedAt": "2026-08-24T12:00:01.000Z",
"lastErrorCode": null,
"leaseExpiresAt": null,
"activatedAt": "2026-08-24T12:00:01.000Z",
"updatedAt": "2026-08-24T12:00:02.000Z"
},
"progress": {
"status": "indexing",
"readyCollections": 1,
"totalCollections": 2
},
"nextRequestInMs": 0
}
}
```

`progress` is `null` until activation is active. `nextRequestInMs` is `0` for an immediate successor, `30000` for a delayed retry, or `null` when the server knows of no successor. Send only one progress request at a time and wait for the returned delay.

Closing the client does not discard completed work, but it stops future requests. To resume, read activation first, read progress when activation is active, then continue progress requests. After an ambiguous response, perform the same reads before retrying.

### List Media Usage Work
### List media usage work

```http
GET /_emdash/api/admin/media-usage/work?collection=posts&state=failed&limit=50&cursor=...
Expand Down Expand Up @@ -710,7 +751,7 @@ page's `nextCursor`. The endpoint does not calculate an exact backlog count.
Responses omit work versions, lease tokens, raw database errors, indexed content, media
references, and exact counts.

### Retry Media Usage Work
### Retry media usage work

```http
POST /_emdash/api/admin/media-usage/work/retry
Expand All @@ -735,9 +776,8 @@ replaces newer work or exposes its lease token.

The list returns only known durable work. Retry can create work for the supplied identity in an
active collection even when no work row exists, but it does not scan for historical gaps. Use
collection-scoped Media Usage repair after imports or direct database writes.
When scheduled maintenance is disabled, failed jobs remain visible and manually retryable, but no
automatic freshness deadline is promised.
collection-scoped media usage repair after imports or direct database writes.
Failed jobs remain visible and manually retryable. A `needs_attention` progress state stops automatic requests from the media usage tracking settings page until the underlying failure is resolved.

### Recover Collection Deletion

Expand All @@ -763,7 +803,7 @@ returns `409 WORK_LEASE_ACTIVE`; a concurrent state change returns `409 WORK_CHA
require `schema:manage`, and bearer tokens also require the `admin` scope. They recover internal
index cleanup only and never delete media assets.

### Repair Media Usage
### Repair media usage

```http
POST /_emdash/api/admin/media-usage/repair
Expand Down
1 change: 1 addition & 0 deletions e2e/tests/setup-wizard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ test.describe("Setup Wizard", () => {
test("shows validation error when title is empty", async ({ admin }) => {
await admin.goToSetup();

await admin.page.getByLabel("Site Title").fill("");
await admin.page.getByRole("button", { name: "Continue" }).click();

await expect(admin.page.locator("text=Site title is required")).toBeVisible();
Expand Down
Loading
Loading