From 8a65187bd60961d94c4887e5f40d2b2606804df6 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Mon, 13 Jul 2026 16:36:37 +0100 Subject: [PATCH 1/8] feat(core): add media usage read queries --- .../migrations/052_media_usage_read_index.ts | 26 ++ .../core/src/database/migrations/runner.ts | 2 + .../src/database/repositories/media-usage.ts | 245 ++++++++++++ .../database/media-usage-migration.test.ts | 43 ++- .../database/media-usage-read-plan.test.ts | 201 ++++++++++ .../media-usage-read-repository.test.ts | 360 ++++++++++++++++++ .../integration/database/migrations.test.ts | 1 + 7 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/database/migrations/052_media_usage_read_index.ts create mode 100644 packages/core/tests/integration/database/media-usage-read-plan.test.ts create mode 100644 packages/core/tests/integration/database/media-usage-read-repository.test.ts diff --git a/packages/core/src/database/migrations/052_media_usage_read_index.ts b/packages/core/src/database/migrations/052_media_usage_read_index.ts new file mode 100644 index 0000000000..bc7c0e3e63 --- /dev/null +++ b/packages/core/src/database/migrations/052_media_usage_read_index.ts @@ -0,0 +1,26 @@ +import type { Kysely } from "kysely"; + +const OLD_INDEX = "idx__emdash_media_usage_media_id"; +const READ_INDEX = "idx__emdash_media_usage_media_source_generation"; + +export async function up(db: Kysely): Promise { + // D1 DDL is non-transactional: create the replacement before dropping the + // old index so an interrupted migration always leaves a media-leading index. + await db.schema + .createIndex(READ_INDEX) + .ifNotExists() + .on("_emdash_media_usage") + .columns(["media_id", "source_key", "generation"]) + .execute(); + await db.schema.dropIndex(OLD_INDEX).ifExists().execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .createIndex(OLD_INDEX) + .ifNotExists() + .on("_emdash_media_usage") + .column("media_id") + .execute(); + await db.schema.dropIndex(READ_INDEX).ifExists().execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index ca2020144e..6df97e6864 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -54,6 +54,7 @@ import * as m048 from "./048_restore_content_taxonomies_term_index.js"; import * as m049 from "./049_taxonomies_name_locale_index.js"; import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; +import * as m052 from "./052_media_usage_read_index.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -106,6 +107,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "049_taxonomies_name_locale_index": m049, "050_media_usage_index_status": m050, "051_content_taxonomies_denorm": m051, + "052_media_usage_read_index": m052, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 7a89350341..ab817af7cf 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -36,6 +36,23 @@ const OCCURRENCE_INSERT_BATCH_SIZE = Math.max( 1, Math.floor(SQL_BATCH_SIZE / OCCURRENCE_BIND_COLUMNS), ); +const CONTENT_SOURCE_ELIGIBILITY = sql`( + s.source_variant = 'draft_overlay' + OR ( + s.source_variant = 'columns' + AND ( + s.content_status = 'published' + OR NOT EXISTS ( + SELECT 1 + FROM _emdash_media_usage_sources AS overlay + WHERE overlay.source_type = 'content' + AND overlay.collection_slug = s.collection_slug + AND overlay.content_id = s.content_id + AND overlay.source_variant = 'draft_overlay' + ) + ) + ) +)`; export interface MediaUsageSourceInput { sourceKey: string; @@ -193,6 +210,24 @@ export interface FindMediaUsageOptions { cursor?: string; } +export interface MediaUsageCollectionIndexStatusScope { + collectionSlug: string; + status: string | null; + schemaVersion: number | null; +} + +export interface MediaUsageEntrySource { + source: MediaUsageSource; + occurrences: MediaUsageOccurrence[]; +} + +export interface MediaUsageEntryGroup { + collectionSlug: string; + contentId: string; + contentDeletedAt: string | null; + sources: MediaUsageEntrySource[]; +} + interface MediaUsageSourceRow { source_key: string; source_type: string; @@ -280,6 +315,11 @@ interface JoinedUsageRow { occurrence_created_at: string; } +interface GroupedUsageRow extends JoinedUsageRow { + entry_deleted_at: string | null; + has_more: number; +} + /** Persistence-only repository for the internal media usage projection tables. */ export class MediaUsageRepository { constructor(private db: Kysely) {} @@ -420,6 +460,165 @@ export class MediaUsageRepository { }; } + async findActiveEntryCountsByMediaIds(mediaIds: readonly string[]): Promise> { + const uniqueMediaIds = [...new Set(mediaIds)]; + const counts = new Map(uniqueMediaIds.map((mediaId) => [mediaId, 0])); + + for (const mediaIdBatch of chunks(uniqueMediaIds, SQL_BATCH_SIZE)) { + const visibleEntries = this.currentContentMediaUsageBaseQuery() + .select([ + "u.media_id as media_id", + "s.collection_slug as collection_slug", + "s.content_id as content_id", + ]) + .where("u.media_id", "in", mediaIdBatch) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("_emdash_media_usage_sources as deleted_source") + .select("deleted_source.source_key") + .where("deleted_source.source_type", "=", "content") + .whereRef("deleted_source.collection_slug", "=", "s.collection_slug") + .whereRef("deleted_source.content_id", "=", "s.content_id") + .where("deleted_source.source_variant", "in", ["columns", "draft_overlay"]) + .where("deleted_source.content_deleted_at", "is not", null), + ), + ), + ) + .distinct() + .as("visible_entries"); + + const rows = await this.db + .selectFrom(visibleEntries) + .select("media_id") + .select((eb) => eb.fn.countAll().as("usage_count")) + .groupBy("media_id") + .execute(); + + for (const row of rows) { + if (row.media_id !== null) counts.set(row.media_id, Number(row.usage_count)); + } + } + + return counts; + } + + async findCollectionIndexStatusScopes( + identity: Pick, + ): Promise { + const rows = await this.db + .selectFrom("_emdash_collections as collection") + .leftJoin("_emdash_media_usage_index_status as status", (join) => + join + .on("status.adapter_id", "=", identity.adapterId) + .on("status.scope_type", "=", identity.scopeType) + .onRef("status.scope_key", "=", "collection.slug"), + ) + .select([ + "collection.slug as collection_slug", + "status.status as status", + "status.schema_version as schema_version", + ]) + .orderBy("collection.slug", "asc") + .execute(); + + return rows.map((row) => ({ + collectionSlug: row.collection_slug, + status: row.status, + schemaVersion: row.schema_version === null ? null : Number(row.schema_version), + })); + } + + async findCurrentEntryUsagePageByMediaId( + mediaId: string, + options: FindMediaUsageOptions = {}, + ): Promise> { + const requestedLimit = Math.floor(options.limit ?? 50); + const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(1, requestedLimit), 100) : 50; + const cursor = options.cursor ? decodeCursor(options.cursor) : null; + let matchedGroups = this.currentContentMediaUsageBaseQuery() + .select(["s.collection_slug as collection_slug", "s.content_id as content_id"]) + .where("u.media_id", "=", mediaId) + .distinct(); + if (cursor) { + matchedGroups = matchedGroups.where((eb) => + eb.or([ + eb("s.collection_slug", ">", cursor.orderValue), + eb.and([ + eb("s.collection_slug", "=", cursor.orderValue), + eb("s.content_id", ">", cursor.id), + ]), + ]), + ); + } + matchedGroups = matchedGroups + .orderBy("s.collection_slug", "asc") + .orderBy("s.content_id", "asc") + .limit(limit + 1); + + const rows: GroupedUsageRow[] = await this.db + .with("matched_groups", () => matchedGroups) + .with("page_groups", (db) => + db + .selectFrom("matched_groups") + .selectAll() + .orderBy("collection_slug", "asc") + .orderBy("content_id", "asc") + .limit(limit), + ) + .selectFrom("page_groups as page") + .crossJoin("_emdash_media_usage_sources as s") + .crossJoin("_emdash_media_usage as u") + .innerJoin("_emdash_collections as collection", "collection.slug", "s.collection_slug") + .whereRef("page.collection_slug", "=", "s.collection_slug") + .whereRef("page.content_id", "=", "s.content_id") + .whereRef("s.source_key", "=", "u.source_key") + .whereRef("s.current_generation", "=", "u.generation") + .select(currentUsageSelect) + .select( + sql`( + SELECT deleted_source.content_deleted_at + FROM _emdash_media_usage_sources AS deleted_source + WHERE deleted_source.source_type = 'content' + AND deleted_source.collection_slug = s.collection_slug + AND deleted_source.content_id = s.content_id + AND deleted_source.source_variant IN ('columns', 'draft_overlay') + AND deleted_source.content_deleted_at IS NOT NULL + ORDER BY deleted_source.content_deleted_at DESC + LIMIT 1 + )`.as("entry_deleted_at"), + ) + .select( + sql`CASE + WHEN (SELECT COUNT(*) FROM matched_groups) > ${limit} THEN 1 + ELSE 0 + END`.as("has_more"), + ) + .where("u.media_id", "=", mediaId) + .where("s.source_type", "=", "content") + .where("s.collection_slug", "is not", null) + .where("s.content_id", "is not", null) + .where("s.source_variant", "in", ["columns", "draft_overlay"]) + .where(CONTENT_SOURCE_ELIGIBILITY) + .orderBy("s.collection_slug", "asc") + .orderBy("s.content_id", "asc") + .orderBy("s.source_variant", "asc") + .orderBy("s.source_key", "asc") + .orderBy("u.field_path", "asc") + .orderBy("u.occurrence_index", "asc") + .orderBy("u.id", "asc") + .execute(); + + const items = groupUsageRows(rows); + const result: FindManyResult = { items }; + if (Number(rows[0]?.has_more ?? 0) === 1 && items.length > 0) { + const last = items.at(-1)!; + result.nextCursor = encodeCursor(last.collectionSlug, last.contentId); + } + return result; + } + async findCurrentUsageByMediaId(mediaId: string): Promise { const rows = await this.db .selectFrom("_emdash_media_usage_sources as s") @@ -889,6 +1088,20 @@ export class MediaUsageRepository { .select(currentUsageSelect); } + private currentContentMediaUsageBaseQuery() { + return this.db + .selectFrom("_emdash_media_usage as u") + .crossJoin("_emdash_media_usage_sources as s") + .innerJoin("_emdash_collections as collection", "collection.slug", "s.collection_slug") + .whereRef("s.source_key", "=", "u.source_key") + .whereRef("s.current_generation", "=", "u.generation") + .where("s.source_type", "=", "content") + .where("s.collection_slug", "is not", null) + .where("s.content_id", "is not", null) + .where("s.source_variant", "in", ["columns", "draft_overlay"]) + .where(CONTENT_SOURCE_ELIGIBILITY); + } + private async deleteSourceKeys(sourceKeys: readonly string[]): Promise { const uniqueSourceKeys = [...new Set(sourceKeys)]; if (uniqueSourceKeys.length === 0) return 0; @@ -1229,6 +1442,38 @@ const currentUsageSelect = [ "u.created_at as occurrence_created_at", ] as const; +function groupUsageRows(rows: readonly GroupedUsageRow[]): MediaUsageEntryGroup[] { + const groups: MediaUsageEntryGroup[] = []; + + for (const row of rows) { + if (row.collection_slug === null || row.content_id === null) continue; + const record = rowToUsageRecord(row); + let group = groups.at(-1); + if ( + !group || + group.collectionSlug !== row.collection_slug || + group.contentId !== row.content_id + ) { + group = { + collectionSlug: row.collection_slug, + contentId: row.content_id, + contentDeletedAt: row.entry_deleted_at, + sources: [], + }; + groups.push(group); + } + + let source = group.sources.at(-1); + if (!source || source.source.sourceKey !== record.source.sourceKey) { + source = { source: record.source, occurrences: [] }; + group.sources.push(source); + } + source.occurrences.push(record.occurrence); + } + + return groups; +} + function rowToSource(row: MediaUsageSourceRow): MediaUsageSource { return { sourceKey: row.source_key, diff --git a/packages/core/tests/integration/database/media-usage-migration.test.ts b/packages/core/tests/integration/database/media-usage-migration.test.ts index 03f9200024..9bada5dfb8 100644 --- a/packages/core/tests/integration/database/media-usage-migration.test.ts +++ b/packages/core/tests/integration/database/media-usage-migration.test.ts @@ -14,7 +14,7 @@ const EXPECTED_INDEXES = [ "idx__emdash_media_usage_sources_locale", "idx__emdash_media_usage_sources_deleted", "idx__emdash_media_usage_sources_translation_group", - "idx__emdash_media_usage_media_id", + "idx__emdash_media_usage_media_source_generation", "idx__emdash_media_usage_provider_asset", "idx__emdash_media_usage_source_generation", "idx__emdash_media_usage_unique_occurrence", @@ -110,6 +110,47 @@ describeEachDialect("media usage index migration", (dialect) => { } }); + it("replaces the media lookup index in retry-safe D1 statement order", async () => { + const migration = + await import("../../../src/database/migrations/052_media_usage_read_index.js"); + + await migration.down(ctx.db); + let indexNames = await listIndexNames(ctx); + expect(indexNames.has("idx__emdash_media_usage_media_id")).toBe(true); + expect(indexNames.has("idx__emdash_media_usage_media_source_generation")).toBe(false); + + // Simulate interruption after the first up statement, before the old index drops. + await ctx.db.schema + .createIndex("idx__emdash_media_usage_media_source_generation") + .ifNotExists() + .on("_emdash_media_usage") + .columns(["media_id", "source_key", "generation"]) + .execute(); + await migration.up(ctx.db); + await migration.up(ctx.db); + + indexNames = await listIndexNames(ctx); + expect(indexNames.has("idx__emdash_media_usage_media_id")).toBe(false); + expect(indexNames.has("idx__emdash_media_usage_media_source_generation")).toBe(true); + + // Simulate interruption after the first down statement, before the new index drops. + await ctx.db.schema + .createIndex("idx__emdash_media_usage_media_id") + .ifNotExists() + .on("_emdash_media_usage") + .column("media_id") + .execute(); + indexNames = await listIndexNames(ctx); + expect(indexNames.has("idx__emdash_media_usage_media_id")).toBe(true); + expect(indexNames.has("idx__emdash_media_usage_media_source_generation")).toBe(true); + await migration.down(ctx.db); + await migration.down(ctx.db); + + indexNames = await listIndexNames(ctx); + expect(indexNames.has("idx__emdash_media_usage_media_id")).toBe(true); + expect(indexNames.has("idx__emdash_media_usage_media_source_generation")).toBe(false); + }); + it("down() drops tables and up() recreates them", async () => { const migration = await import("../../../src/database/migrations/046_media_usage_index.js"); diff --git a/packages/core/tests/integration/database/media-usage-read-plan.test.ts b/packages/core/tests/integration/database/media-usage-read-plan.test.ts new file mode 100644 index 0000000000..b0a82951c9 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-read-plan.test.ts @@ -0,0 +1,201 @@ +/** + * Query-plan and statement-count coverage for media usage reads. + * + * SQLite runs without ANALYZE/sqlite_stat1 here, matching D1's stats-blind + * planner. Dialect result parity is covered by media-usage-read-repository. + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js"; +import { SQL_BATCH_SIZE } from "../../../src/utils/chunks.js"; + +interface CapturedQuery { + sql: string; + parameters: readonly unknown[]; +} + +let sqlite: Database.Database; +let db: Kysely; +let repo: MediaUsageRepository; +let captured: CapturedQuery[]; + +beforeEach(async () => { + captured = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") { + captured.push({ sql: event.query.sql, parameters: event.query.parameters }); + } + }, + }); + await runMigrations(db); + await db + .insertInto("_emdash_collections") + .values({ id: "collection-posts", slug: "posts", label: "Posts", has_seo: 0 }) + .execute(); + repo = new MediaUsageRepository(db); + await repo.replaceSource( + { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns", + }), + sourceType: "content", + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns", + locale: "en", + contentStatus: "published", + }, + [ + { + fieldSlug: "hero", + fieldPath: "hero", + referenceType: "image_field", + mediaId: "media-shared", + provider: "local", + providerAssetId: "media-shared", + }, + ], + ); + captured = []; +}); + +afterEach(async () => { + await db.destroy(); +}); + +it("seeks batched counts through the media/source/generation index", async () => { + const mediaIds = [ + "media-shared", + ...Array.from({ length: SQL_BATCH_SIZE }, (_, index) => `media-${index}`), + ]; + + await repo.findActiveEntryCountsByMediaIds(mediaIds); + + const queries = captured.filter((query) => query.sql.includes("visible_entries")); + expect(queries).toHaveLength(2); + for (const query of queries) { + const plan = explain(query); + expect(query.parameters.length).toBeLessThanOrEqual(100); + expect(firstSourceOrUsageAccess(plan)).toMatch( + /SEARCH u USING (?:COVERING )?INDEX idx__emdash_media_usage_media_source_generation/, + ); + expect(plan).toContain("idx__emdash_media_usage_media_source_generation"); + expect(plan).toContain("idx__emdash_media_usage_sources_content"); + expect(plan).not.toContain("SCAN u"); + } +}); + +it("loads coverage and one grouped page in one statement each", async () => { + await repo.findCollectionIndexStatusScopes({ + adapterId: "content-media", + scopeType: "collection", + }); + await repo.findCurrentEntryUsagePageByMediaId("media-shared", { limit: 1 }); + + const coverageQueries = captured.filter( + (query) => + query.sql.includes("_emdash_media_usage_index_status") && query.sql.includes("left join"), + ); + const groupedQueries = captured.filter((query) => query.sql.includes("matched_groups")); + expect(coverageQueries).toHaveLength(1); + expect(groupedQueries).toHaveLength(1); + + const plan = explain(groupedQueries[0]!); + expect(firstSourceOrUsageAccess(plan)).toMatch( + /SEARCH u USING (?:COVERING )?INDEX idx__emdash_media_usage_media_source_generation/, + ); + expectPageBoundedHydration(plan); + expect(plan).toContain("idx__emdash_media_usage_media_source_generation"); + expect(plan).toContain("idx__emdash_media_usage_sources_content"); + expect(plan).not.toContain("SCAN u"); +}); + +it("keeps a high-cardinality grouped read to one indexed statement", async () => { + for (let index = 2; index <= 200; index++) { + const contentId = `entry-${String(index).padStart(3, "0")}`; + await repo.replaceSource( + { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug: "posts", + contentId, + sourceVariant: "columns", + }), + sourceType: "content", + collectionSlug: "posts", + contentId, + sourceVariant: "columns", + locale: "en", + contentStatus: "published", + }, + [ + { + fieldSlug: "hero", + fieldPath: "hero", + referenceType: "image_field", + mediaId: "media-shared", + provider: "local", + providerAssetId: "media-shared", + }, + ], + ); + } + captured = []; + + const page = await repo.findCurrentEntryUsagePageByMediaId("media-shared", { limit: 2 }); + + expect(page.items.map((item) => item.contentId)).toEqual(["entry-002", "entry-003"]); + expect(page.nextCursor).toEqual(expect.any(String)); + const queries = captured.filter((query) => query.sql.includes("matched_groups")); + expect(queries).toHaveLength(1); + const plan = explain(queries[0]!); + expect(firstSourceOrUsageAccess(plan)).toMatch( + /SEARCH u USING (?:COVERING )?INDEX idx__emdash_media_usage_media_source_generation/, + ); + expectPageBoundedHydration(plan); + expect(plan).toContain("idx__emdash_media_usage_media_source_generation"); + expect(plan).toContain("idx__emdash_media_usage_sources_content"); + expect(plan).not.toContain("SCAN u"); +}); + +/** better-sqlite3 only binds primitives; coerce values captured from Kysely. */ +function bindable(parameter: unknown): unknown { + if (typeof parameter === "boolean") return parameter ? 1 : 0; + if (parameter instanceof Date) return parameter.toISOString(); + if (parameter === undefined) return null; + return parameter; +} + +function explain(query: CapturedQuery): string { + const rows = sqlite + .prepare(`EXPLAIN QUERY PLAN ${query.sql}`) + .all(...query.parameters.map(bindable)) as { detail: string }[]; + return rows.map((row) => row.detail).join("\n"); +} + +function firstSourceOrUsageAccess(plan: string): string | undefined { + return plan.split("\n").find((detail) => /\b(?:SCAN|SEARCH) (?:s|u)\b/.test(detail)); +} + +function expectPageBoundedHydration(plan: string): void { + const mediaAccesses = plan + .split("\n") + .filter( + (detail) => + detail.includes("SEARCH u USING") && + detail.includes("idx__emdash_media_usage_media_source_generation"), + ); + expect(mediaAccesses).toHaveLength(2); + expect(mediaAccesses[0]).toMatch(/\(media_id=\?\)$/); + expect(mediaAccesses[1]).toMatch(/\(media_id=\? AND source_key=\? AND generation=\?\)$/); +} diff --git a/packages/core/tests/integration/database/media-usage-read-repository.test.ts b/packages/core/tests/integration/database/media-usage-read-repository.test.ts new file mode 100644 index 0000000000..59c975de9e --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-read-repository.test.ts @@ -0,0 +1,360 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { InvalidCursorError } from "../../../src/database/repositories/types.js"; +import { + buildContentMediaUsageSourceKey, + type MediaUsageContentSourceVariant, +} from "../../../src/media/usage/source-key.js"; +import { SQL_BATCH_SIZE } from "../../../src/utils/chunks.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("MediaUsageRepository reads", (dialect) => { + let ctx: DialectTestContext; + let repo: MediaUsageRepository; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + repo = new MediaUsageRepository(ctx.db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("counts distinct active entries across occurrences, locales, and collections", async () => { + await registerCollection(ctx, "pages"); + await registerCollection(ctx, "posts"); + + await repo.replaceSource(contentSource("entry-en", "columns"), [ + occurrence("hero", "media-shared"), + occurrence("body", "media-shared"), + ]); + await repo.replaceSource( + contentSource("entry-fr", "columns", { + locale: "fr", + translationGroup: "translations-1", + }), + [occurrence("hero", "media-shared")], + ); + await repo.replaceSource(contentSource("entry-en", "columns", { collectionSlug: "pages" }), [ + occurrence("hero", "media-shared"), + ]); + + const counts = await repo.findActiveEntryCountsByMediaIds(["media-shared", "media-none"]); + + expect(counts).toEqual( + new Map([ + ["media-shared", 3], + ["media-none", 0], + ]), + ); + }); + + it("selects published sources and the non-published visible working copy", async () => { + await registerCollection(ctx, "posts"); + + await repo.replaceSource( + contentSource("published", "columns", { contentStatus: "published" }), + [occurrence("hero", "media-published")], + ); + await repo.replaceSource( + contentSource("published", "draft_overlay", { contentStatus: "published" }), + [occurrence("hero", "media-pending")], + ); + + await repo.replaceSource(contentSource("draft", "columns", { contentStatus: "draft" }), [ + occurrence("hero", "media-superseded"), + ]); + await repo.replaceSource(contentSource("draft", "draft_overlay", { contentStatus: "draft" }), [ + occurrence("hero", "media-visible"), + ]); + + await repo.replaceSource(contentSource("cleared", "columns", { contentStatus: "draft" }), [ + occurrence("hero", "media-cleared"), + ]); + await repo.replaceSource( + contentSource("cleared", "draft_overlay", { contentStatus: "draft" }), + [], + ); + + await repo.replaceSource(contentSource("failed", "columns", { contentStatus: "draft" }), [ + occurrence("hero", "media-failed-overlay"), + ]); + await repo.markSourceAttempted( + contentSource("failed", "draft_overlay", { + contentStatus: "draft", + sourceCompleteness: "failed", + lastErrorCode: "DRAFT_REVISION_INVALID", + }), + ); + + await repo.replaceSource(contentSource("no-overlay", "columns", { contentStatus: "draft" }), [ + occurrence("hero", "media-columns"), + ]); + + const counts = await repo.findActiveEntryCountsByMediaIds([ + "media-published", + "media-pending", + "media-superseded", + "media-visible", + "media-cleared", + "media-failed-overlay", + "media-columns", + ]); + + expect(Object.fromEntries(counts)).toEqual({ + "media-published": 1, + "media-pending": 1, + "media-superseded": 0, + "media-visible": 1, + "media-cleared": 0, + "media-failed-overlay": 0, + "media-columns": 1, + }); + }); + + it("excludes trash, deleted collections, and stale generations from active counts", async () => { + await registerCollection(ctx, "posts"); + + await repo.replaceSource( + contentSource("trash", "columns", { contentDeletedAt: "2026-01-01T00:00:00.000Z" }), + [occurrence("hero", "media-trash")], + ); + await repo.replaceSource( + contentSource("inconsistent-trash", "columns", { contentStatus: "published" }), + [occurrence("hero", "media-inconsistent-trash")], + ); + await repo.replaceSource( + contentSource("inconsistent-trash", "draft_overlay", { + contentStatus: "published", + contentDeletedAt: "2026-01-02T00:00:00.000Z", + }), + [occurrence("body", "media-inconsistent-trash")], + ); + await repo.replaceSource( + contentSource("ghost", "columns", { collectionSlug: "deleted_collection" }), + [occurrence("hero", "media-ghost")], + ); + await repo.replaceSource(contentSource("generation", "columns"), [ + occurrence("hero", "media-stale-generation"), + ]); + await repo.replaceSource(contentSource("generation", "columns"), [ + occurrence("hero", "media-current-generation"), + ]); + await repo.replaceSource( + contentSource("restored", "columns", { + contentDeletedAt: "2026-01-03T00:00:00.000Z", + }), + [occurrence("hero", "media-restored")], + ); + await repo.replaceSource(contentSource("restored", "columns"), [ + occurrence("hero", "media-restored"), + ]); + await repo.replaceSource(contentSource("provider-only", "columns"), [ + occurrence("hero", "unused-local-id", { + mediaId: null, + provider: "external", + providerAssetId: "media-provider-only", + }), + ]); + + const counts = await repo.findActiveEntryCountsByMediaIds([ + "media-trash", + "media-inconsistent-trash", + "media-ghost", + "media-stale-generation", + "media-current-generation", + "media-restored", + "media-provider-only", + ]); + + expect(Object.fromEntries(counts)).toEqual({ + "media-trash": 0, + "media-inconsistent-trash": 0, + "media-ghost": 0, + "media-stale-generation": 0, + "media-current-generation": 1, + "media-restored": 1, + "media-provider-only": 0, + }); + }); + + it("returns zero-filled counts across multiple D1-sized batches", async () => { + const mediaIds = Array.from({ length: SQL_BATCH_SIZE + 1 }, (_, index) => `media-${index}`); + + const counts = await repo.findActiveEntryCountsByMediaIds(mediaIds); + + expect(counts.size).toBe(mediaIds.length); + expect([...counts.values()].every((count) => count === 0)).toBe(true); + }); + + it("loads status coverage for every current collection and ignores orphan statuses", async () => { + await registerCollection(ctx, "pages"); + await registerCollection(ctx, "posts"); + await repo.upsertIndexStatus({ + adapterId: "content-media", + scopeType: "collection", + scopeKey: "posts", + status: "complete", + schemaVersion: 2, + }); + await repo.upsertIndexStatus({ + adapterId: "content-media", + scopeType: "collection", + scopeKey: "deleted_collection", + status: "failed", + }); + + const scopes = await repo.findCollectionIndexStatusScopes({ + adapterId: "content-media", + scopeType: "collection", + }); + + expect(scopes).toEqual([ + { collectionSlug: "pages", status: null, schemaVersion: null }, + { collectionSlug: "posts", status: "complete", schemaVersion: 2 }, + ]); + }); + + it("paginates complete entry groups with nested sources and occurrences", async () => { + await registerCollection(ctx, "pages"); + await registerCollection(ctx, "posts"); + + await repo.replaceSource( + contentSource("entry-a", "columns", { + contentStatus: "published", + contentTitle: "Published title", + }), + [occurrence("hero", "media-shared"), occurrence("body", "media-shared")], + ); + await repo.replaceSource( + contentSource("entry-a", "draft_overlay", { + contentStatus: "published", + contentTitle: "Draft title", + contentDeletedAt: "2026-01-02T00:00:00.000Z", + }), + [occurrence("draftHero", "media-shared")], + ); + await repo.replaceSource(contentSource("entry-b", "columns", { contentStatus: "draft" }), [ + occurrence("hero", "media-shared"), + ]); + await repo.replaceSource( + contentSource("entry-b", "draft_overlay", { contentStatus: "draft" }), + [occurrence("hero", "media-other")], + ); + await repo.replaceSource(contentSource("entry-c", "columns"), [ + occurrence("hero", "media-shared"), + ]); + await repo.replaceSource(contentSource("entry-d", "columns", { collectionSlug: "pages" }), [ + occurrence("hero", "media-shared"), + ]); + + const first = await repo.findCurrentEntryUsagePageByMediaId("media-shared", { limit: 2 }); + + expect(first.items.map(entryIdentity)).toEqual([ + ["pages", "entry-d"], + ["posts", "entry-a"], + ]); + expect(first.nextCursor).toEqual(expect.any(String)); + + const entryA = first.items[1]!; + expect(entryA.contentDeletedAt).toBe("2026-01-02T00:00:00.000Z"); + expect(entryA.sources.map((source) => source.source.sourceVariant)).toEqual([ + "columns", + "draft_overlay", + ]); + expect( + entryA.sources.flatMap((source) => source.occurrences.map((item) => item.fieldPath)), + ).toEqual(["body", "hero", "draftHero"]); + + const second = await repo.findCurrentEntryUsagePageByMediaId("media-shared", { + limit: 2, + cursor: first.nextCursor, + }); + expect(second.items.map(entryIdentity)).toEqual([["posts", "entry-c"]]); + expect(second.nextCursor).toBeUndefined(); + }); + + it("rejects malformed entry-group cursors", async () => { + await expect( + repo.findCurrentEntryUsagePageByMediaId("media-shared", { cursor: "not-a-cursor" }), + ).rejects.toBeInstanceOf(InvalidCursorError); + }); + + it("defaults non-finite entry-group limits", async () => { + await registerCollection(ctx, "posts"); + await repo.replaceSource(contentSource("entry-a", "columns"), [ + occurrence("hero", "media-shared"), + ]); + + const page = await repo.findCurrentEntryUsagePageByMediaId("media-shared", { + limit: Number.NaN, + }); + + expect(page.items.map(entryIdentity)).toEqual([["posts", "entry-a"]]); + }); +}); + +function contentSource( + contentId: string, + variant: MediaUsageContentSourceVariant, + overrides: Partial[0]> = {}, +): Parameters[0] { + const collectionSlug = overrides.collectionSlug ?? "posts"; + return { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug, + contentId, + sourceVariant: variant, + }), + sourceType: "content", + collectionSlug, + contentId, + sourceVariant: variant, + locale: "en", + translationGroup: `tg-${contentId}`, + contentSlug: `slug-${contentId}`, + contentTitle: `Title ${contentId}`, + contentStatus: variant === "columns" ? "published" : "draft", + contentScheduledAt: null, + contentDeletedAt: null, + revisionId: `rev-${contentId}-${variant}`, + ...overrides, + }; +} + +function occurrence( + fieldSlug: string, + mediaId: string, + overrides: Partial[1][number]> = {}, +): Parameters[1][number] { + return { + fieldSlug, + fieldPath: fieldSlug, + occurrenceIndex: 0, + referenceType: "image_field", + mediaId, + provider: "local", + providerAssetId: mediaId, + mediaKind: "image", + mimeType: null, + ...overrides, + }; +} + +async function registerCollection(ctx: DialectTestContext, slug: string): Promise { + await ctx.db + .insertInto("_emdash_collections") + .values({ id: `collection-${slug}`, slug, label: slug, has_seo: 0 }) + .execute(); +} + +function entryIdentity(entry: { collectionSlug: string; contentId: string }): [string, string] { + return [entry.collectionSlug, entry.contentId]; +} diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 6380bf5d04..c1fb5afad1 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -138,6 +138,7 @@ describe("Database Migrations (Integration)", () => { "049_taxonomies_name_locale_index", "050_media_usage_index_status", "051_content_taxonomies_denorm", + "052_media_usage_read_index", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); From 7974ce7347f6f4b207b58d18d5c7208ae4e68e5e Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Mon, 13 Jul 2026 22:25:49 +0100 Subject: [PATCH 2/8] feat(core): add coverage-aware media usage summaries --- packages/core/src/api/authorize.ts | 11 +- packages/core/src/api/errors.ts | 1 + packages/core/src/api/handlers/index.ts | 5 + packages/core/src/api/handlers/media-usage.ts | 99 +++- packages/core/src/api/schemas/media-usage.ts | 21 + packages/core/src/api/schemas/media.ts | 25 + packages/core/src/astro/routes/api/media.ts | 23 +- .../core/src/astro/routes/api/media/[id].ts | 23 +- .../media-usage-runtime-refresh.test.ts | 83 +++ .../unit/api/media-usage-summary.test.ts | 511 ++++++++++++++++++ 10 files changed, 791 insertions(+), 11 deletions(-) create mode 100644 packages/core/tests/unit/api/media-usage-summary.test.ts diff --git a/packages/core/src/api/authorize.ts b/packages/core/src/api/authorize.ts index baea83de9a..e23f7bf4e0 100644 --- a/packages/core/src/api/authorize.ts +++ b/packages/core/src/api/authorize.ts @@ -6,7 +6,7 @@ */ import type { Permission, RoleLevel } from "@emdash-cms/auth"; -import { hasPermission, canActOnOwn } from "@emdash-cms/auth"; +import { hasPermission, canActOnOwn, hasScope } from "@emdash-cms/auth"; import { apiError } from "./error.js"; @@ -15,6 +15,15 @@ interface UserLike { role: RoleLevel; } +export function canReadMediaUsageCount( + user: UserLike | null | undefined, + tokenScopes: string[] | undefined, +): boolean { + return ( + hasPermission(user, "content:read_drafts") && (!tokenScopes || hasScope(tokenScopes, "admin")) + ); +} + /** * Check if user has a permission. Returns a 401/403 Response if not, or null if authorized. * diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index 4a79e751a8..49fdbd1d5d 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -85,6 +85,7 @@ export const ErrorCode = { MEDIA_CREATE_ERROR: "MEDIA_CREATE_ERROR", MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR", MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", + MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR", MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR", NO_STORAGE: "NO_STORAGE", NO_FILE: "NO_FILE", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 181ec2cb68..0594384ee9 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -62,9 +62,14 @@ export { } from "./media.js"; export { + aggregateMediaUsageCoverageStatus, + handleMediaUsageSummaries, handleMediaUsageRepair, toMediaUsageRepairResponse, + type MediaUsageCoverage, + type MediaUsageCoverageStatus, type MediaUsageRepairResponse, + type MediaUsageSummary, } from "./media-usage.js"; // Schema handlers diff --git a/packages/core/src/api/handlers/media-usage.ts b/packages/core/src/api/handlers/media-usage.ts index e98f88ec8e..11a51fbd3a 100644 --- a/packages/core/src/api/handlers/media-usage.ts +++ b/packages/core/src/api/handlers/media-usage.ts @@ -1,22 +1,98 @@ import type { Kysely } from "kysely"; +import { + MediaUsageRepository, + type MediaUsageCollectionIndexStatusScope, +} from "../../database/repositories/media-usage.js"; import type { Database } from "../../database/types.js"; +import { + CONTENT_MEDIA_USAGE_ADAPTER_ID, + CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, +} from "../../media/usage/content-refresh.js"; import { repairContentMediaUsageAll, repairContentMediaUsageCollection, type ContentMediaUsageRepairAllResult, type ContentMediaUsageRepairCollectionResult, } from "../../media/usage/content-repair.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../media/usage/content-snapshots.js"; import { ErrorCode } from "../errors.js"; -import type { MediaUsageRepairRequest, MediaUsageRepairResponse } from "../schemas/media-usage.js"; +import type { + MediaUsageCoverageStatus, + MediaUsageRepairRequest, + MediaUsageRepairResponse, + MediaUsageSummary, +} from "../schemas/media-usage.js"; import type { ApiResult } from "../types.js"; -export type { MediaUsageRepairRequest, MediaUsageRepairResponse } from "../schemas/media-usage.js"; +export type { + MediaUsageCoverage, + MediaUsageCoverageStatus, + MediaUsageRepairRequest, + MediaUsageRepairResponse, + MediaUsageSummary, +} from "../schemas/media-usage.js"; type ContentMediaUsageRepairResult = | ContentMediaUsageRepairCollectionResult | ContentMediaUsageRepairAllResult; +export function aggregateMediaUsageCoverageStatus( + scopes: readonly MediaUsageCollectionIndexStatusScope[], +): MediaUsageCoverageStatus { + const statuses = scopes.map(normalizeMediaUsageCoverageStatus); + if (statuses.every((status) => status === "complete")) { + return "complete"; + } + if (statuses.includes("unknown")) return "unknown"; + if (statuses.includes("running")) return "running"; + if (statuses.includes("stale")) return "stale"; + if (statuses.includes("partial")) return "partial"; + if (statuses.every((status) => status === "never")) return "never"; + if (statuses.every((status) => status === "failed")) return "failed"; + return "partial"; +} + +export async function handleMediaUsageSummaries( + db: Kysely, + mediaIds: readonly string[], + options: { includeCount: boolean }, +): Promise>> { + try { + const repository = new MediaUsageRepository(db); + const scopes = await repository.findCollectionIndexStatusScopes({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + }); + const coverage = { + scope: "all_content_collections" as const, + status: aggregateMediaUsageCoverageStatus(scopes), + }; + const counts = options.includeCount + ? await repository.findActiveEntryCountsByMediaIds(mediaIds) + : null; + const summaries: Record = {}; + + for (const mediaId of new Set(mediaIds)) { + summaries[mediaId] = { + count: counts ? (counts.get(mediaId) ?? 0) : null, + coverage, + }; + } + + return { success: true, data: summaries }; + } catch (error) { + console.error("[media-usage] summary read failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_READ_ERROR, + message: "Failed to read media usage", + }, + }; + } +} + export async function handleMediaUsageRepair( db: Kysely, input: MediaUsageRepairRequest, @@ -78,3 +154,22 @@ function toMediaUsageRepairCollectionSummary(result: ContentMediaUsageRepairColl completedAt: result.completedAt, }; } + +function normalizeMediaUsageCoverageStatus( + scope: MediaUsageCollectionIndexStatusScope, +): MediaUsageCoverageStatus { + if (scope.status === null) return "never"; + if (scope.status === "complete") { + return scope.schemaVersion === CONTENT_SOURCE_SCHEMA_VERSION ? "complete" : "stale"; + } + if ( + scope.status === "never" || + scope.status === "running" || + scope.status === "partial" || + scope.status === "failed" || + scope.status === "stale" + ) { + return scope.status; + } + return "unknown"; +} diff --git a/packages/core/src/api/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index 7cd89f25e7..6ff70c34c9 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -2,6 +2,24 @@ import { z } from "zod"; import { slugPattern } from "./common.js"; +export const mediaUsageCoverageStatusSchema = z + .enum(["complete", "never", "running", "partial", "failed", "stale", "unknown"]) + .meta({ id: "MediaUsageCoverageStatus" }); + +export const mediaUsageCoverageSchema = z + .object({ + scope: z.literal("all_content_collections"), + status: mediaUsageCoverageStatusSchema, + }) + .meta({ id: "MediaUsageCoverage" }); + +export const mediaUsageSummarySchema = z + .object({ + count: z.number().int().min(0).nullable(), + coverage: mediaUsageCoverageSchema, + }) + .meta({ id: "MediaUsageSummary" }); + export const mediaUsageRepairStatusSchema = z .enum(["complete", "partial", "failed", "stale"]) .meta({ id: "MediaUsageRepairStatus" }); @@ -46,3 +64,6 @@ export const mediaUsageRepairResponseSchema = z export type MediaUsageRepairRequest = z.infer; export type MediaUsageRepairResponse = z.infer; +export type MediaUsageCoverageStatus = z.infer; +export type MediaUsageCoverage = z.infer; +export type MediaUsageSummary = z.infer; diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 3b2519e10c..10ab3ecf50 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { cursorPaginationQuery } from "./common.js"; +import { mediaUsageSummarySchema } from "./media-usage.js"; // --------------------------------------------------------------------------- // Media: Input schemas @@ -23,9 +24,14 @@ export const mediaListQuery = cursorPaginationQuery mimeType: mimeTypeFilter, /** Case-insensitive filename substring search (also matches extensions). */ q: z.string().trim().min(1).max(200).optional(), + includeUsage: z.literal("1").optional(), }) .meta({ id: "MediaListQuery" }); +export const mediaGetQuery = z + .object({ includeUsage: z.literal("1").optional() }) + .meta({ id: "MediaGetQuery" }); + export const mediaUpdateBody = z .object({ alt: z.string().optional(), @@ -115,6 +121,25 @@ export const mediaResponseSchema = z .object({ item: mediaItemSchema }) .meta({ id: "MediaResponse" }); +export const mediaReadItemSchema = mediaItemSchema + .extend({ usage: mediaUsageSummarySchema.optional() }) + .meta({ id: "MediaReadItem" }); + +export const mediaReadResponseSchema = z + .object({ item: mediaReadItemSchema }) + .meta({ id: "MediaReadResponse" }); + +export const mediaListReadItemSchema = mediaReadItemSchema + .extend({ url: z.string() }) + .meta({ id: "MediaListReadItem" }); + +export const mediaListReadResponseSchema = z + .object({ + items: z.array(mediaListReadItemSchema), + nextCursor: z.string().optional(), + }) + .meta({ id: "MediaListReadResponse" }); + export const mediaListResponseSchema = z .object({ items: z.array(mediaItemSchema), diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index b1bb4e7b92..9dadf3f588 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -10,9 +10,10 @@ import * as path from "node:path"; import type { APIRoute } from "astro"; import { ulid } from "ulidx"; -import { requirePerm } from "#api/authorize.js"; +import { canReadMediaUsageCount, requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js"; +import { handleMediaUsageSummaries } from "#api/handlers/media-usage.js"; import { isParseError, parseQuery } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js"; import { MediaRepository } from "#db/repositories/media.js"; @@ -65,8 +66,26 @@ export const GET: APIRoute = async ({ request, locals }) => { // Add URL to each media item (relative URLs for portability) const itemsWithUrl = result.data.items.map((item) => addUrlToMedia(item)); + if (query.includeUsage !== "1") { + return apiSuccess({ items: itemsWithUrl, nextCursor: result.data.nextCursor }); + } + + const includeCount = canReadMediaUsageCount(user, locals.tokenScopes); + const usageResult = await handleMediaUsageSummaries( + emdash.db, + itemsWithUrl.map((item) => item.id), + { includeCount }, + ); + if (!usageResult.success) return unwrapResult(usageResult); + + const itemsWithUsage = []; + for (const item of itemsWithUrl) { + const usage = usageResult.data[item.id]; + if (!usage) return apiError("MEDIA_USAGE_READ_ERROR", "Failed to read media usage", 500); + itemsWithUsage.push({ ...item, usage }); + } - return apiSuccess({ items: itemsWithUrl, nextCursor: result.data.nextCursor }); + return apiSuccess({ items: itemsWithUsage, nextCursor: result.data.nextCursor }); }; /** diff --git a/packages/core/src/astro/routes/api/media/[id].ts b/packages/core/src/astro/routes/api/media/[id].ts index ae5adee373..b70123c8c9 100644 --- a/packages/core/src/astro/routes/api/media/[id].ts +++ b/packages/core/src/astro/routes/api/media/[id].ts @@ -8,17 +8,18 @@ import type { APIRoute } from "astro"; -import { requireOwnerPerm, requirePerm } from "#api/authorize.js"; -import { apiError, handleError, unwrapResult } from "#api/error.js"; -import { isParseError, parseBody } from "#api/parse.js"; -import { mediaUpdateBody } from "#api/schemas.js"; +import { canReadMediaUsageCount, requireOwnerPerm, requirePerm } from "#api/authorize.js"; +import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; +import { handleMediaUsageSummaries } from "#api/handlers/media-usage.js"; +import { isParseError, parseBody, parseQuery } from "#api/parse.js"; +import { mediaGetQuery, mediaUpdateBody } from "#api/schemas.js"; export const prerender = false; /** * Get media item */ -export const GET: APIRoute = async ({ params, locals }) => { +export const GET: APIRoute = async ({ params, request, locals }) => { const { emdash, user } = locals; const { id } = params; @@ -32,9 +33,19 @@ export const GET: APIRoute = async ({ params, locals }) => { if (!emdash?.handleMediaGet) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } + const query = parseQuery(new URL(request.url), mediaGetQuery); + if (isParseError(query)) return query; const result = await emdash.handleMediaGet(id); - return unwrapResult(result); + if (!result.success || query.includeUsage !== "1") return unwrapResult(result); + + const includeCount = canReadMediaUsageCount(user, locals.tokenScopes); + const usageResult = await handleMediaUsageSummaries(emdash.db, [id], { includeCount }); + if (!usageResult.success) return unwrapResult(usageResult); + const usage = usageResult.data[id]; + if (!usage) return apiError("MEDIA_USAGE_READ_ERROR", "Failed to read media usage", 500); + + return apiSuccess({ item: { ...result.data.item, usage } }); }; /** diff --git a/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts b/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts index 28058491b9..6936572361 100644 --- a/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts +++ b/packages/core/tests/integration/database/media-usage-runtime-refresh.test.ts @@ -1,6 +1,7 @@ import { sql } from "kysely"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { handleMediaUsageSummaries } from "../../../src/api/handlers/media-usage.js"; import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; import { RevisionRepository } from "../../../src/database/repositories/revision.js"; import type { EmDashRuntime } from "../../../src/emdash-runtime.js"; @@ -9,6 +10,7 @@ import { CONTENT_MEDIA_USAGE_ADAPTER_ID, CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, } from "../../../src/media/usage/content-refresh.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../../src/media/usage/content-snapshots.js"; import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { createTestRuntime } from "../../utils/mcp-runtime.js"; @@ -288,6 +290,87 @@ describeEachDialect("runtime content media usage refresh", (dialect) => { ); }); + it("suppresses draft columns usage after a real runtime overlay refresh failure", async () => { + const mediaId = "media-columns-before-failed-overlay"; + const created = await runtime.handleContentCreate("posts", { + slug: "failed-first-overlay", + status: "draft", + data: { + title: "Failed First Overlay", + hero: mediaRef(mediaId), + }, + }); + expect(created.success).toBe(true); + if (!created.success) throw new Error(created.error.message); + const contentId = created.data.item.id; + + expect(await usageRepo.findSource(sourceKey("posts", contentId, "columns"))).toEqual( + expect.objectContaining({ + contentStatus: "draft", + sourceCompleteness: "complete", + }), + ); + for (const scopeKey of ["posts", "plain_posts", "localized_posts"]) { + await usageRepo.upsertIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey, + status: "complete", + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + lastErrorCode: null, + }); + } + + expect(await handleMediaUsageSummaries(ctx.db, [mediaId], { includeCount: true })).toEqual({ + success: true, + data: { + [mediaId]: { + count: 1, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }, + }); + + await corruptFuturePostDraftRevisionSnapshots(ctx); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const updated = await runtime + .handleContentUpdate("posts", contentId, { + data: { hero: mediaRef("media-overlay-after-failure") }, + }) + .finally(() => consoleError.mockRestore()); + + expect(updated.success).toBe(true); + expect(await usageRepo.findSource(sourceKey("posts", contentId, "draft_overlay"))).toEqual( + expect.objectContaining({ + contentStatus: "draft", + sourceCompleteness: "failed", + lastErrorCode: "DRAFT_REVISION_INVALID", + }), + ); + expect(await usageRepo.findCurrentUsageByMediaId(mediaId)).toHaveLength(1); + expect(await handleMediaUsageSummaries(ctx.db, [mediaId], { includeCount: true })).toEqual({ + success: true, + data: { + [mediaId]: { + count: 0, + coverage: { scope: "all_content_collections", status: "stale" }, + }, + }, + }); + expect( + await usageRepo.findIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey: "posts", + }), + ).toEqual( + expect.objectContaining({ + status: "stale", + lastErrorCode: "DRAFT_REVISION_INVALID", + }), + ); + }); + it("refreshes columns usage for duplicated content", async () => { const created = await runtime.handleContentCreate("plain_posts", { slug: "original-post", diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts new file mode 100644 index 0000000000..933e9bbd02 --- /dev/null +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -0,0 +1,511 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + aggregateMediaUsageCoverageStatus, + handleMediaUsageSummaries, +} from "../../../src/api/handlers/media-usage.js"; +import { handleMediaGet, handleMediaList } from "../../../src/api/handlers/media.js"; +import { + mediaGetQuery, + mediaListQuery, + mediaReadResponseSchema, + mediaResponseSchema, + mediaUsageSummarySchema, +} from "../../../src/api/schemas/index.js"; +import { GET as listMedia } from "../../../src/astro/routes/api/media.js"; +import { GET as getMediaItem } from "../../../src/astro/routes/api/media/[id].js"; +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { MediaRepository, type MediaItem } from "../../../src/database/repositories/media.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { + CONTENT_MEDIA_USAGE_ADAPTER_ID, + CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, +} from "../../../src/media/usage/content-refresh.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../../src/media/usage/content-snapshots.js"; +import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js"; + +type ListRouteContext = Parameters[0]; +type GetRouteContext = Parameters[0]; + +interface SuccessBody { + data: T; +} + +interface ErrorBody { + error: { code: string; message: string }; +} + +interface MediaListBodyItem extends MediaItem { + url: string; + usage?: { + count: number | null; + coverage: { scope: "all_content_collections"; status: string }; + }; +} + +interface MediaGetBodyItem extends MediaItem { + usage?: MediaListBodyItem["usage"]; +} + +describe("media usage summary schemas", () => { + it.each([mediaListQuery, mediaGetQuery])( + "accepts only the exact includeUsage literal", + (schema) => { + expect(schema.parse({ includeUsage: "1" }).includeUsage).toBe("1"); + for (const includeUsage of ["0", "true", "false"]) { + expect(schema.safeParse({ includeUsage }).success).toBe(false); + } + }, + ); + + it("accepts every public coverage state and nullable counts", () => { + for (const status of [ + "complete", + "never", + "running", + "partial", + "failed", + "stale", + "unknown", + ] as const) { + expect( + mediaUsageSummarySchema.parse({ + count: null, + coverage: { scope: "all_content_collections", status }, + }), + ).toEqual({ count: null, coverage: { scope: "all_content_collections", status } }); + } + }); + + it("keeps usage off mutation response schemas", () => { + const item = mediaItemFixture(); + const usage = { + count: 0, + coverage: { scope: "all_content_collections" as const, status: "complete" as const }, + }; + + expect(mediaReadResponseSchema.parse({ item: { ...item, usage } }).item.usage).toEqual(usage); + expect(mediaResponseSchema.parse({ item: { ...item, usage } }).item).not.toHaveProperty( + "usage", + ); + }); +}); + +describe("media usage coverage aggregation", () => { + const scope = (status: string | null, schemaVersion = CONTENT_SOURCE_SCHEMA_VERSION) => ({ + collectionSlug: "posts", + status, + schemaVersion: status === null ? null : schemaVersion, + }); + + it.each([ + ["no collections", [], "complete"], + ["all complete", [scope("complete")], "complete"], + ["all missing", [scope(null), { ...scope(null), collectionSlug: "pages" }], "never"], + ["complete and missing", [scope("complete"), scope(null)], "partial"], + ["old complete", [scope("complete", CONTENT_SOURCE_SCHEMA_VERSION - 1)], "stale"], + ["unknown stored status", [scope("surprise")], "unknown"], + ["unknown before running", [scope("running"), scope("surprise")], "unknown"], + ["homogeneous running", [scope("running"), scope("running")], "running"], + ["running before stale", [scope("stale"), scope("running")], "running"], + ["homogeneous stale", [scope("stale"), scope("stale")], "stale"], + ["stale before partial", [scope("partial"), scope("stale")], "stale"], + ["homogeneous partial", [scope("partial"), scope("partial")], "partial"], + ["homogeneous failed", [scope("failed"), scope("failed")], "failed"], + ["complete and failed", [scope("complete"), scope("failed")], "partial"], + ["mixed failed and never", [scope("failed"), scope(null)], "partial"], + ["running and failed", [scope("running"), scope("failed")], "running"], + ] as const)("returns %s coverage", (_name, scopes, expected) => { + expect(aggregateMediaUsageCoverageStatus(scopes)).toBe(expected); + }); +}); + +describe("media usage summary handler and routes", () => { + let sqlite: Database.Database; + let db: Kysely; + let queries: string[]; + let usedMedia: MediaItem; + let unusedMedia: MediaItem; + + beforeEach(async () => { + queries = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") queries.push(event.query.sql); + }, + }); + await runMigrations(db); + + const mediaRepository = new MediaRepository(db); + usedMedia = await mediaRepository.create({ + filename: "used.png", + mimeType: "image/png", + storageKey: "used.png", + }); + unusedMedia = await mediaRepository.create({ + filename: "unused.png", + mimeType: "image/png", + storageKey: "unused.png", + }); + + await db + .insertInto("_emdash_collections") + .values({ id: "collection-posts", slug: "posts", label: "Posts", has_seo: 0 }) + .execute(); + const usageRepository = new MediaUsageRepository(db); + await usageRepository.upsertIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey: "posts", + status: "complete", + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + }); + await usageRepository.replaceSource( + { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns", + }), + sourceType: "content", + collectionSlug: "posts", + contentId: "entry-1", + sourceVariant: "columns", + contentStatus: "published", + }, + [ + { + fieldSlug: "hero", + fieldPath: "hero", + referenceType: "image_field", + mediaId: usedMedia.id, + provider: "local", + providerAssetId: usedMedia.id, + }, + ], + ); + queries = []; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.destroy(); + }); + + it("loads coverage once and skips count SQL when counts are redacted", async () => { + const result = await handleMediaUsageSummaries(db, [usedMedia.id, unusedMedia.id], { + includeCount: false, + }); + + expect(result).toEqual({ + success: true, + data: { + [usedMedia.id]: { + count: null, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + [unusedMedia.id]: { + count: null, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }, + }); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain("_emdash_media_usage_index_status"); + }); + + it("loads one coverage query and one batched count query", async () => { + const result = await handleMediaUsageSummaries(db, [usedMedia.id, unusedMedia.id], { + includeCount: true, + }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: { + [usedMedia.id]: expect.objectContaining({ count: 1 }), + [unusedMedia.id]: expect.objectContaining({ count: 0 }), + }, + }), + ); + expect(queries).toHaveLength(2); + expect(queries.filter((query) => query.includes("visible_entries"))).toHaveLength(1); + }); + + it("chunks more than 50 media IDs without becoming N+1", async () => { + const mediaIds = [ + usedMedia.id, + ...Array.from({ length: 50 }, (_, index) => `unmatched-media-${index}`), + ]; + + const result = await handleMediaUsageSummaries(db, mediaIds, { includeCount: true }); + + expect(result).toEqual(expect.objectContaining({ success: true })); + expect(queries).toHaveLength(3); + expect(queries.filter((query) => query.includes("visible_entries"))).toHaveLength(2); + }); + + it("preserves the list response and query cost without includeUsage", async () => { + const response = await invokeList("", Role.CONTRIBUTOR); + const data = await readSuccess<{ + items: MediaListBodyItem[]; + nextCursor?: string; + }>(response); + + expect(response.status).toBe(200); + expect(data.items).toHaveLength(2); + expect(data.items.every((item) => !("usage" in item))).toBe(true); + expect(data.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: usedMedia.id, + url: `/_emdash/api/media/file/${usedMedia.storageKey}`, + }), + ]), + ); + expect(queries).toHaveLength(1); + }); + + it("attaches numeric usage counts to every list item for an authorized session", async () => { + const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); + const itemsById = new Map(data.items.map((item) => [item.id, item])); + + expect(itemsById.get(usedMedia.id)?.usage).toEqual({ + count: 1, + coverage: { scope: "all_content_collections", status: "complete" }, + }); + expect(itemsById.get(unusedMedia.id)?.usage?.count).toBe(0); + expect(queries).toHaveLength(3); + }); + + it.each([ + ["subscriber session", Role.SUBSCRIBER, undefined], + ["media token owned by an admin", Role.ADMIN, ["media:read"]], + ["admin token owned by a subscriber", Role.SUBSCRIBER, ["admin"]], + ] as const)("redacts counts and skips count SQL for a %s", async (_name, role, tokenScopes) => { + const response = await invokeList("?includeUsage=1", role, tokenScopes); + const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); + + expect(data.items.every((item) => item.usage?.count === null)).toBe(true); + expect(queries).toHaveLength(2); + expect(queries.some((query) => query.includes("visible_entries"))).toBe(false); + }); + + it("allows numeric counts for an admin token owned by a contributor", async () => { + const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR, ["admin"]); + const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); + + expect(data.items.find((item) => item.id === usedMedia.id)?.usage?.count).toBe(1); + expect(queries).toHaveLength(3); + }); + + it("preserves list URLs and cursors when usage is requested", async () => { + const baselineResponse = await invokeList("?limit=1", Role.CONTRIBUTOR); + const baseline = await readSuccess<{ items: MediaListBodyItem[]; nextCursor?: string }>( + baselineResponse, + ); + queries = []; + + const response = await invokeList("?limit=1&includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ items: MediaListBodyItem[]; nextCursor?: string }>(response); + + expect(data.nextCursor).toBeTypeOf("string"); + expect(data.nextCursor).toBe(baseline.nextCursor); + expect(data.items[0]?.url).toBe(`/_emdash/api/media/file/${baseline.items[0]?.storageKey}`); + expect(data.items[0]?.usage).toBeDefined(); + expect(queries).toHaveLength(3); + }); + + it.each(["0", "true", "false"])("rejects includeUsage=%s", async (includeUsage) => { + const response = await invokeList(`?includeUsage=${includeUsage}`, Role.CONTRIBUTOR); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "VALIDATION_ERROR" }) }), + ); + expect(queries).toHaveLength(0); + }); + + it("adds a summary to media get within the three-query budget", async () => { + const response = await invokeGet(usedMedia.id, "?includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(data.item.usage).toEqual({ + count: 1, + coverage: { scope: "all_content_collections", status: "complete" }, + }); + expect(queries).toHaveLength(3); + }); + + it("preserves media get and its query cost without includeUsage", async () => { + const response = await invokeGet(usedMedia.id, "", Role.CONTRIBUTOR); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(response.status).toBe(200); + expect(data.item).not.toHaveProperty("usage"); + expect(queries).toHaveLength(1); + }); + + it("redacts media get counts without executing count SQL", async () => { + const response = await invokeGet(usedMedia.id, "?includeUsage=1", Role.SUBSCRIBER); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(data.item.usage).toEqual({ + count: null, + coverage: { scope: "all_content_collections", status: "complete" }, + }); + expect(queries).toHaveLength(2); + expect(queries.some((query) => query.includes("visible_entries"))).toBe(false); + }); + + it("applies token count authorization to media get", async () => { + const redactedResponse = await invokeGet(usedMedia.id, "?includeUsage=1", Role.ADMIN, [ + "media:read", + ]); + const redacted = await readSuccess<{ item: MediaGetBodyItem }>(redactedResponse); + + expect(redacted.item.usage?.count).toBeNull(); + expect(queries).toHaveLength(2); + queries = []; + + const numericResponse = await invokeGet(usedMedia.id, "?includeUsage=1", Role.CONTRIBUTOR, [ + "admin", + ]); + const numeric = await readSuccess<{ item: MediaGetBodyItem }>(numericResponse); + + expect(numeric.item.usage?.count).toBe(1); + expect(queries).toHaveLength(3); + }); + + it("uses the last duplicate includeUsage value and ignores unknown get query keys", async () => { + const response = await invokeGet( + usedMedia.id, + "?includeUsage=0&unknown=value&includeUsage=1", + Role.CONTRIBUTOR, + ); + const data = await readSuccess<{ item: MediaGetBodyItem }>(response); + + expect(response.status).toBe(200); + expect(data.item.usage?.count).toBe(1); + expect(queries).toHaveLength(3); + }); + + it("validates recognized media get query values", async () => { + const response = await invokeGet(usedMedia.id, "?includeUsage=false", Role.CONTRIBUTOR); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "VALIDATION_ERROR" }) }), + ); + expect(queries).toHaveLength(0); + }); + + it("returns 404 for missing media without running usage queries", async () => { + const response = await invokeGet("missing", "?includeUsage=1", Role.CONTRIBUTOR); + + expect(response.status).toBe(404); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "NOT_FOUND" }) }), + ); + expect(queries).toHaveLength(1); + expect(queries[0]).not.toContain("_emdash_media_usage"); + }); + + it("returns MEDIA_USAGE_READ_ERROR when requested summary loading fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + await db.schema.dropTable("_emdash_media_usage_index_status").execute(); + queries = []; + + const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR); + + expect(response.status).toBe(500); + expect((await response.json()) as ErrorBody).toEqual({ + error: { code: "MEDIA_USAGE_READ_ERROR", message: "Failed to read media usage" }, + }); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it("returns MEDIA_USAGE_READ_ERROR when requested media get summary loading fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + await db.schema.dropTable("_emdash_media_usage_index_status").execute(); + queries = []; + + const response = await invokeGet(usedMedia.id, "?includeUsage=1", Role.CONTRIBUTOR); + + expect(response.status).toBe(500); + expect((await response.json()) as ErrorBody).toEqual({ + error: { code: "MEDIA_USAGE_READ_ERROR", message: "Failed to read media usage" }, + }); + // Kysely's query logger records the successful media lookup but not the failed statement. + expect(queries).toHaveLength(1); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + function invokeList( + query: string, + role: RoleLevel, + tokenScopes?: readonly string[], + ): Promise { + return listMedia({ + request: new Request(`http://localhost/_emdash/api/media${query}`), + locals: routeLocals(role, tokenScopes), + } as ListRouteContext) as Promise; + } + + function invokeGet( + id: string, + query: string, + role: RoleLevel, + tokenScopes?: readonly string[], + ): Promise { + return getMediaItem({ + params: { id }, + request: new Request(`http://localhost/_emdash/api/media/${id}${query}`), + locals: routeLocals(role, tokenScopes), + } as GetRouteContext) as Promise; + } + + function routeLocals(role: RoleLevel, tokenScopes?: readonly string[]) { + return { + emdash: { + db, + handleMediaList: (params: Parameters[1]) => + handleMediaList(db, params), + handleMediaGet: (id: string) => handleMediaGet(db, id), + }, + user: { id: "user-1", role }, + tokenScopes: tokenScopes ? [...tokenScopes] : undefined, + }; + } +}); + +async function readSuccess(response: Response): Promise { + return ((await response.json()) as SuccessBody).data; +} + +function mediaItemFixture(): MediaItem { + return { + id: "media-1", + filename: "hero.png", + mimeType: "image/png", + size: null, + width: null, + height: null, + alt: null, + caption: null, + storageKey: "hero.png", + status: "ready", + contentHash: null, + blurhash: null, + dominantColor: null, + createdAt: "2026-07-13T00:00:00.000Z", + authorId: null, + }; +} From 11afbb1b46fb7e61f3d7a1b60f4858b744d45d0c Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Mon, 13 Jul 2026 23:08:20 +0100 Subject: [PATCH 3/8] feat(core): add media Used in details endpoint --- packages/core/src/api/handlers/index.ts | 5 + packages/core/src/api/handlers/media-usage.ts | 128 ++++- packages/core/src/api/schemas/media-usage.ts | 47 ++ packages/core/src/astro/integration/routes.ts | 5 + .../src/astro/routes/api/media/[id]/usage.ts | 30 + .../src/database/repositories/media-usage.ts | 35 +- .../database/media-usage-read-plan.test.ts | 2 + .../media-usage-read-repository.test.ts | 26 +- .../unit/api/media-usage-read-route.test.ts | 523 ++++++++++++++++++ .../unit/astro/media-usage-read-auth.test.ts | 154 ++++++ 10 files changed, 931 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/astro/routes/api/media/[id]/usage.ts create mode 100644 packages/core/tests/unit/api/media-usage-read-route.test.ts create mode 100644 packages/core/tests/unit/astro/media-usage-read-auth.test.ts diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 0594384ee9..4e08f4e6be 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -63,12 +63,17 @@ export { export { aggregateMediaUsageCoverageStatus, + handleMediaUsageDetails, handleMediaUsageSummaries, handleMediaUsageRepair, toMediaUsageRepairResponse, type MediaUsageCoverage, type MediaUsageCoverageStatus, + type MediaUsageDetailsResponse, + type MediaUsageEntryDetail, + type MediaUsageOccurrenceDetail, type MediaUsageRepairResponse, + type MediaUsageSourceDetail, type MediaUsageSummary, } from "./media-usage.js"; diff --git a/packages/core/src/api/handlers/media-usage.ts b/packages/core/src/api/handlers/media-usage.ts index 11a51fbd3a..f9b829d9be 100644 --- a/packages/core/src/api/handlers/media-usage.ts +++ b/packages/core/src/api/handlers/media-usage.ts @@ -3,7 +3,10 @@ import type { Kysely } from "kysely"; import { MediaUsageRepository, type MediaUsageCollectionIndexStatusScope, + type MediaUsageEntryGroup, } from "../../database/repositories/media-usage.js"; +import { MediaRepository } from "../../database/repositories/media.js"; +import { InvalidCursorError } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; import { CONTENT_MEDIA_USAGE_ADAPTER_ID, @@ -18,7 +21,11 @@ import { import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../media/usage/content-snapshots.js"; import { ErrorCode } from "../errors.js"; import type { + MediaUsageCoverage, MediaUsageCoverageStatus, + MediaUsageDetailsResponse, + MediaUsageEntryDetail, + MediaUsageOccurrenceDetail, MediaUsageRepairRequest, MediaUsageRepairResponse, MediaUsageSummary, @@ -28,6 +35,10 @@ import type { ApiResult } from "../types.js"; export type { MediaUsageCoverage, MediaUsageCoverageStatus, + MediaUsageDetailsResponse, + MediaUsageEntryDetail, + MediaUsageOccurrenceDetail, + MediaUsageSourceDetail, MediaUsageRepairRequest, MediaUsageRepairResponse, MediaUsageSummary, @@ -60,14 +71,7 @@ export async function handleMediaUsageSummaries( ): Promise>> { try { const repository = new MediaUsageRepository(db); - const scopes = await repository.findCollectionIndexStatusScopes({ - adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, - scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, - }); - const coverage = { - scope: "all_content_collections" as const, - status: aggregateMediaUsageCoverageStatus(scopes), - }; + const coverage = await loadMediaUsageCoverage(repository); const counts = options.includeCount ? await repository.findActiveEntryCountsByMediaIds(mediaIds) : null; @@ -93,6 +97,52 @@ export async function handleMediaUsageSummaries( } } +export async function handleMediaUsageDetails( + db: Kysely, + mediaId: string, + options: { cursor?: string; limit?: number }, +): Promise> { + try { + const media = await new MediaRepository(db).findById(mediaId); + if (!media) { + return { + success: false, + error: { + code: ErrorCode.NOT_FOUND, + message: `Media item not found: ${mediaId}`, + }, + }; + } + + const repository = new MediaUsageRepository(db); + const coverage = await loadMediaUsageCoverage(repository); + const page = await repository.findCurrentEntryUsagePageByMediaId(mediaId, options); + return { + success: true, + data: { + items: page.items.map(toMediaUsageEntryDetail), + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), + coverage, + }, + }; + } catch (error) { + if (error instanceof InvalidCursorError) { + return { + success: false, + error: { code: ErrorCode.INVALID_CURSOR, message: error.message }, + }; + } + console.error("[media-usage] detail read failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_READ_ERROR, + message: "Failed to read media usage", + }, + }; + } +} + export async function handleMediaUsageRepair( db: Kysely, input: MediaUsageRepairRequest, @@ -173,3 +223,65 @@ function normalizeMediaUsageCoverageStatus( } return "unknown"; } + +async function loadMediaUsageCoverage( + repository: MediaUsageRepository, +): Promise { + const scopes = await repository.findCollectionIndexStatusScopes({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + }); + return { + scope: "all_content_collections", + status: aggregateMediaUsageCoverageStatus(scopes), + }; +} + +function toMediaUsageEntryDetail(group: MediaUsageEntryGroup): MediaUsageEntryDetail { + const preferred = + group.sources.find(({ source }) => source.sourceVariant === "draft_overlay") ?? + group.sources.find(({ source }) => source.sourceVariant === "columns"); + if (!preferred) { + throw new Error("Media usage entry has no supported source"); + } + + return { + collection: group.collectionSlug, + contentId: group.contentId, + title: preferred.source.contentTitle, + slug: preferred.source.contentSlug, + locale: preferred.source.locale, + status: preferred.source.contentStatus, + scheduledAt: preferred.source.contentScheduledAt, + deletedAt: group.contentDeletedAt, + sources: group.sources.flatMap(({ source, occurrences }) => { + if (source.sourceVariant !== "columns" && source.sourceVariant !== "draft_overlay") { + return []; + } + return [ + { + variant: source.sourceVariant, + occurrences: occurrences.map((occurrence) => ({ + fieldSlug: occurrence.fieldSlug, + fieldPath: occurrence.fieldPath, + occurrenceIndex: occurrence.occurrenceIndex, + referenceType: normalizeMediaUsageReferenceType(occurrence.referenceType), + })), + }, + ]; + }), + }; +} + +function normalizeMediaUsageReferenceType( + referenceType: string, +): MediaUsageOccurrenceDetail["referenceType"] { + if ( + referenceType === "image_field" || + referenceType === "file_field" || + referenceType === "portable_text_image" + ) { + return referenceType; + } + return "unknown"; +} diff --git a/packages/core/src/api/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index 6ff70c34c9..59271be328 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -20,6 +20,49 @@ export const mediaUsageSummarySchema = z }) .meta({ id: "MediaUsageSummary" }); +export const mediaUsageDetailsQuery = z.object({ + cursor: z.string().min(1).max(2048).optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), +}); + +export const mediaUsageOccurrenceDetailSchema = z + .object({ + fieldSlug: z.string(), + fieldPath: z.string(), + occurrenceIndex: z.number().int().min(0), + referenceType: z.enum(["image_field", "file_field", "portable_text_image", "unknown"]), + }) + .meta({ id: "MediaUsageOccurrenceDetail" }); + +export const mediaUsageSourceDetailSchema = z + .object({ + variant: z.enum(["columns", "draft_overlay"]), + occurrences: z.array(mediaUsageOccurrenceDetailSchema), + }) + .meta({ id: "MediaUsageSourceDetail" }); + +export const mediaUsageEntryDetailSchema = z + .object({ + collection: z.string(), + contentId: z.string(), + title: z.string().nullable(), + slug: z.string().nullable(), + locale: z.string().nullable(), + status: z.string().nullable(), + scheduledAt: z.string().nullable(), + deletedAt: z.string().nullable(), + sources: z.array(mediaUsageSourceDetailSchema), + }) + .meta({ id: "MediaUsageEntryDetail" }); + +export const mediaUsageDetailsResponseSchema = z + .object({ + items: z.array(mediaUsageEntryDetailSchema), + nextCursor: z.string().optional(), + coverage: mediaUsageCoverageSchema, + }) + .meta({ id: "MediaUsageDetailsResponse" }); + export const mediaUsageRepairStatusSchema = z .enum(["complete", "partial", "failed", "stale"]) .meta({ id: "MediaUsageRepairStatus" }); @@ -67,3 +110,7 @@ export type MediaUsageRepairResponse = z.infer; export type MediaUsageCoverage = z.infer; export type MediaUsageSummary = z.infer; +export type MediaUsageOccurrenceDetail = z.infer; +export type MediaUsageSourceDetail = z.infer; +export type MediaUsageEntryDetail = z.infer; +export type MediaUsageDetailsResponse = z.infer; diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 2174640914..82932bb271 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -213,6 +213,11 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/media/[id].ts"), }); + injectRoute({ + pattern: "/_emdash/api/media/[id]/usage", + entrypoint: resolveRoute("api/media/[id]/usage.ts"), + }); + injectRoute({ pattern: "/_emdash/api/media/[id]/confirm", entrypoint: resolveRoute("api/media/[id]/confirm.ts"), diff --git a/packages/core/src/astro/routes/api/media/[id]/usage.ts b/packages/core/src/astro/routes/api/media/[id]/usage.ts new file mode 100644 index 0000000000..6a702e7335 --- /dev/null +++ b/packages/core/src/astro/routes/api/media/[id]/usage.ts @@ -0,0 +1,30 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { handleMediaUsageDetails } from "#api/handlers/media-usage.js"; +import { isParseError, parseQuery } from "#api/parse.js"; +import { mediaUsageDetailsQuery } from "#api/schemas.js"; +import { requireScope } from "#auth/scopes.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + + const mediaDenied = requirePerm(user, "media:read"); + if (mediaDenied) return mediaDenied; + const contentDenied = requirePerm(user, "content:read_drafts"); + if (contentDenied) return contentDenied; + const scopeDenied = requireScope(locals, "admin"); + if (scopeDenied) return scopeDenied; + + const { id } = params; + if (!id) return apiError("INVALID_REQUEST", "Media ID required", 400); + if (!emdash?.db) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const query = parseQuery(new URL(request.url), mediaUsageDetailsQuery); + if (isParseError(query)) return query; + + return unwrapResult(await handleMediaUsageDetails(emdash.db, id, query)); +}; diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index ab817af7cf..f3655015d6 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -20,7 +20,7 @@ import type { MediaUsageTable, } from "../types.js"; import { validateIdentifier } from "../validate.js"; -import { decodeCursor, encodeCursor, type FindManyResult } from "./types.js"; +import { decodeCursor, encodeCursor, InvalidCursorError, type FindManyResult } from "./types.js"; type DatabaseExecutor = Kysely | Transaction; type MediaUsageSourceNullableStringColumn = @@ -537,6 +537,9 @@ export class MediaUsageRepository { const requestedLimit = Math.floor(options.limit ?? 50); const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(1, requestedLimit), 100) : 50; const cursor = options.cursor ? decodeCursor(options.cursor) : null; + if (cursor && (cursor.orderValue.length === 0 || cursor.id.length === 0)) { + throw new InvalidCursorError(options.cursor ?? ""); + } let matchedGroups = this.currentContentMediaUsageBaseQuery() .select(["s.collection_slug as collection_slug", "s.content_id as content_id"]) .where("u.media_id", "=", mediaId) @@ -567,7 +570,21 @@ export class MediaUsageRepository { .orderBy("content_id", "asc") .limit(limit), ) - .selectFrom("page_groups as page") + .with("entry_state", (db) => + db + .selectFrom("page_groups as page") + .crossJoin("_emdash_media_usage_sources as state") + .select(["page.collection_slug", "page.content_id"]) + .select((eb) => + eb.fn.max("state.content_deleted_at").as("entry_deleted_at"), + ) + .whereRef("page.collection_slug", "=", "state.collection_slug") + .whereRef("page.content_id", "=", "state.content_id") + .where("state.source_type", "=", "content") + .where("state.source_variant", "in", ["columns", "draft_overlay"]) + .groupBy(["page.collection_slug", "page.content_id"]), + ) + .selectFrom("entry_state as page") .crossJoin("_emdash_media_usage_sources as s") .crossJoin("_emdash_media_usage as u") .innerJoin("_emdash_collections as collection", "collection.slug", "s.collection_slug") @@ -576,19 +593,7 @@ export class MediaUsageRepository { .whereRef("s.source_key", "=", "u.source_key") .whereRef("s.current_generation", "=", "u.generation") .select(currentUsageSelect) - .select( - sql`( - SELECT deleted_source.content_deleted_at - FROM _emdash_media_usage_sources AS deleted_source - WHERE deleted_source.source_type = 'content' - AND deleted_source.collection_slug = s.collection_slug - AND deleted_source.content_id = s.content_id - AND deleted_source.source_variant IN ('columns', 'draft_overlay') - AND deleted_source.content_deleted_at IS NOT NULL - ORDER BY deleted_source.content_deleted_at DESC - LIMIT 1 - )`.as("entry_deleted_at"), - ) + .select("page.entry_deleted_at") .select( sql`CASE WHEN (SELECT COUNT(*) FROM matched_groups) > ${limit} THEN 1 diff --git a/packages/core/tests/integration/database/media-usage-read-plan.test.ts b/packages/core/tests/integration/database/media-usage-read-plan.test.ts index b0a82951c9..36c813cc76 100644 --- a/packages/core/tests/integration/database/media-usage-read-plan.test.ts +++ b/packages/core/tests/integration/database/media-usage-read-plan.test.ts @@ -110,6 +110,8 @@ it("loads coverage and one grouped page in one statement each", async () => { const groupedQueries = captured.filter((query) => query.sql.includes("matched_groups")); expect(coverageQueries).toHaveLength(1); expect(groupedQueries).toHaveLength(1); + expect(groupedQueries[0]!.sql).toContain("entry_state"); + expect(groupedQueries[0]!.sql).not.toContain("deleted_source"); const plan = explain(groupedQueries[0]!); expect(firstSourceOrUsageAccess(plan)).toMatch( diff --git a/packages/core/tests/integration/database/media-usage-read-repository.test.ts b/packages/core/tests/integration/database/media-usage-read-repository.test.ts index 59c975de9e..b93ab6759e 100644 --- a/packages/core/tests/integration/database/media-usage-read-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-read-repository.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it } from "vitest"; import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; -import { InvalidCursorError } from "../../../src/database/repositories/types.js"; +import { encodeCursor, InvalidCursorError } from "../../../src/database/repositories/types.js"; import { buildContentMediaUsageSourceKey, type MediaUsageContentSourceVariant, @@ -185,6 +185,21 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { }); }); + it("returns trashed entries in details while excluding them from active counts", async () => { + await registerCollection(ctx, "posts"); + const deletedAt = "2026-01-01T00:00:00.000Z"; + await repo.replaceSource(contentSource("trash", "columns", { contentDeletedAt: deletedAt }), [ + occurrence("hero", "media-trash"), + ]); + + const counts = await repo.findActiveEntryCountsByMediaIds(["media-trash"]); + const page = await repo.findCurrentEntryUsagePageByMediaId("media-trash"); + + expect(counts.get("media-trash")).toBe(0); + expect(page.items.map(entryIdentity)).toEqual([["posts", "trash"]]); + expect(page.items[0]?.contentDeletedAt).toBe(deletedAt); + }); + it("returns zero-filled counts across multiple D1-sized batches", async () => { const mediaIds = Array.from({ length: SQL_BATCH_SIZE + 1 }, (_, index) => `media-${index}`); @@ -287,6 +302,15 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { ).rejects.toBeInstanceOf(InvalidCursorError); }); + it.each([encodeCursor("", "entry-a"), encodeCursor("posts", "")])( + "rejects structurally empty entry-group cursor components", + async (cursor) => { + await expect( + repo.findCurrentEntryUsagePageByMediaId("media-shared", { cursor }), + ).rejects.toBeInstanceOf(InvalidCursorError); + }, + ); + it("defaults non-finite entry-group limits", async () => { await registerCollection(ctx, "posts"); await repo.replaceSource(contentSource("entry-a", "columns"), [ diff --git a/packages/core/tests/unit/api/media-usage-read-route.test.ts b/packages/core/tests/unit/api/media-usage-read-route.test.ts new file mode 100644 index 0000000000..2507e79d59 --- /dev/null +++ b/packages/core/tests/unit/api/media-usage-read-route.test.ts @@ -0,0 +1,523 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { handleMediaUsageDetails } from "../../../src/api/handlers/media-usage.js"; +import { + mediaUsageDetailsQuery, + mediaUsageDetailsResponseSchema, +} from "../../../src/api/schemas/index.js"; +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import * as usageRoute from "../../../src/astro/routes/api/media/[id]/usage.js"; +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { MediaRepository, type MediaItem } from "../../../src/database/repositories/media.js"; +import { encodeCursor } from "../../../src/database/repositories/types.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { + CONTENT_MEDIA_USAGE_ADAPTER_ID, + CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, +} from "../../../src/media/usage/content-refresh.js"; +import { CONTENT_SOURCE_SCHEMA_VERSION } from "../../../src/media/usage/content-snapshots.js"; +import { + buildContentMediaUsageSourceKey, + type MediaUsageContentSourceVariant, +} from "../../../src/media/usage/source-key.js"; + +type RouteContext = Parameters[0]; + +interface SuccessBody { + data: T; +} + +interface ErrorBody { + error: { code: string; message: string }; +} + +describe("media usage detail schemas", () => { + it("defaults and validates entry-group pagination", () => { + expect(mediaUsageDetailsQuery.parse({})).toEqual({ limit: 50 }); + expect(mediaUsageDetailsQuery.parse({ limit: "100", cursor: "cursor" })).toEqual({ + limit: 100, + cursor: "cursor", + }); + for (const input of [ + { limit: "0" }, + { limit: "101" }, + { limit: "1.5" }, + { cursor: "" }, + { cursor: "x".repeat(2049) }, + ]) { + expect(mediaUsageDetailsQuery.safeParse(input).success).toBe(false); + } + }); + + it("accepts the narrow public response shape", () => { + expect( + mediaUsageDetailsResponseSchema.parse({ + items: [ + { + collection: "posts", + contentId: "entry-1", + title: null, + slug: "entry-1", + locale: "en", + status: "draft", + scheduledAt: null, + deletedAt: null, + sources: [ + { + variant: "draft_overlay", + occurrences: [ + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "unknown", + }, + ], + }, + ], + }, + ], + coverage: { scope: "all_content_collections", status: "partial" }, + }), + ).toEqual(expect.objectContaining({ items: [expect.objectContaining({ title: null })] })); + }); +}); + +describe("media usage details handler and route", () => { + let sqlite: Database.Database; + let db: Kysely; + let queries: string[]; + let usedMedia: MediaItem; + let unreferencedMedia: MediaItem; + + beforeEach(async () => { + queries = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") queries.push(event.query.sql); + }, + }); + await runMigrations(db); + + const mediaRepository = new MediaRepository(db); + usedMedia = await mediaRepository.create({ + filename: "used.png", + mimeType: "image/png", + storageKey: "used.png", + }); + unreferencedMedia = await mediaRepository.create({ + filename: "unreferenced.png", + mimeType: "image/png", + storageKey: "unreferenced.png", + }); + + await db + .insertInto("_emdash_collections") + .values([ + { id: "collection-pages", slug: "pages", label: "Pages", has_seo: 0 }, + { id: "collection-posts", slug: "posts", label: "Posts", has_seo: 0 }, + ]) + .execute(); + const usageRepository = new MediaUsageRepository(db); + for (const scopeKey of ["pages", "posts"]) { + await usageRepository.upsertIndexStatus({ + adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID, + scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, + scopeKey, + status: "complete", + schemaVersion: CONTENT_SOURCE_SCHEMA_VERSION, + }); + } + + await usageRepository.replaceSource( + contentSource("entry-page", "columns", { + collectionSlug: "pages", + contentTitle: "Page title", + contentSlug: "page-slug", + contentStatus: "draft", + }), + [occurrence("hero", "hero", "image_field", usedMedia.id)], + ); + await usageRepository.replaceSource( + contentSource("entry-post", "columns", { + contentTitle: "Columns title", + contentSlug: "columns-slug", + contentStatus: "published", + }), + [ + occurrence("hero", "hero", "image_field", usedMedia.id), + occurrence("body", "body.file", "file_field", usedMedia.id, 1), + ], + ); + const overlay = contentSource("entry-post", "draft_overlay", { + contentTitle: null, + contentSlug: "overlay-slug", + locale: "fr", + contentStatus: "scheduled", + contentScheduledAt: "2026-08-01T00:00:00.000Z", + contentDeletedAt: "2026-07-01T00:00:00.000Z", + }); + await usageRepository.replaceSource(overlay, [ + occurrence("content", "content[0]", "portable_text_image", usedMedia.id), + occurrence("future", "future.path", "image_field", usedMedia.id, 2), + ]); + await db + .updateTable("_emdash_media_usage") + .set({ reference_type: "future_reference_type" }) + .where("source_key", "=", overlay.sourceKey) + .where("field_slug", "=", "future") + .execute(); + queries = []; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.destroy(); + }); + + it("registers a GET-only local-media usage route", () => { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => routes.push(route)); + const matches = routes.filter((route) => route.pattern === "/_emdash/api/media/[id]/usage"); + + expect(matches).toHaveLength(1); + expect(matches[0]?.entrypoint).toContain("api/media/_id_/usage"); + expect(usageRoute.prerender).toBe(false); + expect(Object.keys(usageRoute).toSorted()).toEqual(["GET", "prerender"]); + }); + + it("maps preferred metadata, conservative deletion, and narrow nested DTOs", async () => { + const result = await handleMediaUsageDetails(db, usedMedia.id, { limit: 10 }); + + expect(result).toEqual({ + success: true, + data: { + items: [ + { + collection: "pages", + contentId: "entry-page", + title: "Page title", + slug: "page-slug", + locale: "en", + status: "draft", + scheduledAt: null, + deletedAt: null, + sources: [ + { + variant: "columns", + occurrences: [ + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "image_field", + }, + ], + }, + ], + }, + { + collection: "posts", + contentId: "entry-post", + title: null, + slug: "overlay-slug", + locale: "fr", + status: "scheduled", + scheduledAt: "2026-08-01T00:00:00.000Z", + deletedAt: "2026-07-01T00:00:00.000Z", + sources: [ + { + variant: "columns", + occurrences: [ + { + fieldSlug: "body", + fieldPath: "body.file", + occurrenceIndex: 1, + referenceType: "file_field", + }, + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "image_field", + }, + ], + }, + { + variant: "draft_overlay", + occurrences: [ + { + fieldSlug: "content", + fieldPath: "content[0]", + occurrenceIndex: 0, + referenceType: "portable_text_image", + }, + { + fieldSlug: "future", + fieldPath: "future.path", + occurrenceIndex: 2, + referenceType: "unknown", + }, + ], + }, + ], + }, + ], + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }); + expect(queries).toHaveLength(3); + const serialized = JSON.stringify(result); + for (const forbidden of [ + "sourceKey", + "generation", + "revisionId", + "translationGroup", + "providerAssetId", + "sourceCompleteness", + "lastErrorCode", + ]) { + expect(serialized).not.toContain(`"${forbidden}"`); + } + expect(serialized).not.toContain("count"); + }); + + it("returns empty details plus coverage for an existing unreferenced media item", async () => { + const result = await handleMediaUsageDetails(db, unreferencedMedia.id, {}); + + expect(result).toEqual({ + success: true, + data: { + items: [], + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }); + expect(queries).toHaveLength(3); + }); + + it("returns NOT_FOUND before coverage or grouped usage reads", async () => { + const result = await handleMediaUsageDetails(db, "missing-media", {}); + + expect(result).toEqual({ + success: false, + error: { code: "NOT_FOUND", message: "Media item not found: missing-media" }, + }); + expect(queries).toHaveLength(1); + expect(queries[0]).not.toContain("_emdash_media_usage"); + }); + + it("maps malformed and structurally invalid cursors to INVALID_CURSOR", async () => { + for (const cursor of ["not-a-cursor", encodeCursor("", "entry-post")]) { + queries = []; + const result = await handleMediaUsageDetails(db, usedMedia.id, { cursor }); + + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: "INVALID_CURSOR" }), + }), + ); + expect(queries).toHaveLength(2); + } + }); + + it("returns generic read errors without leaking database failures", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + await db.schema.dropTable("_emdash_media_usage").execute(); + queries = []; + + const result = await handleMediaUsageDetails(db, usedMedia.id, {}); + + expect(result).toEqual({ + success: false, + error: { code: "MEDIA_USAGE_READ_ERROR", message: "Failed to read media usage" }, + }); + expect(JSON.stringify(result)).not.toContain("no such table"); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it("pages entry groups through the route within three queries per page", async () => { + const firstResponse = await invokeRoute({ + id: usedMedia.id, + query: "?limit=1", + role: Role.CONTRIBUTOR, + }); + const first = await readSuccess<{ + items: Array<{ collection: string; contentId: string }>; + nextCursor?: string; + }>(firstResponse); + + expect(first.items).toEqual([ + expect.objectContaining({ collection: "pages", contentId: "entry-page" }), + ]); + expect(first.nextCursor).toBeTypeOf("string"); + expect(queries).toHaveLength(3); + queries = []; + + const secondResponse = await invokeRoute({ + id: usedMedia.id, + query: `?limit=1&cursor=${encodeURIComponent(first.nextCursor!)}`, + role: Role.CONTRIBUTOR, + }); + const second = await readSuccess<{ + items: Array<{ collection: string; contentId: string }>; + nextCursor?: string; + }>(secondResponse); + + expect(second.items).toEqual([ + expect.objectContaining({ collection: "posts", contentId: "entry-post" }), + ]); + expect(second.nextCursor).toBeUndefined(); + expect(queries).toHaveLength(3); + }); + + it.each([ + ["missing ID", undefined, "", Role.CONTRIBUTOR, undefined, 400, "INVALID_REQUEST"], + ["empty cursor", "media", "?cursor=", Role.CONTRIBUTOR, undefined, 400, "VALIDATION_ERROR"], + [ + "oversized cursor", + "media", + `?cursor=${"x".repeat(2049)}`, + Role.CONTRIBUTOR, + undefined, + 400, + "VALIDATION_ERROR", + ], + ["invalid limit", "media", "?limit=0", Role.CONTRIBUTOR, undefined, 400, "VALIDATION_ERROR"], + ] as const)( + "returns stable validation errors for %s", + async (_name, id, query, role, tokenScopes, status, code) => { + const response = await invokeRoute({ id, query, role, tokenScopes }); + + expect(response.status).toBe(status); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code }) }), + ); + expect(queries).toHaveLength(0); + }, + ); + + it("returns INVALID_CURSOR for an authorized malformed cursor", async () => { + const response = await invokeRoute({ + id: usedMedia.id, + query: "?cursor=not-a-cursor", + role: Role.CONTRIBUTOR, + }); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "INVALID_CURSOR" }) }), + ); + expect(queries).toHaveLength(2); + }); + + it.each([ + ["anonymous caller", null, undefined, 401, "UNAUTHORIZED"], + ["subscriber session", Role.SUBSCRIBER, undefined, 403, "FORBIDDEN"], + ["media-read token", Role.CONTRIBUTOR, ["media:read"], 403, "INSUFFICIENT_SCOPE"], + ["admin token owned by subscriber", Role.SUBSCRIBER, ["admin"], 403, "FORBIDDEN"], + ] as const)( + "denies a %s before input validation or database reads", + async (_name, role, tokenScopes, status, code) => { + const response = await invokeRoute({ + id: "missing-media", + query: "?cursor=not-a-cursor", + role, + tokenScopes, + }); + + expect(response.status).toBe(status); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code }) }), + ); + expect(queries).toHaveLength(0); + }, + ); + + it("returns NOT_CONFIGURED after authorization", async () => { + const request = new Request(`http://localhost/_emdash/api/media/${usedMedia.id}/usage`); + const response = await usageRoute.GET({ + params: { id: usedMedia.id }, + request, + locals: { emdash: {}, user: { id: "user-1", role: Role.CONTRIBUTOR } }, + } as RouteContext); + + expect(response.status).toBe(500); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "NOT_CONFIGURED" }) }), + ); + }); + + async function invokeRoute(input: { + id?: string; + query: string; + role: RoleLevel | null; + tokenScopes?: readonly string[]; + }): Promise { + const request = new Request( + `http://localhost/_emdash/api/media/${input.id ?? "missing"}/usage${input.query}`, + ); + return usageRoute.GET({ + params: { id: input.id }, + request, + locals: { + emdash: { db }, + user: input.role === null ? null : { id: "user-1", role: input.role }, + tokenScopes: input.tokenScopes ? [...input.tokenScopes] : undefined, + }, + } as RouteContext) as Promise; + } +}); + +async function readSuccess(response: Response): Promise { + return ((await response.json()) as SuccessBody).data; +} + +function contentSource( + contentId: string, + variant: MediaUsageContentSourceVariant, + overrides: Partial[0]> = {}, +): Parameters[0] { + const collectionSlug = overrides.collectionSlug ?? "posts"; + return { + sourceKey: buildContentMediaUsageSourceKey({ + collectionSlug, + contentId, + sourceVariant: variant, + }), + sourceType: "content", + collectionSlug, + contentId, + sourceVariant: variant, + locale: "en", + translationGroup: `tg-${contentId}`, + contentSlug: `slug-${contentId}`, + contentTitle: `Title ${contentId}`, + contentStatus: variant === "columns" ? "published" : "draft", + ...overrides, + }; +} + +function occurrence( + fieldSlug: string, + fieldPath: string, + referenceType: "image_field" | "file_field" | "portable_text_image", + mediaId: string, + occurrenceIndex = 0, +): Parameters[1][number] { + return { + fieldSlug, + fieldPath, + occurrenceIndex, + referenceType, + mediaId, + provider: "local", + providerAssetId: mediaId, + }; +} diff --git a/packages/core/tests/unit/astro/media-usage-read-auth.test.ts b/packages/core/tests/unit/astro/media-usage-read-auth.test.ts new file mode 100644 index 0000000000..3589e80504 --- /dev/null +++ b/packages/core/tests/unit/astro/media-usage-read-auth.test.ts @@ -0,0 +1,154 @@ +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); + +vi.mock( + "virtual:emdash/auth", + () => ({ + authenticate: vi.fn(), + }), + { virtual: true }, +); + +vi.mock( + "virtual:emdash/config", + () => ({ + default: {}, + }), + { virtual: true }, +); + +import { handleApiTokenCreate } from "../../../src/api/handlers/api-tokens.js"; +import { onRequest as authMiddleware } from "../../../src/astro/middleware/auth.js"; +import { GET } from "../../../src/astro/routes/api/media/[id]/usage.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +type AuthContext = Parameters[0]; + +interface ApiErrorBody { + error: { + code: string; + message: string; + }; +} + +describe("media usage detail auth middleware", () => { + let db: Kysely | undefined; + let mediaId: string; + + beforeEach(async () => { + db = await setupTestDatabase(); + await db + .insertInto("users") + .values([ + { + id: "contributor-1", + email: "contributor@example.com", + name: "Contributor", + role: Role.CONTRIBUTOR, + email_verified: 1, + }, + { + id: "subscriber-1", + email: "subscriber@example.com", + name: "Subscriber", + role: Role.SUBSCRIBER, + email_verified: 1, + }, + ]) + .execute(); + mediaId = ( + await new MediaRepository(db).create({ + filename: "usage.png", + mimeType: "image/png", + storageKey: "usage.png", + }) + ).id; + }); + + afterEach(async () => { + if (db) await teardownTestDatabase(db); + db = undefined; + }); + + it("allows an admin-scoped token for a contributor to read usage details", async () => { + const response = await invokeThroughAuth(await createToken("contributor-1", ["admin"])); + + expect(response.status).toBe(200); + expect((await response.json()) as { data: { items: unknown[] } }).toEqual( + expect.objectContaining({ data: expect.objectContaining({ items: [] }) }), + ); + }); + + it.each([["media:read"], ["content:read_drafts"], ["media:read", "content:read_drafts"]])( + "rejects a contributor token without admin scope: %j", + async (...scopes) => { + const response = await invokeThroughAuth(await createToken("contributor-1", scopes)); + + await expectError(response, 403, "INSUFFICIENT_SCOPE"); + }, + ); + + it("rejects an admin-scoped token whose user lacks read permissions", async () => { + const response = await invokeThroughAuth(await createToken("subscriber-1", ["admin"])); + + await expectError(response, 403, "FORBIDDEN"); + }); + + it("rejects an invalid token before the route runs", async () => { + const context = usageContext("ec_pat_invalid"); + const next = vi.fn(async () => new Response("should not run")); + + const response = await authMiddleware(context, next); + + expect(next).not.toHaveBeenCalled(); + await expectError(response, 401, "INVALID_TOKEN"); + }); + + async function createToken(userId: string, scopes: string[]): Promise { + const result = await handleApiTokenCreate(db!, userId, { + name: `${userId} token`, + scopes, + }); + if (!result.success) { + throw new Error(`Failed to create token: ${result.error.message}`); + } + return result.data.token; + } + + async function invokeThroughAuth(token: string): Promise { + const context = usageContext(token); + return authMiddleware(context, () => GET(context as never)); + } + + function usageContext(token: string): AuthContext { + const request = new Request(`http://localhost/_emdash/api/media/${mediaId}/usage`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return { + params: { id: mediaId }, + request, + url: new URL(request.url), + locals: { emdash: { db: db! } }, + redirect: vi.fn(), + session: { + get: vi.fn(), + set: vi.fn(), + destroy: vi.fn(), + }, + } as unknown as AuthContext; + } +}); + +async function expectError(response: Response, status: number, code: string): Promise { + expect(response.status).toBe(status); + const body = (await response.json()) as ApiErrorBody; + expect(body.error.code).toBe(code); +} From 26b6ac2b8f171880698c5b5594fe4933b0a456d5 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Thu, 16 Jul 2026 14:17:51 +0100 Subject: [PATCH 4/8] test(core): consolidate media usage read coverage --- .../unit/api/media-usage-read-route.test.ts | 75 ++++--------------- .../unit/api/media-usage-summary.test.ts | 15 +--- .../unit/astro/media-usage-read-auth.test.ts | 36 ++++++--- 3 files changed, 42 insertions(+), 84 deletions(-) diff --git a/packages/core/tests/unit/api/media-usage-read-route.test.ts b/packages/core/tests/unit/api/media-usage-read-route.test.ts index 2507e79d59..98eaf7bd45 100644 --- a/packages/core/tests/unit/api/media-usage-read-route.test.ts +++ b/packages/core/tests/unit/api/media-usage-read-route.test.ts @@ -13,7 +13,6 @@ import * as usageRoute from "../../../src/astro/routes/api/media/[id]/usage.js"; import { runMigrations } from "../../../src/database/migrations/runner.js"; import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; import { MediaRepository, type MediaItem } from "../../../src/database/repositories/media.js"; -import { encodeCursor } from "../../../src/database/repositories/types.js"; import type { Database as DatabaseSchema } from "../../../src/database/types.js"; import { CONTENT_MEDIA_USAGE_ADAPTER_ID, @@ -52,39 +51,6 @@ describe("media usage detail schemas", () => { expect(mediaUsageDetailsQuery.safeParse(input).success).toBe(false); } }); - - it("accepts the narrow public response shape", () => { - expect( - mediaUsageDetailsResponseSchema.parse({ - items: [ - { - collection: "posts", - contentId: "entry-1", - title: null, - slug: "entry-1", - locale: "en", - status: "draft", - scheduledAt: null, - deletedAt: null, - sources: [ - { - variant: "draft_overlay", - occurrences: [ - { - fieldSlug: "hero", - fieldPath: "hero", - occurrenceIndex: 0, - referenceType: "unknown", - }, - ], - }, - ], - }, - ], - coverage: { scope: "all_content_collections", status: "partial" }, - }), - ).toEqual(expect.objectContaining({ items: [expect.objectContaining({ title: null })] })); - }); }); describe("media usage details handler and route", () => { @@ -188,8 +154,10 @@ describe("media usage details handler and route", () => { expect(matches).toHaveLength(1); expect(matches[0]?.entrypoint).toContain("api/media/_id_/usage"); - expect(usageRoute.prerender).toBe(false); - expect(Object.keys(usageRoute).toSorted()).toEqual(["GET", "prerender"]); + expect(usageRoute.GET).toBeTypeOf("function"); + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + expect(usageRoute).not.toHaveProperty(method); + } }); it("maps preferred metadata, conservative deletion, and narrow nested DTOs", async () => { @@ -272,6 +240,8 @@ describe("media usage details handler and route", () => { coverage: { scope: "all_content_collections", status: "complete" }, }, }); + if (!result.success) throw new Error("Expected media usage details"); + expect(mediaUsageDetailsResponseSchema.parse(result.data)).toEqual(result.data); expect(queries).toHaveLength(3); const serialized = JSON.stringify(result); for (const forbidden of [ @@ -312,19 +282,16 @@ describe("media usage details handler and route", () => { expect(queries[0]).not.toContain("_emdash_media_usage"); }); - it("maps malformed and structurally invalid cursors to INVALID_CURSOR", async () => { - for (const cursor of ["not-a-cursor", encodeCursor("", "entry-post")]) { - queries = []; - const result = await handleMediaUsageDetails(db, usedMedia.id, { cursor }); + it("maps malformed cursors to INVALID_CURSOR", async () => { + const result = await handleMediaUsageDetails(db, usedMedia.id, { cursor: "not-a-cursor" }); - expect(result).toEqual( - expect.objectContaining({ - success: false, - error: expect.objectContaining({ code: "INVALID_CURSOR" }), - }), - ); - expect(queries).toHaveLength(2); - } + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: "INVALID_CURSOR" }), + }), + ); + expect(queries).toHaveLength(2); }); it("returns generic read errors without leaking database failures", async () => { @@ -380,16 +347,6 @@ describe("media usage details handler and route", () => { it.each([ ["missing ID", undefined, "", Role.CONTRIBUTOR, undefined, 400, "INVALID_REQUEST"], ["empty cursor", "media", "?cursor=", Role.CONTRIBUTOR, undefined, 400, "VALIDATION_ERROR"], - [ - "oversized cursor", - "media", - `?cursor=${"x".repeat(2049)}`, - Role.CONTRIBUTOR, - undefined, - 400, - "VALIDATION_ERROR", - ], - ["invalid limit", "media", "?limit=0", Role.CONTRIBUTOR, undefined, 400, "VALIDATION_ERROR"], ] as const)( "returns stable validation errors for %s", async (_name, id, query, role, tokenScopes, status, code) => { @@ -414,14 +371,12 @@ describe("media usage details handler and route", () => { expect((await response.json()) as ErrorBody).toEqual( expect.objectContaining({ error: expect.objectContaining({ code: "INVALID_CURSOR" }) }), ); - expect(queries).toHaveLength(2); }); it.each([ ["anonymous caller", null, undefined, 401, "UNAUTHORIZED"], ["subscriber session", Role.SUBSCRIBER, undefined, 403, "FORBIDDEN"], ["media-read token", Role.CONTRIBUTOR, ["media:read"], 403, "INSUFFICIENT_SCOPE"], - ["admin token owned by subscriber", Role.SUBSCRIBER, ["admin"], 403, "FORBIDDEN"], ] as const)( "denies a %s before input validation or database reads", async (_name, role, tokenScopes, status, code) => { diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts index 933e9bbd02..19311f2125 100644 --- a/packages/core/tests/unit/api/media-usage-summary.test.ts +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -323,8 +323,8 @@ describe("media usage summary handler and routes", () => { expect(queries).toHaveLength(3); }); - it.each(["0", "true", "false"])("rejects includeUsage=%s", async (includeUsage) => { - const response = await invokeList(`?includeUsage=${includeUsage}`, Role.CONTRIBUTOR); + it("maps an invalid list includeUsage value to a validation error", async () => { + const response = await invokeList("?includeUsage=0", Role.CONTRIBUTOR); expect(response.status).toBe(400); expect((await response.json()) as ErrorBody).toEqual( @@ -365,7 +365,7 @@ describe("media usage summary handler and routes", () => { expect(queries.some((query) => query.includes("visible_entries"))).toBe(false); }); - it("applies token count authorization to media get", async () => { + it("redacts media get counts for a media-read token", async () => { const redactedResponse = await invokeGet(usedMedia.id, "?includeUsage=1", Role.ADMIN, [ "media:read", ]); @@ -373,15 +373,6 @@ describe("media usage summary handler and routes", () => { expect(redacted.item.usage?.count).toBeNull(); expect(queries).toHaveLength(2); - queries = []; - - const numericResponse = await invokeGet(usedMedia.id, "?includeUsage=1", Role.CONTRIBUTOR, [ - "admin", - ]); - const numeric = await readSuccess<{ item: MediaGetBodyItem }>(numericResponse); - - expect(numeric.item.usage?.count).toBe(1); - expect(queries).toHaveLength(3); }); it("uses the last duplicate includeUsage value and ignores unknown get query keys", async () => { diff --git a/packages/core/tests/unit/astro/media-usage-read-auth.test.ts b/packages/core/tests/unit/astro/media-usage-read-auth.test.ts index 3589e80504..9f4407df5a 100644 --- a/packages/core/tests/unit/astro/media-usage-read-auth.test.ts +++ b/packages/core/tests/unit/astro/media-usage-read-auth.test.ts @@ -86,14 +86,26 @@ describe("media usage detail auth middleware", () => { ); }); - it.each([["media:read"], ["content:read_drafts"], ["media:read", "content:read_drafts"]])( - "rejects a contributor token without admin scope: %j", - async (...scopes) => { - const response = await invokeThroughAuth(await createToken("contributor-1", scopes)); + it("lets a media-read token reach the route before rejecting its missing admin scope", async () => { + const context = usageContext(await createToken("contributor-1", ["media:read"])); + const next = vi.fn(() => GET(context as never)); - await expectError(response, 403, "INSUFFICIENT_SCOPE"); - }, - ); + const response = await authMiddleware(context, next); + + expect(next).toHaveBeenCalledOnce(); + expect(context.locals.tokenScopes).toEqual(["media:read"]); + await expectError(response, 403, "INSUFFICIENT_SCOPE"); + }); + + it("rejects a token without media-read scope before the route runs", async () => { + const context = usageContext(await createToken("contributor-1", ["content:read"])); + const next = vi.fn(async () => new Response("should not run")); + + const response = await authMiddleware(context, next); + + expect(next).not.toHaveBeenCalled(); + await expectError(response, 403, "INSUFFICIENT_SCOPE"); + }); it("rejects an admin-scoped token whose user lacks read permissions", async () => { const response = await invokeThroughAuth(await createToken("subscriber-1", ["admin"])); @@ -101,14 +113,14 @@ describe("media usage detail auth middleware", () => { await expectError(response, 403, "FORBIDDEN"); }); - it("rejects an invalid token before the route runs", async () => { - const context = usageContext("ec_pat_invalid"); + it("rejects an unauthenticated request before the route runs", async () => { + const context = usageContext(); const next = vi.fn(async () => new Response("should not run")); const response = await authMiddleware(context, next); expect(next).not.toHaveBeenCalled(); - await expectError(response, 401, "INVALID_TOKEN"); + await expectError(response, 401, "NOT_AUTHENTICATED"); }); async function createToken(userId: string, scopes: string[]): Promise { @@ -127,9 +139,9 @@ describe("media usage detail auth middleware", () => { return authMiddleware(context, () => GET(context as never)); } - function usageContext(token: string): AuthContext { + function usageContext(token?: string): AuthContext { const request = new Request(`http://localhost/_emdash/api/media/${mediaId}/usage`, { - headers: { Authorization: `Bearer ${token}` }, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); return { From d111ca5dfcbf4a951415e14b495db7386d591bd8 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Thu, 16 Jul 2026 15:11:12 +0100 Subject: [PATCH 5/8] perf(core): remove redundant media usage join --- packages/core/src/database/repositories/media-usage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index f3655015d6..3ebd0fff68 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -587,7 +587,6 @@ export class MediaUsageRepository { .selectFrom("entry_state as page") .crossJoin("_emdash_media_usage_sources as s") .crossJoin("_emdash_media_usage as u") - .innerJoin("_emdash_collections as collection", "collection.slug", "s.collection_slug") .whereRef("page.collection_slug", "=", "s.collection_slug") .whereRef("page.content_id", "=", "s.content_id") .whereRef("s.source_key", "=", "u.source_key") From 9d9813e53224e08024bc1a6cca9fb5d3db7e092a Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 17 Jul 2026 11:39:43 +0100 Subject: [PATCH 6/8] feat(core): add media usage client methods --- packages/core/src/client/index.ts | 86 +++++++- .../core/tests/unit/client/client.test.ts | 204 ++++++++++++++++++ 2 files changed, 288 insertions(+), 2 deletions(-) diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 4930b158f7..47c6134eaf 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -127,6 +127,62 @@ export interface Field { sortOrder?: number; } +/** Aggregate trust state for media usage reads */ +export type MediaUsageCoverageStatus = + | "complete" + | "never" + | "running" + | "partial" + | "failed" + | "stale" + | "unknown"; + +/** Aggregate media usage coverage across all content collections */ +export interface MediaUsageCoverage { + scope: "all_content_collections"; + status: MediaUsageCoverageStatus; +} + +/** Coverage-aware usage count for a media item */ +export interface MediaUsageSummary { + count: number | null; + coverage: MediaUsageCoverage; +} + +/** One indexed media reference within a content source */ +export interface MediaUsageOccurrenceDetail { + fieldSlug: string; + fieldPath: string; + occurrenceIndex: number; + referenceType: "image_field" | "file_field" | "portable_text_image" | "unknown"; +} + +/** Indexed references from one visible content source */ +export interface MediaUsageSourceDetail { + variant: "columns" | "draft_overlay"; + occurrences: MediaUsageOccurrenceDetail[]; +} + +/** One content entry that references a media item */ +export interface MediaUsageEntryDetail { + collection: string; + contentId: string; + title: string | null; + slug: string | null; + locale: string | null; + status: string | null; + scheduledAt: string | null; + deletedAt: string | null; + sources: MediaUsageSourceDetail[]; +} + +/** Entry-grouped media usage details */ +export interface MediaUsageDetailsResponse { + items: MediaUsageEntryDetail[]; + nextCursor?: string; + coverage: MediaUsageCoverage; +} + /** Media item */ export interface MediaItem { id: string; @@ -140,6 +196,7 @@ export interface MediaItem { caption?: string; createdAt: string; updatedAt: string; + usage?: MediaUsageSummary; } /** Media usage repair request */ @@ -678,22 +735,47 @@ export class EmDashClient { mimeType?: string; limit?: number; cursor?: string; + includeUsage?: boolean; }): Promise> { const params = new URLSearchParams(); if (options?.mimeType) params.set("mimeType", options.mimeType); if (options?.limit) params.set("limit", String(options.limit)); if (options?.cursor) params.set("cursor", options.cursor); + if (options?.includeUsage === true) params.set("includeUsage", "1"); const qs = params.toString(); return this.request>("GET", `/media${qs ? `?${qs}` : ""}`); } /** Get a single media item */ - async mediaGet(id: string): Promise { - const data = await this.request<{ item: MediaItem }>("GET", `/media/${encodeURIComponent(id)}`); + async mediaGet(id: string, options?: { includeUsage?: boolean }): Promise { + const params = new URLSearchParams(); + if (options?.includeUsage === true) params.set("includeUsage", "1"); + + const qs = params.toString(); + const data = await this.request<{ item: MediaItem }>( + "GET", + `/media/${encodeURIComponent(id)}${qs ? `?${qs}` : ""}`, + ); return data.item; } + /** Get entry-grouped usage details for a media item */ + async mediaGetUsage( + id: string, + options?: { limit?: number; cursor?: string }, + ): Promise { + const params = new URLSearchParams(); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + if (options?.cursor !== undefined) params.set("cursor", options.cursor); + + const qs = params.toString(); + return this.request( + "GET", + `/media/${encodeURIComponent(id)}/usage${qs ? `?${qs}` : ""}`, + ); + } + /** Upload a media file */ async mediaUpload( file: Uint8Array | Blob, diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index d515c72534..eb80abe4cc 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -681,6 +681,210 @@ describe("EmDashClient", () => { }); }); + describe("media usage reads", () => { + it("opts media list into usage summaries with an exact includeUsage value", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ + items: [ + { + id: "media-1", + usage: { + count: null, + coverage: { scope: "all_content_collections", status: "partial" }, + }, + }, + ], + nextCursor: "next-media", + }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaList({ + mimeType: "image/png", + limit: 25, + cursor: "after / one", + includeUsage: true, + }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media"); + expect(Object.fromEntries(capturedUrl?.searchParams ?? [])).toEqual({ + mimeType: "image/png", + limit: "25", + cursor: "after / one", + includeUsage: "1", + }); + expect(result).toEqual({ + items: [ + { + id: "media-1", + usage: { + count: null, + coverage: { scope: "all_content_collections", status: "partial" }, + }, + }, + ], + nextCursor: "next-media", + }); + }); + + it("omits includeUsage from media list when it is false", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ items: [] }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await client.mediaList({ includeUsage: false }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media"); + expect(capturedUrl?.search).toBe(""); + }); + + it("opts media get into a usage summary without changing item unwrapping", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ + item: { + id: "media/one", + usage: { + count: 3, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }, + }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const item = await client.mediaGet("media/one", { includeUsage: true }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media/media%2Fone"); + expect(capturedUrl?.search).toBe("?includeUsage=1"); + expect(item).toEqual({ + id: "media/one", + usage: { + count: 3, + coverage: { scope: "all_content_collections", status: "complete" }, + }, + }); + }); + + it("omits includeUsage from media get when it is false", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ item: { id: "media-1" } }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await client.mediaGet("media-1", { includeUsage: false }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media/media-1"); + expect(capturedUrl?.search).toBe(""); + }); + + it("serializes media usage detail pagination and unwraps grouped details", async () => { + let capturedUrl: URL | undefined; + const response = { + items: [ + { + collection: "posts", + contentId: "post-1", + title: "Launch notes", + slug: "launch-notes", + locale: "en", + status: "published", + scheduledAt: null, + deletedAt: null, + sources: [ + { + variant: "columns", + occurrences: [ + { + fieldSlug: "hero", + fieldPath: "hero", + occurrenceIndex: 0, + referenceType: "image_field", + }, + ], + }, + ], + }, + ], + nextCursor: "next / group", + coverage: { scope: "all_content_collections", status: "complete" }, + }; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse(response); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaGetUsage("media/one", { + limit: 25, + cursor: "after / group", + }); + + expect(capturedUrl?.pathname).toBe("/_emdash/api/media/media%2Fone/usage"); + expect(Object.fromEntries(capturedUrl?.searchParams ?? [])).toEqual({ + limit: "25", + cursor: "after / group", + }); + expect(result).toEqual(response); + }); + + it.each([ + [403, "INSUFFICIENT_SCOPE", "Admin scope required"], + [404, "NOT_FOUND", "Media not found"], + ] as const)( + "throws EmDashApiError for media usage detail HTTP %i responses", + async (status, code, message) => { + const backend = createMockBackend([ + { + method: "GET", + path: "/media/media-1/usage", + handler: () => jsonResponse({ error: { code, message } }, status), + }, + ]); + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await expect(client.mediaGetUsage("media-1")).rejects.toMatchObject({ + name: "EmDashApiError", + status, + code, + message, + }); + }, + ); + }); + describe("mediaRepairUsage()", () => { it("sends collection repair requests with the caller-provided body", async () => { let capturedPath = ""; From f510a1418bce9df587f29bd879adce640fee09ea Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 17 Jul 2026 11:39:52 +0100 Subject: [PATCH 7/8] docs(core): document media usage read APIs --- .changeset/media-usage-reads.md | 5 + docs/src/content/docs/reference/rest-api.mdx | 118 +++++++++++++++++-- packages/core/src/api/openapi/document.ts | 52 +++++++- packages/core/src/api/schemas/media-usage.ts | 8 +- packages/core/src/api/schemas/media.ts | 10 +- packages/core/tests/unit/api/openapi.test.ts | 62 ++++++++++ 6 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 .changeset/media-usage-reads.md diff --git a/.changeset/media-usage-reads.md b/.changeset/media-usage-reads.md new file mode 100644 index 0000000000..a5c9c8d545 --- /dev/null +++ b/.changeset/media-usage-reads.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds coverage-aware media usage counts and read-only Used in details to the REST API and core client. diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 8a20a5cf69..875af32603 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -178,22 +178,23 @@ DELETE /_emdash/api/content/:collection/:id ### List Media ```http -GET /_emdash/api/media +GET /_emdash/api/media?includeUsage=1 ``` #### Parameters -| Parameter | Type | Description | -| ---------- | -------- | ---------------------------- | -| `cursor` | `string` | Pagination cursor | -| `limit` | `number` | Items per page (default: 20) | -| `mimeType` | `string` | Filter by MIME type prefix | +| Parameter | Type | Description | +| -------------- | -------- | -------------------------------------------------------------- | +| `cursor` | `string` | Opaque pagination cursor | +| `limit` | `number` | Items per page, from 1 to 100 (default: 50) | +| `mimeType` | `string` | Filter by one or more comma-separated MIME types | +| `q` | `string` | Case-insensitive filename search | +| `includeUsage` | `1` | Include a coverage-aware `usage` summary on every returned item | #### Response ```json { - "success": true, "data": { "items": [ { @@ -203,8 +204,15 @@ GET /_emdash/api/media "size": 102400, "width": 1920, "height": 1080, - "url": "https://cdn.example.com/photo.jpg", - "createdAt": "2025-01-24T12:00:00Z" + "url": "/_emdash/api/media/file/uploads/photo.jpg", + "createdAt": "2025-01-24T12:00:00Z", + "usage": { + "count": 3, + "coverage": { + "scope": "all_content_collections", + "status": "complete" + } + } } ], "nextCursor": "eyJpZCI6..." @@ -215,9 +223,99 @@ GET /_emdash/api/media ### Get Media ```http -GET /_emdash/api/media/:id +GET /_emdash/api/media/:id?includeUsage=1 +``` + +`includeUsage` is optional on both list and get. Its only accepted value is `1`. When omitted, +the `usage` property is omitted and the server does not run usage queries. + +### Usage Summaries + +`usage.count` is the number of distinct active EmDash content rows or locales whose selected +current indexed source references the media item. Repeated references and multiple source +variants for the same content entry count once. Trashed content does not count. + +A numeric count can reveal draft-like content. It is returned only when a session user has +`content:read_drafts`, or when an API token has `admin` scope and its associated user also has +that permission. Other media readers receive `usage.count: null`; this is a successful redacted +response, not an error. + +Every requested summary includes aggregate coverage for all currently registered content +collections: + +| Status | Meaning | +| ---------- | --------------------------------------------------------------------- | +| `complete` | Every registered collection has current, completed usage coverage | +| `never` | No registered collection has completed an initial usage repair | +| `running` | A usage repair is currently running | +| `partial` | Coverage is mixed or only part of the registered scope was indexed | +| `failed` | Coverage failed across the registered scope | +| `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 +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 + +```http +GET /_emdash/api/media/:id/usage?limit=50&cursor=... +``` + +This endpoint requires `media:read` and `content:read_drafts`. Token-authenticated callers also +require `admin` scope; token scope does not bypass the associated user's permissions. + +`limit` controls content entry groups per page, from 1 to 100 (default: 50). Pagination never +splits the sources or occurrences for one returned entry group. + +```json +{ + "data": { + "items": [ + { + "collection": "posts", + "contentId": "01CONTENT...", + "title": "Launch notes", + "slug": "launch-notes", + "locale": "en", + "status": "published", + "scheduledAt": null, + "deletedAt": null, + "sources": [ + { + "variant": "columns", + "occurrences": [ + { + "fieldSlug": "hero", + "fieldPath": "hero", + "occurrenceIndex": 0, + "referenceType": "image_field" + } + ] + } + ] + } + ], + "nextCursor": "eyJvcmRlclZhbHVlIjoicG9zdHMiLCJpZCI6IjAxLi4uIn0", + "coverage": { + "scope": "all_content_collections", + "status": "complete" + } + } +} ``` +Authorized details include active and trashed entries. A non-null `deletedAt` identifies a +trashed entry. Sources are `columns` or `draft_overlay`; occurrences identify the supported field +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 +custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or +provider-only assets. + ### Create Media ```http diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 206f43b9e8..fc59fbd712 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -37,14 +37,22 @@ import { contentUpdateBody, trashedContentListResponseSchema, } from "../schemas/content.js"; -import { mediaUsageRepairBody, mediaUsageRepairResponseSchema } from "../schemas/media-usage.js"; +import { + mediaUsageDetailsQuery, + mediaUsageDetailsResponseSchema, + mediaUsageRepairBody, + mediaUsageRepairResponseSchema, +} from "../schemas/media-usage.js"; import { DEFAULT_MAX_UPLOAD_SIZE, mediaConfirmBody, mediaConfirmResponseSchema, mediaExistingResponseSchema, + mediaGetQuery, mediaListQuery, + mediaListReadResponseSchema, mediaListResponseSchema, + mediaReadResponseSchema, mediaResponseSchema, mediaUpdateBody, mediaUploadUrlBody, @@ -661,15 +669,19 @@ function buildMediaPaths(maxUploadSize: number) { get: { operationId: "listMedia", summary: "List media items", + description: + "Lists media items. Set `includeUsage=1` to attach coverage-aware advisory usage counts; a count may be null when the caller cannot read draft-derived usage.", tags: ["Media"], requestParams: { query: mediaListQuery }, responses: { "200": { description: "Media list", - content: { [JSON_CONTENT]: { schema: successEnvelope(mediaListResponseSchema) } }, + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaListReadResponseSchema) }, + }, }, ...authErrors, - ...standardErrors(500), + ...standardErrors(400, 500), }, }, }, @@ -677,17 +689,20 @@ function buildMediaPaths(maxUploadSize: number) { get: { operationId: "getMedia", summary: "Get a media item", + description: + "Gets a media item. Set `includeUsage=1` to attach a coverage-aware advisory usage count; the count may be null when the caller cannot read draft-derived usage.", tags: ["Media"], requestParams: { path: z.object({ id: z.string().meta({ description: "Media ID" }) }), + query: mediaGetQuery, }, responses: { "200": { description: "Media item", - content: { [JSON_CONTENT]: { schema: successEnvelope(mediaResponseSchema) } }, + content: { [JSON_CONTENT]: { schema: successEnvelope(mediaReadResponseSchema) } }, }, ...authErrors, - ...standardErrors(404, 500), + ...standardErrors(400, 404, 500), }, }, put: { @@ -724,6 +739,29 @@ function buildMediaPaths(maxUploadSize: number) { }, }, }, + "/_emdash/api/media/{id}/usage": { + get: { + operationId: "getMediaUsage", + summary: "Get media usage details", + description: + "Returns paginated content entry groups whose current indexed sources reference a local media item. Results include aggregate coverage and are advisory during concurrent writes. Requires media read and draft-content read permission; token-authenticated callers also require admin scope.", + tags: ["Media"], + requestParams: { + path: z.object({ id: z.string().meta({ description: "Media ID" }) }), + query: mediaUsageDetailsQuery, + }, + responses: { + "200": { + description: "Entry-grouped media usage details", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaUsageDetailsResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, + }, "/_emdash/api/admin/media-usage/repair": { post: { operationId: "repairMediaUsage", @@ -2417,6 +2455,10 @@ export function generateOpenApiDocument( }, ], components: { + schemas: { + // Preserve the previously published component while media reads use richer schemas. + MediaListResponse: mediaListResponseSchema, + }, securitySchemes: { session: { type: "apiKey", diff --git a/packages/core/src/api/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index 59271be328..cfed0cd28b 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -21,8 +21,12 @@ export const mediaUsageSummarySchema = z .meta({ id: "MediaUsageSummary" }); export const mediaUsageDetailsQuery = z.object({ - cursor: z.string().min(1).max(2048).optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1).max(2048).optional().meta({ + description: "Opaque content-entry-group cursor", + }), + limit: z.coerce.number().int().min(1).max(100).optional().default(50).meta({ + description: "Maximum number of content entry groups to return (1-100, default 50)", + }), }); export const mediaUsageOccurrenceDetailSchema = z diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 10ab3ecf50..cdcfee3598 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -24,12 +24,18 @@ export const mediaListQuery = cursorPaginationQuery mimeType: mimeTypeFilter, /** Case-insensitive filename substring search (also matches extensions). */ q: z.string().trim().min(1).max(200).optional(), - includeUsage: z.literal("1").optional(), + includeUsage: z.literal("1").optional().meta({ + description: "Include a coverage-aware usage summary on each media item", + }), }) .meta({ id: "MediaListQuery" }); export const mediaGetQuery = z - .object({ includeUsage: z.literal("1").optional() }) + .object({ + includeUsage: z.literal("1").optional().meta({ + description: "Include a coverage-aware usage summary on the media item", + }), + }) .meta({ id: "MediaGetQuery" }); export const mediaUpdateBody = z diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index 093ce8e20e..61d4b3ce16 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -32,11 +32,64 @@ describe("OpenAPI document generation", () => { expect(paths).toContain("/_emdash/api/media"); expect(paths).toContain("/_emdash/api/media/{id}"); + expect(paths).toContain("/_emdash/api/media/{id}/usage"); expect(paths).toContain("/_emdash/api/media/upload-url"); expect(paths).toContain("/_emdash/api/media/{id}/confirm"); expect(paths).toContain("/_emdash/api/admin/media-usage/repair"); }); + it("documents media usage summary opt-in parameters and read responses", () => { + const doc = generateOpenApiDocument(); + const list = doc.paths?.["/_emdash/api/media"]?.get as { + parameters?: Array<{ name?: string; in?: string }>; + responses?: Record; + }; + const get = doc.paths?.["/_emdash/api/media/{id}"]?.get as { + parameters?: Array<{ name?: string; in?: string }>; + responses?: Record; + }; + + expect(list.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "includeUsage", in: "query" })]), + ); + expect(get.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "includeUsage", in: "query" })]), + ); + expect(JSON.stringify(list.responses?.["200"])).toContain("MediaListReadResponse"); + expect(JSON.stringify(get.responses?.["200"])).toContain("MediaReadResponse"); + }); + + it("documents the media usage details operation", () => { + const doc = generateOpenApiDocument(); + const get = doc.paths?.["/_emdash/api/media/{id}/usage"]?.get as { + operationId?: string; + tags?: string[]; + parameters?: Array<{ name?: string; in?: string }>; + responses?: Record; + }; + + expect(get.operationId).toBe("getMediaUsage"); + expect(get.tags).toEqual(["Media"]); + expect(get.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "id", in: "path" }), + expect.objectContaining({ name: "limit", in: "query" }), + expect.objectContaining({ name: "cursor", in: "query" }), + ]), + ); + expect(get.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "401": expect.any(Object), + "403": expect.any(Object), + "404": expect.any(Object), + "500": expect.any(Object), + }), + ); + expect(JSON.stringify(get.responses?.["200"])).toContain("MediaUsageDetailsResponse"); + }); + it("documents the media usage repair operation", () => { const doc = generateOpenApiDocument(); const path = doc.paths?.["/_emdash/api/admin/media-usage/repair"]; @@ -224,6 +277,7 @@ describe("OpenAPI document generation", () => { // Media operations expect(operationIds).toContain("listMedia"); expect(operationIds).toContain("getMedia"); + expect(operationIds).toContain("getMediaUsage"); expect(operationIds).toContain("deleteMedia"); expect(operationIds).toContain("getMediaUploadUrl"); expect(operationIds).toContain("repairMediaUsage"); @@ -295,6 +349,14 @@ describe("OpenAPI document generation", () => { // Media schemas expect(schemas).toHaveProperty("MediaItem"); expect(schemas).toHaveProperty("MediaListResponse"); + expect(schemas).toHaveProperty("MediaReadResponse"); + expect(schemas).toHaveProperty("MediaListReadResponse"); + expect(schemas).toHaveProperty("MediaUsageCoverage"); + expect(schemas).toHaveProperty("MediaUsageSummary"); + expect(schemas).toHaveProperty("MediaUsageOccurrenceDetail"); + expect(schemas).toHaveProperty("MediaUsageSourceDetail"); + expect(schemas).toHaveProperty("MediaUsageEntryDetail"); + expect(schemas).toHaveProperty("MediaUsageDetailsResponse"); expect(schemas).toHaveProperty("MediaUsageRepairBody"); expect(schemas).toHaveProperty("MediaUsageRepairResponse"); From 976ed70cea334e577aabc77648a9ff6aee55b936 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 17 Jul 2026 14:11:14 +0100 Subject: [PATCH 8/8] perf(core): skip empty media usage queries --- packages/core/src/api/handlers/media-usage.ts | 2 ++ packages/core/tests/unit/api/media-usage-summary.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/core/src/api/handlers/media-usage.ts b/packages/core/src/api/handlers/media-usage.ts index f9b829d9be..6b7d33c6aa 100644 --- a/packages/core/src/api/handlers/media-usage.ts +++ b/packages/core/src/api/handlers/media-usage.ts @@ -69,6 +69,8 @@ export async function handleMediaUsageSummaries( mediaIds: readonly string[], options: { includeCount: boolean }, ): Promise>> { + if (mediaIds.length === 0) return { success: true, data: {} }; + try { const repository = new MediaUsageRepository(db); const coverage = await loadMediaUsageCoverage(repository); diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts index 19311f2125..6d5f2d65b1 100644 --- a/packages/core/tests/unit/api/media-usage-summary.test.ts +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -198,6 +198,13 @@ describe("media usage summary handler and routes", () => { await db.destroy(); }); + it.each([false, true])("skips usage queries for an empty media page", async (includeCount) => { + const result = await handleMediaUsageSummaries(db, [], { includeCount }); + + expect(result).toEqual({ success: true, data: {} }); + expect(queries).toHaveLength(0); + }); + it("loads coverage once and skips count SQL when counts are redacted", async () => { const result = await handleMediaUsageSummaries(db, [usedMedia.id, unusedMedia.id], { includeCount: false,