Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ba4646a
feat(core): add guarded collection deletion foundation
khoinguyenpham04 Aug 11, 2026
302ac9e
feat(core): safely detach activated collections
khoinguyenpham04 Aug 11, 2026
a576d10
feat(core): process bounded collection deletion cleanup
khoinguyenpham04 Aug 11, 2026
83dbf0c
feat(core): expose collection deletion recovery controls
khoinguyenpham04 Aug 11, 2026
2d36e13
fix(cloudflare): harden collection deletion guards
khoinguyenpham04 Aug 12, 2026
725ef86
fix(core): bound collection deletion completion checks
khoinguyenpham04 Aug 12, 2026
110f945
fix(core): use database time for deletion progress
khoinguyenpham04 Aug 12, 2026
1d81a96
chore(core): register collection deletion schemas
khoinguyenpham04 Aug 12, 2026
808d03e
chore(core): keep deletion lease guards transaction-scoped
khoinguyenpham04 Aug 12, 2026
26e4b26
fix(core): preserve collection deletion compatibility
khoinguyenpham04 Aug 12, 2026
90f01f8
fix(cloudflare): pin mutation reads to DO primary
khoinguyenpham04 Aug 12, 2026
e2a18b6
feat(media): add reconciliation coordinator state
khoinguyenpham04 Aug 12, 2026
136ece4
feat(media): add bounded reconciliation scan
khoinguyenpham04 Aug 12, 2026
e3d35a2
feat(media): finalize automatic reconciliation
khoinguyenpham04 Aug 12, 2026
da3101d
feat(media): schedule automatic reconciliation
khoinguyenpham04 Aug 12, 2026
5ce7f25
fix(media): preserve scheduled maintenance compatibility
khoinguyenpham04 Aug 12, 2026
da4e77e
Merge remote-tracking branch 'origin/main' into feature/media-usage-a…
khoinguyenpham04 Aug 12, 2026
8c8c0e0
docs: clarify automatic reconciliation changeset
khoinguyenpham04 Aug 12, 2026
36b58d2
feat: expose media usage activation status
khoinguyenpham04 Aug 12, 2026
61d0cbd
feat: advance media usage activation safely
khoinguyenpham04 Aug 12, 2026
a90b089
docs: add controlled activation runbook
khoinguyenpham04 Aug 12, 2026
f312ebe
docs: clarify media usage activation changeset
khoinguyenpham04 Aug 12, 2026
4366e83
docs: clarify media usage activation guidance
khoinguyenpham04 Aug 14, 2026
c862530
Merge origin/main into feature/media-usage-production-activation
khoinguyenpham04 Aug 14, 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/controlled-media-usage-activation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Adds a one-time, administrator-controlled process for enabling automatic media usage indexing in production.
7 changes: 7 additions & 0 deletions docs/src/content/docs/deployment/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ To use different schedules, set the corresponding `generalCron` or `mediaUsageCr
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.

## Deploy

Deploy to Cloudflare Workers:
Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/deployment/nodejs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ 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

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

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.

## Production Storage

For production, use S3-compatible storage instead of local filesystem:
Expand Down
65 changes: 65 additions & 0 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,71 @@ Content-Type: application/json
DELETE /_emdash/api/media/:id
```

### Enable automatic media usage indexing

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.

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

#### Check the current state

```http
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.
- `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

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

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

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

Set both fields to `true`:

- `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.

#### Enable indexing in production

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.

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.

<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.

### List Media Usage Work

```http
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export const ErrorCode = {
MEDIA_USAGE_WORK_RETRY_ERROR: "MEDIA_USAGE_WORK_RETRY_ERROR",
MEDIA_USAGE_COLLECTION_DELETION_LIST_ERROR: "MEDIA_USAGE_COLLECTION_DELETION_LIST_ERROR",
MEDIA_USAGE_COLLECTION_DELETION_RETRY_ERROR: "MEDIA_USAGE_COLLECTION_DELETION_RETRY_ERROR",
MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH: "MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH",
MEDIA_USAGE_ACTIVATION_READ_ERROR: "MEDIA_USAGE_ACTIVATION_READ_ERROR",
MEDIA_USAGE_ACTIVATION_BUSY: "MEDIA_USAGE_ACTIVATION_BUSY",
MEDIA_USAGE_ACTIVATION_CONFLICT: "MEDIA_USAGE_ACTIVATION_CONFLICT",
MEDIA_USAGE_ACTIVATION_ADVANCE_ERROR: "MEDIA_USAGE_ACTIVATION_ADVANCE_ERROR",
WORK_LEASE_ACTIVE: "WORK_LEASE_ACTIVE",
WORK_CHANGED: "WORK_CHANGED",
NO_STORAGE: "NO_STORAGE",
Expand Down Expand Up @@ -461,6 +466,9 @@ export function mapErrorStatus(code: string | undefined): number {
case ErrorCode.ENV_INCOMPATIBLE:
case ErrorCode.WORK_LEASE_ACTIVE:
case ErrorCode.WORK_CHANGED:
case ErrorCode.MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH:
case ErrorCode.MEDIA_USAGE_ACTIVATION_BUSY:
case ErrorCode.MEDIA_USAGE_ACTIVATION_CONFLICT:
return 409;

// 410 Gone
Expand Down
102 changes: 102 additions & 0 deletions packages/core/src/api/handlers/media-usage-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Kysely } from "kysely";

import type { Database } from "../../database/types.js";
import {
activateMediaUsageCapture,
getMediaUsageActivationStatus,
MediaUsageActivationVersionMismatchError,
} from "../../media/usage/activation.js";
import { ErrorCode } from "../errors.js";
import type {
MediaUsageActivationAdvanceRequest,
MediaUsageActivationAdvanceResponse,
MediaUsageActivationStatus,
} from "../schemas/media-usage.js";
import type { ApiResult } from "../types.js";

export async function handleMediaUsageActivationStatus(
db: Kysely<Database>,
): Promise<ApiResult<MediaUsageActivationStatus>> {
try {
return { success: true, data: await getMediaUsageActivationStatus(db) };
} catch (error) {
if (error instanceof MediaUsageActivationVersionMismatchError) {
return {
success: false,
error: {
code: ErrorCode.MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH,
message: "Media usage activation version is incompatible with this runtime",
},
};
}
console.error("[media-usage:activation] status read failed:", error);
return {
success: false,
error: {
code: ErrorCode.MEDIA_USAGE_ACTIVATION_READ_ERROR,
message: "Failed to read media usage activation status",
},
};
}
}

export async function handleMediaUsageActivationAdvance(
db: Kysely<Database>,
input: MediaUsageActivationAdvanceRequest,
): Promise<ApiResult<MediaUsageActivationAdvanceResponse>> {
try {
const result = await activateMediaUsageCapture(db, {
writersDrained: input.writersDrained,
});
if (result.outcome === "lease_active") {
return {
success: false,
error: {
code: ErrorCode.MEDIA_USAGE_ACTIVATION_BUSY,
message: "Media usage activation is already in progress",
details: { leaseExpiresAt: result.leaseExpiresAt },
},
};
}
if (result.outcome === "conflict") {
return {
success: false,
error: {
code: ErrorCode.MEDIA_USAGE_ACTIVATION_CONFLICT,
message: "Media usage activation ownership changed",
},
};
}

const activation = await getMediaUsageActivationStatus(db);
if (activation.state === "expanded") {
throw new Error("Media usage activation did not advance");
}
return {
success: true,
data: {
outcome: activation.state,
processedCollections: result.processedCollections,
activation,
},
};
} catch (error) {
if (error instanceof MediaUsageActivationVersionMismatchError) {
return {
success: false,
error: {
code: ErrorCode.MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH,
message: "Media usage activation version is incompatible with this runtime",
},
};
}
console.error("[media-usage:activation] advance failed:", error);
return {
success: false,
error: {
code: ErrorCode.MEDIA_USAGE_ACTIVATION_ADVANCE_ERROR,
message: "Failed to advance media usage activation",
},
};
}
}
50 changes: 50 additions & 0 deletions packages/core/src/api/openapi/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
mediaUsageCollectionDeletionListResponseSchema,
mediaUsageCollectionDeletionRetryBody,
mediaUsageCollectionDeletionRetryResponseSchema,
mediaUsageActivationStatusSchema,
mediaUsageActivationAdvanceBody,
mediaUsageActivationAdvanceResponseSchema,
mediaUsageActivationConflictSchema,
mediaUsageRepairBody,
mediaUsageRepairResponseSchema,
mediaUsageWorkListQuery,
Expand Down Expand Up @@ -822,6 +826,52 @@ function buildMediaPaths(maxUploadSize: number) {
},
},
},
"/_emdash/api/admin/media-usage/activation": {
get: {
operationId: "getMediaUsageActivation",
summary: "Get media usage activation status",
description:
"Returns the redacted status of controlled Media Usage capture activation. This operation is read-only and does not start or resume activation. Requires `schema:manage`; bearer tokens also require the `admin` scope.",
tags: ["Media"],
responses: {
"200": {
description: "Media usage activation status",
content: {
[JSON_CONTENT]: { schema: successEnvelope(mediaUsageActivationStatusSchema) },
},
},
...authErrors,
...standardErrors(409, 500),
},
},
post: {
operationId: "advanceMediaUsageActivation",
summary: "Advance media usage activation",
description:
"Starts, resumes, or retries exactly one bounded activation batch after the operator confirms that all writers are drained and automatic maintenance is ready. Requires `schema:manage`; bearer tokens also require the `admin` scope.",
tags: ["Media"],
requestBody: {
required: true,
content: { [JSON_CONTENT]: { schema: mediaUsageActivationAdvanceBody } },
},
responses: {
"200": {
description: "Current media usage activation progress",
content: {
[JSON_CONTENT]: {
schema: successEnvelope(mediaUsageActivationAdvanceResponseSchema),
},
},
},
...authErrors,
...standardErrors(400, 500),
"409": {
description: "Activation is busy, changed ownership, or is incompatible",
content: { [JSON_CONTENT]: { schema: mediaUsageActivationConflictSchema } },
},
},
},
},
"/_emdash/api/admin/media-usage/work/retry": {
post: {
operationId: "retryMediaUsageWork",
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/api/schemas/media-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,59 @@ export const mediaUsageWorkRetryConflictSchema = z.object({
]),
});

export const mediaUsageActivationStateSchema = z
.enum(["expanded", "activating", "active"])
.meta({ id: "MediaUsageActivationState" });

export const mediaUsageActivationStatusSchema = z
.object({
state: mediaUsageActivationStateSchema,
collectionCursor: z.string().nullable(),
attemptCount: z.number().int().min(0),
drainConfirmedAt: z.string().nullable(),
lastAttemptedAt: z.string().nullable(),
lastErrorCode: z.literal("MEDIA_USAGE_ACTIVATION_FAILED").nullable(),
leaseExpiresAt: z.string().nullable(),
activatedAt: z.string().nullable(),
updatedAt: z.string(),
})
.meta({ id: "MediaUsageActivationStatus" });

export const mediaUsageActivationAdvanceBody = z
.object({
writersDrained: z.literal(true),
maintenanceReady: z.literal(true),
})
.strict()
.meta({ id: "MediaUsageActivationAdvanceBody" });

export const mediaUsageActivationAdvanceResponseSchema = z
.object({
outcome: z.enum(["activating", "active"]),
processedCollections: z.number().int().min(0).max(1),
activation: mediaUsageActivationStatusSchema,
})
.meta({ id: "MediaUsageActivationAdvanceResponse" });

export const mediaUsageActivationConflictSchema = z.object({
success: z.literal(false),
error: z.discriminatedUnion("code", [
z.object({
code: z.literal("MEDIA_USAGE_ACTIVATION_BUSY"),
message: z.string(),
details: z.object({ leaseExpiresAt: z.string() }),
}),
z.object({
code: z.literal("MEDIA_USAGE_ACTIVATION_CONFLICT"),
message: z.string(),
}),
z.object({
code: z.literal("MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH"),
message: z.string(),
}),
]),
});

export const mediaUsageCollectionDeletionStateSchema = z
.enum(["pending", "retry", "leased", "failed"])
.meta({ id: "MediaUsageCollectionDeletionState" });
Expand Down Expand Up @@ -232,6 +285,11 @@ export type MediaUsageWorkItem = z.infer<typeof mediaUsageWorkItemSchema>;
export type MediaUsageWorkListResponse = z.infer<typeof mediaUsageWorkListResponseSchema>;
export type MediaUsageWorkRetryRequest = z.infer<typeof mediaUsageWorkRetryBody>;
export type MediaUsageWorkRetryResponse = z.infer<typeof mediaUsageWorkRetryResponseSchema>;
export type MediaUsageActivationStatus = z.infer<typeof mediaUsageActivationStatusSchema>;
export type MediaUsageActivationAdvanceRequest = z.infer<typeof mediaUsageActivationAdvanceBody>;
export type MediaUsageActivationAdvanceResponse = z.infer<
typeof mediaUsageActivationAdvanceResponseSchema
>;
export type MediaUsageCollectionDeletionListQuery = z.infer<
typeof mediaUsageCollectionDeletionListQuery
>;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@ export function injectCoreRoutes(
pattern: "/_emdash/api/admin/media-usage/work/retry",
entrypoint: resolveRoute("api/admin/media-usage/work/retry.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/media-usage/activation",
entrypoint: resolveRoute("api/admin/media-usage/activation.ts"),
});
injectRoute({
pattern: "/_emdash/api/admin/media-usage/collection-deletions",
entrypoint: resolveRoute("api/admin/media-usage/collection-deletions/index.ts"),
Expand Down
Loading
Loading