Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d1bc115
feat(media): add usage index tables
khoinguyenpham04 Jun 30, 2026
72b3253
feat(media): add usage extractor
khoinguyenpham04 Jun 30, 2026
8ef7d0e
feat(media): add usage repository
khoinguyenpham04 Jun 30, 2026
fa9c6b1
chore(media): add usage foundation changeset
khoinguyenpham04 Jul 1, 2026
a97e17e
ci: update query-count snapshots
emdashbot[bot] Jul 1, 2026
0b99ad1
fix(media): skip path-like local usage refs
khoinguyenpham04 Jul 1, 2026
f477b81
feat(media): add usage index status metadata
khoinguyenpham04 Jul 1, 2026
6b6c011
feat: enhance media usage index with new tracking and management feat…
khoinguyenpham04 Jul 1, 2026
0066d19
feat(media): Rename Repository Fixtures To Storage Variants
khoinguyenpham04 Jul 2, 2026
81158b2
feat(media): Add Source-Key Contract Tests
khoinguyenpham04 Jul 2, 2026
c66ca0f
feat(media): refine extractor to focus on image subfields and update …
khoinguyenpham04 Jul 2, 2026
f7590e1
feat(media): tightened the source-variant write/input type seam.
khoinguyenpham04 Jul 2, 2026
918ac41
feat(media): implement content media usage field discovery and associ…
khoinguyenpham04 Jul 2, 2026
5a662f5
feat(media): add content media usage snapshots loader and associated …
khoinguyenpham04 Jul 2, 2026
35c90cf
feat(media): enhance content media usage snapshots with draft revisio…
khoinguyenpham04 Jul 2, 2026
647361d
feat(media): update buildContentSource to conditionally include sourc…
khoinguyenpham04 Jul 2, 2026
7466f64
feat(media): Add Content Usage Refresh Service
khoinguyenpham04 Jul 2, 2026
0705860
feat(media): Wire Runtime Create, Update, Duplicate, Revision Restore
khoinguyenpham04 Jul 2, 2026
cf64e8a
Wire Publish, Unpublish, Schedule, Unschedule, Discard Draft
khoinguyenpham04 Jul 2, 2026
1ecf9f5
Wire Trash And Permanent Delete
khoinguyenpham04 Jul 2, 2026
24e5333
Refresh I18n Non-Translatable Siblings
khoinguyenpham04 Jul 2, 2026
7b6dbb9
Mark Bypass And Schema Paths Stale
khoinguyenpham04 Jul 3, 2026
18cdf12
Add Refresh Failure Path Coverage
khoinguyenpham04 Jul 3, 2026
770f8a2
Implement guarded replace and delete methods in MediaUsageRepository …
khoinguyenpham04 Jul 3, 2026
f30f1de
Fix media usage lint issues
khoinguyenpham04 Jul 3, 2026
02c220d
Merge remote-tracking branch 'origin/main' into feature/media-usage-i…
khoinguyenpham04 Jul 3, 2026
1446a65
Fix media usage display title extraction
khoinguyenpham04 Jul 3, 2026
fc980c3
update guarded replace result handling and add test for replacement s…
khoinguyenpham04 Jul 3, 2026
6f496fb
Implement retry logic for content media usage refresh to handle gener…
khoinguyenpham04 Jul 3, 2026
042c0e5
Harden media usage failure handling
khoinguyenpham04 Jul 3, 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/media-usage-index-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Adds the internal media usage index foundation for upcoming usage-aware media workflows. This creates the usage index schema during migrations but does not change Media Library behavior yet.
5 changes: 5 additions & 0 deletions .changeset/media-usage-index-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Adds internal media usage index hardening for future reference tracking.
Comment thread
khoinguyenpham04 marked this conversation as resolved.
10 changes: 5 additions & 5 deletions docs/src/content/docs/deployment/schema-evolution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ A destructive schema change (removing a field, restructuring a collection) is sa

```jsonc
{
"env": {
"preview": {
"d1_databases": [{ "binding": "DB", "database_name": "emdash-db-preview" }],
},
},
"env": {
"preview": {
"d1_databases": [{ "binding": "DB", "database_name": "emdash-db-preview" }],
},
},
}
```

Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,15 +1062,18 @@ export async function handleContentDelete(
db: Kysely<Database>,
collection: string,
id: string,
): Promise<ApiResult<{ deleted: true }>> {
): Promise<ApiResult<{ deleted: true; id: string }>> {
try {
const deleted = await withTransaction(db, async (trx) => {
const result = await withTransaction(db, async (trx) => {
const repo = new ContentRepository(trx);
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
return repo.delete(collection, resolvedId);
return {
id: resolvedId,
deleted: await repo.delete(collection, resolvedId),
};
});

if (!deleted) {
if (!result.deleted) {
return {
success: false,
error: {
Expand All @@ -1082,7 +1085,7 @@ export async function handleContentDelete(

return {
success: true,
data: { deleted: true },
data: { deleted: true, id: result.id },
};
} catch (error) {
console.error("Content delete error:", error);
Expand Down Expand Up @@ -1145,7 +1148,7 @@ export async function handleContentPermanentDelete(
db: Kysely<Database>,
collection: string,
id: string,
): Promise<ApiResult<{ deleted: true }>> {
): Promise<ApiResult<{ deleted: true; id: string }>> {
try {
const repo = new ContentRepository(db);
const resolvedId = (await resolveIdIncludingTrashed(repo, collection, id)) ?? id;
Expand Down Expand Up @@ -1182,7 +1185,7 @@ export async function handleContentPermanentDelete(

return {
success: true,
data: { deleted: true },
data: { deleted: true, id: resolvedId },
};
} catch (error) {
console.error("Content permanent delete error:", error);
Expand Down
220 changes: 122 additions & 98 deletions packages/core/src/astro/routes/api/import/wordpress/rewrite-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { wpRewriteUrlsBody } from "#api/schemas.js";
import { validateIdentifier } from "#db/validate.js";
import { normalizeMediaValue } from "#media/normalize.js";
import type { MediaProvider } from "#media/types.js";
import { markContentMediaUsageCollectionStaleSafely } from "#media/usage/content-refresh.js";
import type { EmDashHandlers } from "#types";

import {
Expand Down Expand Up @@ -77,11 +78,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
};

async function rewriteUrls(
export async function rewriteUrls(
db: NonNullable<EmDashHandlers["db"]>,
urlMap: Record<string, string>,
getProvider: (id: string) => MediaProvider | undefined,
collections?: string[],
markUsageCollectionStale: typeof markContentMediaUsageCollectionStaleSafely = markContentMediaUsageCollectionStaleSafely,
): Promise<RewriteUrlsResult> {
const { SchemaRegistry } = await import("#schema/registry.js");
const registry = new SchemaRegistry(db);
Expand All @@ -92,6 +94,18 @@ async function rewriteUrls(
urlsRewritten: 0,
errors: [],
};
const staleMarkedCollections = new Set<string>();
const staleMarkFailedCollections = new Set<string>();
const markCollectionStale = async (collectionSlug: string): Promise<void> => {
if (staleMarkedCollections.has(collectionSlug)) return;
const marked = await markUsageCollectionStale(db, collectionSlug, "CONTENT_USAGE_STALE");
if (marked) {
staleMarkedCollections.add(collectionSlug);
staleMarkFailedCollections.delete(collectionSlug);
} else {
staleMarkFailedCollections.add(collectionSlug);
}
};

// Build base URL map for flexible matching
const baseMap = buildBaseUrlMap(urlMap);
Expand All @@ -102,127 +116,137 @@ async function rewriteUrls(
? allCollections.filter((c) => collections.includes(c.slug))
: allCollections;

for (const collection of targetCollections) {
// Get fields that might contain URLs
const fields = await registry.listFields(collection.id);
const portableTextFields = fields.filter((f) => f.type === "portableText");
const stringFields = fields.filter((f) => ["text", "string"].includes(f.type));
// Image and file fields store URLs directly as TEXT
const mediaFields = fields.filter((f) => ["image", "file"].includes(f.type));

if (portableTextFields.length === 0 && stringFields.length === 0 && mediaFields.length === 0)
continue;

// Get table name
validateIdentifier(collection.slug, "collection slug");
const tableName = `ec_${collection.slug}`;

try {
// Query all rows
const rows = await sql<{ id: string; [key: string]: unknown }>`
try {
for (const collection of targetCollections) {
// Get fields that might contain URLs
const fields = await registry.listFields(collection.id);
const portableTextFields = fields.filter((f) => f.type === "portableText");
const stringFields = fields.filter((f) => ["text", "string"].includes(f.type));
// Image and file fields store URLs directly as TEXT
const mediaFields = fields.filter((f) => ["image", "file"].includes(f.type));

if (portableTextFields.length === 0 && stringFields.length === 0 && mediaFields.length === 0)
continue;

// Get table name
validateIdentifier(collection.slug, "collection slug");
const tableName = `ec_${collection.slug}`;

try {
// Query all rows
const rows = await sql<{ id: string; [key: string]: unknown }>`
SELECT * FROM ${sql.ref(tableName)}
WHERE deleted_at IS NULL
`.execute(db);

for (const row of rows.rows) {
let rowUpdated = false;
const updates: Record<string, unknown> = {};
let rowUrlsRewritten = 0;
for (const row of rows.rows) {
let rowUpdated = false;
const updates: Record<string, unknown> = {};
let rowUrlsRewritten = 0;

// Handle Portable Text fields - parse JSON and rewrite URLs in blocks
for (const field of portableTextFields) {
const value = row[field.slug];
if (!value || typeof value !== "string") continue;
// Handle Portable Text fields - parse JSON and rewrite URLs in blocks
for (const field of portableTextFields) {
const value = row[field.slug];
if (!value || typeof value !== "string") continue;

try {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns unknown; validated by Array.isArray below
const blocks = JSON.parse(value) as PortableTextBlock[];
if (!Array.isArray(blocks)) continue;
try {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns unknown; validated by Array.isArray below
const blocks = JSON.parse(value) as PortableTextBlock[];
if (!Array.isArray(blocks)) continue;

const rewriteResult = rewritePortableTextUrls(blocks, urlMap, baseMap);
const rewriteResult = rewritePortableTextUrls(blocks, urlMap, baseMap);

if (rewriteResult.changed) {
updates[field.slug] = JSON.stringify(blocks);
rowUpdated = true;
rowUrlsRewritten += rewriteResult.urlsRewritten;
if (rewriteResult.changed) {
updates[field.slug] = JSON.stringify(blocks);
rowUpdated = true;
rowUrlsRewritten += rewriteResult.urlsRewritten;
}
} catch {
// Not valid JSON, try string replacement as fallback
const stringResult = rewriteStringUrls(value, urlMap, baseMap);
if (stringResult.changed) {
updates[field.slug] = stringResult.newValue;
rowUpdated = true;
rowUrlsRewritten += stringResult.urlsRewritten;
}
}
} catch {
// Not valid JSON, try string replacement as fallback
}

// Handle string/text fields - simple string replacement
for (const field of stringFields) {
const value = row[field.slug];
if (!value || typeof value !== "string") continue;

const stringResult = rewriteStringUrls(value, urlMap, baseMap);
if (stringResult.changed) {
updates[field.slug] = stringResult.newValue;
rowUpdated = true;
rowUrlsRewritten += stringResult.urlsRewritten;
}
}
}

// Handle string/text fields - simple string replacement
for (const field of stringFields) {
const value = row[field.slug];
if (!value || typeof value !== "string") continue;

const stringResult = rewriteStringUrls(value, urlMap, baseMap);
if (stringResult.changed) {
updates[field.slug] = stringResult.newValue;
rowUpdated = true;
rowUrlsRewritten += stringResult.urlsRewritten;
}
}

// Handle image/file fields - normalize to MediaValue objects
for (const field of mediaFields) {
const value = row[field.slug];
if (!value || typeof value !== "string") continue;

// Values are stored as JSON MediaValue objects (e.g. featured_image from
// import normalizes to {"provider":"external","src":"<wp url>"}). Match on the
// inner `src`, falling back to the raw value for legacy bare-URL rows.
const newUrl = findMatchingUrl(extractMediaUrl(value), urlMap, baseMap);
if (newUrl) {
// Normalize into a proper MediaValue instead of storing a bare URL
try {
const normalized = await normalizeMediaValue(newUrl, getProvider);
updates[field.slug] = normalized ? JSON.stringify(normalized) : newUrl;
} catch {
updates[field.slug] = newUrl;
// Handle image/file fields - normalize to MediaValue objects
for (const field of mediaFields) {
const value = row[field.slug];
if (!value || typeof value !== "string") continue;

// Values are stored as JSON MediaValue objects (e.g. featured_image from
// import normalizes to {"provider":"external","src":"<wp url>"}). Match on the
// inner `src`, falling back to the raw value for legacy bare-URL rows.
const newUrl = findMatchingUrl(extractMediaUrl(value), urlMap, baseMap);
if (newUrl) {
// Normalize into a proper MediaValue instead of storing a bare URL
try {
const normalized = await normalizeMediaValue(newUrl, getProvider);
updates[field.slug] = normalized ? JSON.stringify(normalized) : newUrl;
} catch {
updates[field.slug] = newUrl;
}
rowUpdated = true;
rowUrlsRewritten++;
}
rowUpdated = true;
rowUrlsRewritten++;
}
}

if (rowUpdated) {
try {
// Build update query dynamically
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Kysely dynamic table requires type assertion
let query = db.updateTable(tableName as any).where("id", "=", row.id);

for (const [key, value] of Object.entries(updates)) {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Kysely dynamic column update requires type assertion
query = query.set({ [key]: value } as any);
if (rowUpdated) {
try {
// Build update query dynamically
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Kysely dynamic table requires type assertion
let query = db.updateTable(tableName as any).where("id", "=", row.id);

for (const [key, value] of Object.entries(updates)) {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Kysely dynamic column update requires type assertion
query = query.set({ [key]: value } as any);
}

await query.execute();

result.updated++;
result.urlsRewritten += rowUrlsRewritten;
result.byCollection[collection.slug] =
(result.byCollection[collection.slug] || 0) + 1;
await markCollectionStale(collection.slug);
} catch (updateError) {
result.errors.push({
collection: collection.slug,
id: row.id,
error: updateError instanceof Error ? updateError.message : "Update failed",
});
}

await query.execute();

result.updated++;
result.urlsRewritten += rowUrlsRewritten;
result.byCollection[collection.slug] = (result.byCollection[collection.slug] || 0) + 1;
} catch (updateError) {
result.errors.push({
collection: collection.slug,
id: row.id,
error: updateError instanceof Error ? updateError.message : "Update failed",
});
}
}
} catch (queryError) {
result.errors.push({
collection: collection.slug,
id: "*",
error: queryError instanceof Error ? queryError.message : "Query failed for collection",
});
}
} catch (queryError) {
result.errors.push({
collection: collection.slug,
id: "*",
error: queryError instanceof Error ? queryError.message : "Query failed for collection",
});
}
} finally {
for (const collectionSlug of staleMarkFailedCollections) {
if (staleMarkedCollections.has(collectionSlug)) continue;
const marked = await markUsageCollectionStale(db, collectionSlug, "CONTENT_USAGE_STALE");
if (marked) staleMarkedCollections.add(collectionSlug);
}
}

Expand Down
Loading
Loading