diff --git a/.changeset/media-usage-index.md b/.changeset/media-usage-index.md new file mode 100644 index 000000000..8062cb45f --- /dev/null +++ b/.changeset/media-usage-index.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds internal media usage tracking for content entries, preparing the media library for usage views and safer delete workflows. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index e2863c73e..7311e642f 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -9,6 +9,7 @@ import { BylineRepository } from "../../database/repositories/byline.js"; import type { ContentBylineInput } from "../../database/repositories/byline.js"; import { CommentRepository } from "../../database/repositories/comment.js"; import { ContentRepository } from "../../database/repositories/content.js"; +import type { MediaUsageState } from "../../database/repositories/media-usage.js"; import { RedirectRepository } from "../../database/repositories/redirect.js"; import { RevisionRepository } from "../../database/repositories/revision.js"; import { SeoRepository } from "../../database/repositories/seo.js"; @@ -29,12 +30,84 @@ import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js"; +import { deleteContentMediaUsage, replaceContentMediaUsage } from "../../media/usage-index.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; import { validateMediaFields } from "./validate-media-fields.js"; +const MEDIA_USAGE_FIELD_TYPES = new Set(["image", "file", "repeater", "portableText"]); + +function getContentMediaUsageState(item: Pick): MediaUsageState { + return item.status === "published" ? "live" : "draft"; +} + +async function replaceCurrentContentMediaUsage( + db: Kysely, + collection: string, + item: ContentItem, + options: { contentDeletedAt?: string | null } = {}, +): Promise { + const state = getContentMediaUsageState(item); + let data = item.data; + let revisionId: string | null = state === "live" ? item.liveRevisionId : null; + + if (state === "draft" && item.draftRevisionId) { + const revision = await new RevisionRepository(db).findById(item.draftRevisionId); + if (revision) { + data = revision.data; + revisionId = revision.id; + } + } + + await replaceContentMediaUsage( + db, + collection, + item, + state, + data, + revisionId, + options.contentDeletedAt ?? null, + ); +} + +async function replaceCurrentContentMediaUsageSources( + db: Kysely, + collection: string, + item: ContentItem, + options: { contentDeletedAt?: string | null } = {}, +): Promise { + await replaceCurrentContentMediaUsage(db, collection, item, options); + + if (item.status !== "published" || !item.draftRevisionId) return; + const draft = await new RevisionRepository(db).findById(item.draftRevisionId); + if (!draft) return; + + await replaceContentMediaUsage( + db, + collection, + item, + "draft", + draft.data, + draft.id, + options.contentDeletedAt ?? null, + ); +} + +async function getContentDeletedAt( + db: Kysely, + collection: string, + contentId: string, +): Promise { + validateIdentifier(collection, "collection slug"); + const tableName = `ec_${collection}`; + const result = await sql<{ deleted_at: string | null }>` + SELECT deleted_at FROM ${sql.ref(tableName)} WHERE id = ${contentId} + `.execute(db); + return result.rows[0]?.deleted_at ?? null; +} + /** * Narrow a caught error to one carrying a structured `apiError` discriminant. * Used by transaction callbacks that want to surface a specific error code @@ -707,6 +780,8 @@ export async function handleContentCreate( created.seo = { ...SEO_DEFAULTS }; } + await replaceCurrentContentMediaUsage(trx, collection, created); + return created; }); @@ -822,7 +897,9 @@ export async function handleContentUpdate( // Read existing item once for both _rev check and old slug capture const existing = - body._rev || body.slug ? await trxRepo.findById(collection, resolvedId) : null; + body._rev || body.slug !== undefined || body.status !== undefined + ? await trxRepo.findById(collection, resolvedId) + : null; // Validate _rev if provided (optimistic concurrency) if (body._rev) { @@ -885,8 +962,9 @@ export async function handleContentUpdate( // Sync non-translatable fields to sibling locales in the same // translation group. Only runs when i18n is enabled, data was updated, // and the item belongs to a translation group with siblings. + let syncedMediaUsageSiblingIds: string[] = []; if (isI18nEnabled() && body.data && updated.translationGroup) { - await syncNonTranslatableFields( + syncedMediaUsageSiblingIds = await syncNonTranslatableFields( trx, collection, updated.id, @@ -904,6 +982,23 @@ export async function handleContentUpdate( updated.seo = await seoRepo.get(collection, resolvedId); } + if (body.data !== undefined || body.status !== undefined || body.slug !== undefined) { + const previousState = existing ? getContentMediaUsageState(existing) : null; + const nextState = getContentMediaUsageState(updated); + if (previousState && previousState !== nextState) { + await deleteContentMediaUsage(trx, collection, resolvedId, previousState); + } + await replaceCurrentContentMediaUsageSources(trx, collection, updated); + } + for (const siblingId of syncedMediaUsageSiblingIds) { + const sibling = await trxRepo.findByIdIncludingTrashed(collection, siblingId); + if (sibling) { + await replaceCurrentContentMediaUsageSources(trx, collection, sibling, { + contentDeletedAt: await getContentDeletedAt(trx, collection, sibling.id), + }); + } + } + await hydrateBylines(trx, collection, updated); return updated; @@ -1010,6 +1105,7 @@ export async function handleContentDuplicate( } await hydrateBylines(trx, collection, dup); + await replaceCurrentContentMediaUsage(trx, collection, dup); return dup; }); @@ -1051,7 +1147,17 @@ export async function handleContentDelete( const deleted = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - return repo.delete(collection, resolvedId); + const didDelete = await repo.delete(collection, resolvedId); + if (didDelete) { + const item = await repo.findByIdIncludingTrashed(collection, resolvedId); + const deletedAt = await getContentDeletedAt(trx, collection, resolvedId); + if (item) { + await replaceCurrentContentMediaUsageSources(trx, collection, item, { + contentDeletedAt: deletedAt, + }); + } + } + return didDelete; }); if (!deleted) { @@ -1092,7 +1198,9 @@ export async function handleContentRestore( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveIdIncludingTrashed(repo, collection, id)) ?? id; - return repo.restore(collection, resolvedId); + const restored = await repo.restore(collection, resolvedId); + if (restored) await replaceCurrentContentMediaUsageSources(trx, collection, restored); + return restored; }); if (!item) { @@ -1143,6 +1251,7 @@ export async function handleContentPermanentDelete( // Clean up SEO data for permanently deleted content const seoRepo = new SeoRepository(trx); await seoRepo.delete(collection, resolvedId); + await deleteContentMediaUsage(trx, collection, resolvedId); // Clean up comments for permanently deleted content const commentRepo = new CommentRepository(trx); await commentRepo.deleteByContent(collection, resolvedId); @@ -1271,7 +1380,9 @@ export async function handleContentSchedule( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - return repo.schedule(collection, resolvedId, scheduledAt); + const scheduled = await repo.schedule(collection, resolvedId, scheduledAt); + await replaceCurrentContentMediaUsage(trx, collection, scheduled); + return scheduled; }); const hasSeo = await collectionHasSeo(db, collection); @@ -1314,7 +1425,9 @@ export async function handleContentUnschedule( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - return repo.unschedule(collection, resolvedId); + const unscheduled = await repo.unschedule(collection, resolvedId); + await replaceCurrentContentMediaUsage(trx, collection, unscheduled); + return unscheduled; }); const hasSeo = await collectionHasSeo(db, collection); @@ -1362,7 +1475,22 @@ export async function handleContentPublish( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - return repo.publish(collection, resolvedId, options.publishedAt, options.requireScheduledDue); + const published = await repo.publish( + collection, + resolvedId, + options.publishedAt, + options.requireScheduledDue, + ); + await replaceContentMediaUsage( + trx, + collection, + published, + "live", + published.data, + published.liveRevisionId, + ); + await deleteContentMediaUsage(trx, collection, resolvedId, "draft"); + return published; }); const hasSeo = await collectionHasSeo(db, collection); @@ -1419,7 +1547,20 @@ export async function handleContentUnpublish( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - return repo.unpublish(collection, resolvedId); + const unpublished = await repo.unpublish(collection, resolvedId); + await deleteContentMediaUsage(trx, collection, resolvedId, "live"); + const draft = unpublished.draftRevisionId + ? await new RevisionRepository(trx).findById(unpublished.draftRevisionId) + : null; + await replaceContentMediaUsage( + trx, + collection, + unpublished, + "draft", + draft?.data ?? unpublished.data, + draft?.id ?? null, + ); + return unpublished; }); const hasSeo = await collectionHasSeo(db, collection); @@ -1489,7 +1630,13 @@ export async function handleContentDiscardDraft( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - return repo.discardDraft(collection, resolvedId); + const updated = await repo.discardDraft(collection, resolvedId); + if (updated.status === "published") { + await deleteContentMediaUsage(trx, collection, resolvedId, "draft"); + } else { + await replaceCurrentContentMediaUsage(trx, collection, updated); + } + return updated; }); const hasSeo = await collectionHasSeo(db, collection); @@ -1675,7 +1822,7 @@ async function syncNonTranslatableFields( updatedItemId: string, translationGroup: string, data: Record, -): Promise { +): Promise { // Get the collection to find its fields const collection = await trx .selectFrom("_emdash_collections") @@ -1683,18 +1830,18 @@ async function syncNonTranslatableFields( .where("slug", "=", collectionSlug) .executeTakeFirst(); - if (!collection) return; + if (!collection) return []; // Find non-translatable fields that are present in the update data const fields = await trx .selectFrom("_emdash_fields") - .select("slug") + .select(["slug", "type"]) .where("collection_id", "=", collection.id) .where("translatable", "=", 0) .execute(); const nonTranslatableSlugs = fields.map((f) => f.slug); - if (nonTranslatableSlugs.length === 0) return; + if (nonTranslatableSlugs.length === 0) return []; // Filter to only the non-translatable fields present in this update const syncData: Record = {}; @@ -1703,7 +1850,10 @@ async function syncNonTranslatableFields( syncData[slug] = data[slug]; } } - if (Object.keys(syncData).length === 0) return; + if (Object.keys(syncData).length === 0) return []; + const shouldReindexMediaUsage = fields.some( + (field) => field.slug in syncData && MEDIA_USAGE_FIELD_TYPES.has(field.type), + ); // Build the SET clause for sibling rows validateIdentifier(collectionSlug, "collection slug"); @@ -1722,4 +1872,13 @@ async function syncNonTranslatableFields( WHERE translation_group = ${translationGroup} AND id != ${updatedItemId} `.execute(trx); + + if (!shouldReindexMediaUsage) return []; + + const siblings = await sql<{ id: string }>` + SELECT id FROM ${sql.ref(tableName)} + WHERE translation_group = ${translationGroup} + AND id != ${updatedItemId} + `.execute(trx); + return siblings.rows.map((row) => row.id); } diff --git a/packages/core/src/api/handlers/revision.ts b/packages/core/src/api/handlers/revision.ts index a31ec0bb8..4b7b896a6 100644 --- a/packages/core/src/api/handlers/revision.ts +++ b/packages/core/src/api/handlers/revision.ts @@ -8,6 +8,7 @@ import { ContentRepository } from "../../database/repositories/content.js"; import { RevisionRepository, type Revision } from "../../database/repositories/revision.js"; import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; +import { replaceContentMediaUsage } from "../../media/usage-index.js"; import type { ApiResult, ContentResponse } from "../types.js"; export interface RevisionListResponse { @@ -123,6 +124,12 @@ export async function handleRevisionRestore( data: fieldData, slug: typeof _slug === "string" ? _slug : undefined, }); + await replaceContentMediaUsage( + trx, + revision.collection, + updated, + updated.status === "published" ? "live" : "draft", + ); await trxRevisionRepo.create({ collection: revision.collection, diff --git a/packages/core/src/database/migrations/046_media_usage_index.ts b/packages/core/src/database/migrations/046_media_usage_index.ts new file mode 100644 index 000000000..dcb95f2b2 --- /dev/null +++ b/packages/core/src/database/migrations/046_media_usage_index.ts @@ -0,0 +1,112 @@ +import { sql, type Kysely } from "kysely"; + +import { currentTimestamp } from "../dialect-helpers.js"; +import type { Database } from "../types.js"; + +/** + * Media usage index. + * + * Sources describe the indexed thing (`content:posts:01...:live`), while usage + * rows describe individual media occurrences within the source. Usage rows are + * generation-tagged so D1 can insert a new generation before flipping the source + * pointer, avoiding a visible empty index if replacement work fails midway. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("_emdash_media_usage_sources") + .ifNotExists() + .addColumn("source_key", "text", (c) => c.primaryKey()) + .addColumn("source_type", "text", (c) => c.notNull()) + .addColumn("collection", "text") + .addColumn("content_id", "text") + .addColumn("content_slug", "text") + .addColumn("locale", "text") + .addColumn("translation_group", "text") + .addColumn("content_status", "text") + .addColumn("content_deleted_at", "text") + .addColumn("state", "text", (c) => c.notNull()) + .addColumn("revision_id", "text") + .addColumn("current_generation", "text", (c) => c.notNull()) + .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db))) + .addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db))) + .execute(); + + await db.schema + .createIndex("idx__emdash_media_usage_sources_content") + .ifNotExists() + .on("_emdash_media_usage_sources") + .columns(["collection", "content_id", "state"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_sources_translation_group") + .ifNotExists() + .on("_emdash_media_usage_sources") + .columns(["collection", "translation_group", "state"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_sources_state") + .ifNotExists() + .on("_emdash_media_usage_sources") + .column("state") + .execute(); + + await db.schema + .createTable("_emdash_media_usage") + .ifNotExists() + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("source_key", "text", (c) => c.notNull()) + .addColumn("generation", "text", (c) => c.notNull()) + .addColumn("media_id", "text") + .addColumn("provider", "text", (c) => c.notNull().defaultTo("local")) + .addColumn("provider_asset_id", "text", (c) => c.notNull()) + .addColumn("media_kind", "text") + .addColumn("mime_type", "text") + .addColumn("reference_type", "text", (c) => c.notNull()) + .addColumn("field_path", "text", (c) => c.notNull()) + .addColumn("sort_order", "integer", (c) => c.notNull().defaultTo(0)) + .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db))) + .addUniqueConstraint("media_usage_occurrence_unique", [ + "source_key", + "generation", + "field_path", + ]) + .execute(); + + await db.schema + .createIndex("idx__emdash_media_usage_media") + .ifNotExists() + .on("_emdash_media_usage") + .columns(["media_id", "source_key"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_provider_asset") + .ifNotExists() + .on("_emdash_media_usage") + .columns(["provider", "provider_asset_id", "source_key"]) + .execute(); + await db.schema + .createIndex("idx__emdash_media_usage_source_generation") + .ifNotExists() + .on("_emdash_media_usage") + .columns(["source_key", "generation"]) + .execute(); + + await backfillExistingContentMediaUsage(db); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("_emdash_media_usage").ifExists().execute(); + await db.schema.dropTable("_emdash_media_usage_sources").ifExists().execute(); +} + +async function backfillExistingContentMediaUsage(db: Kysely): Promise { + const collections = await sql<{ slug: string }>` + SELECT slug FROM _emdash_collections + `.execute(db); + const { replaceCollectionMediaUsage } = await import("../../media/usage-index.js"); + + for (const collection of collections.rows) { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- migration runs against Kysely, runtime helper needs the generated DB type + await replaceCollectionMediaUsage(db as Kysely, collection.slug); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 0d45422a1..0c651816b 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -47,6 +47,7 @@ import * as m042 from "./042_byline_fields.js"; import * as m043 from "./043_content_references.js"; import * as m044 from "./044_comment_reactions.js"; import * as m045 from "./045_taxonomy_parent_group.js"; +import * as m046 from "./046_media_usage_index.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -93,6 +94,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "043_content_references": m043, "044_comment_reactions": m044, "045_taxonomy_parent_group": m045, + "046_media_usage_index": m046, }); /** 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 new file mode 100644 index 000000000..b0ecd4693 --- /dev/null +++ b/packages/core/src/database/repositories/media-usage.ts @@ -0,0 +1,304 @@ +import { sql, type Kysely, type Selectable } from "kysely"; +import { ulid } from "ulidx"; + +import type { ExtractedMediaUsage } from "../../media/usage-extractor.js"; +import { chunks } from "../../utils/chunks.js"; +import type { Database, MediaUsageSourceTable, MediaUsageTable } from "../types.js"; +import { validateIdentifier } from "../validate.js"; + +const D1_BIND_PARAMETER_LIMIT = 100; +export const MEDIA_USAGE_INSERT_VALUES_PER_ROW = 11; +export const MEDIA_USAGE_INSERT_CHUNK_SIZE = Math.floor( + D1_BIND_PARAMETER_LIMIT / MEDIA_USAGE_INSERT_VALUES_PER_ROW, +); + +export type MediaUsageState = "live" | "draft"; + +export interface ReplaceContentMediaUsageInput { + collection: string; + contentId: string; + contentSlug?: string | null; + locale?: string | null; + translationGroup?: string | null; + contentStatus?: string | null; + contentDeletedAt?: string | null; + state: MediaUsageState; + revisionId?: string | null; + references: readonly ExtractedMediaUsage[]; +} + +export interface CurrentMediaUsage { + mediaId: string | null; + provider: string; + providerAssetId: string; + mediaKind: string | null; + mimeType: string | null; + referenceType: string; + fieldPath: string; + sortOrder: number; + collection: string | null; + contentId: string | null; + contentSlug: string | null; + locale: string | null; + translationGroup: string | null; + contentStatus: string | null; + contentDeletedAt: string | null; + state: string; + revisionId: string | null; +} + +export class MediaUsageRepository { + constructor(private db: Kysely) {} + + static contentSourceKey(collection: string, contentId: string, state: MediaUsageState): string { + return `content:${collection}:${contentId}:${state}`; + } + + async replaceContentUsage(input: ReplaceContentMediaUsageInput): Promise { + const sourceKey = MediaUsageRepository.contentSourceKey( + input.collection, + input.contentId, + input.state, + ); + const generation = ulid(); + const now = new Date().toISOString(); + const references = dedupeReferences(input.references); + const usageRows = references.map((ref, index) => ({ + id: ulid(), + source_key: sourceKey, + generation, + media_id: ref.mediaId ?? null, + provider: ref.provider, + provider_asset_id: ref.providerAssetId, + media_kind: ref.mediaKind ?? null, + mime_type: ref.mimeType ?? null, + reference_type: ref.referenceType, + field_path: ref.fieldPath, + sort_order: index, + })); + + if (usageRows.length > 0) { + for (const batch of chunks(usageRows, MEDIA_USAGE_INSERT_CHUNK_SIZE)) { + await this.db.insertInto("_emdash_media_usage").values(batch).execute(); + } + } + + await this.db + .insertInto("_emdash_media_usage_sources") + .values({ + source_key: sourceKey, + source_type: "content", + collection: input.collection, + content_id: input.contentId, + content_slug: input.contentSlug ?? null, + locale: input.locale ?? null, + translation_group: input.translationGroup ?? null, + content_status: input.contentStatus ?? null, + content_deleted_at: input.contentDeletedAt ?? null, + state: input.state, + revision_id: input.revisionId ?? null, + current_generation: generation, + updated_at: now, + }) + .onConflict((oc) => + oc.column("source_key").doUpdateSet({ + source_type: "content", + collection: input.collection, + content_id: input.contentId, + content_slug: input.contentSlug ?? null, + locale: input.locale ?? null, + translation_group: input.translationGroup ?? null, + content_status: input.contentStatus ?? null, + content_deleted_at: input.contentDeletedAt ?? null, + state: input.state, + revision_id: input.revisionId ?? null, + current_generation: generation, + updated_at: now, + }), + ) + .execute(); + + await this.deleteStaleGenerationsForSource(sourceKey); + } + + async deleteStaleGenerationsForSource(sourceKey: string): Promise { + await sql` + DELETE FROM _emdash_media_usage + WHERE source_key = ${sourceKey} + AND generation != ( + SELECT current_generation + FROM _emdash_media_usage_sources + WHERE source_key = ${sourceKey} + ) + `.execute(this.db); + } + + async deleteContentUsage( + collection: string, + contentId: string, + state?: MediaUsageState, + ): Promise { + const sourceKeys = state + ? [MediaUsageRepository.contentSourceKey(collection, contentId, state)] + : [ + MediaUsageRepository.contentSourceKey(collection, contentId, "live"), + MediaUsageRepository.contentSourceKey(collection, contentId, "draft"), + ]; + + await this.db.deleteFrom("_emdash_media_usage").where("source_key", "in", sourceKeys).execute(); + await this.db + .deleteFrom("_emdash_media_usage_sources") + .where("source_key", "in", sourceKeys) + .execute(); + } + + async deleteCollectionUsage(collection: string): Promise { + validateIdentifier(collection, "collection slug"); + const sourceKeys = this.db + .selectFrom("_emdash_media_usage_sources") + .select("source_key") + .where("source_type", "=", "content") + .where("collection", "=", collection); + + await this.db.deleteFrom("_emdash_media_usage").where("source_key", "in", sourceKeys).execute(); + await this.db + .deleteFrom("_emdash_media_usage_sources") + .where("source_type", "=", "content") + .where("collection", "=", collection) + .execute(); + } + + async findCurrentByMediaId(mediaId: string): Promise { + const rows = await this.db + .selectFrom("_emdash_media_usage as usage") + .innerJoin("_emdash_media_usage_sources as source", (join) => + join + .onRef("source.source_key", "=", "usage.source_key") + .onRef("source.current_generation", "=", "usage.generation"), + ) + .select([ + "usage.media_id as media_id", + "usage.provider as provider", + "usage.provider_asset_id as provider_asset_id", + "usage.media_kind as media_kind", + "usage.mime_type as mime_type", + "usage.reference_type as reference_type", + "usage.field_path as field_path", + "usage.sort_order as sort_order", + "source.collection as collection", + "source.content_id as content_id", + "source.content_slug as content_slug", + "source.locale as locale", + "source.translation_group as translation_group", + "source.content_status as content_status", + "source.content_deleted_at as content_deleted_at", + "source.state as state", + "source.revision_id as revision_id", + ]) + .where("usage.media_id", "=", mediaId) + .orderBy("source.updated_at", "desc") + .orderBy("usage.sort_order", "asc") + .orderBy("usage.id", "asc") + .execute(); + + return rows.map(rowToCurrentUsage); + } + + async findCurrentByProviderAsset( + provider: string, + providerAssetId: string, + ): Promise { + const rows = await this.db + .selectFrom("_emdash_media_usage as usage") + .innerJoin("_emdash_media_usage_sources as source", (join) => + join + .onRef("source.source_key", "=", "usage.source_key") + .onRef("source.current_generation", "=", "usage.generation"), + ) + .select([ + "usage.media_id as media_id", + "usage.provider as provider", + "usage.provider_asset_id as provider_asset_id", + "usage.media_kind as media_kind", + "usage.mime_type as mime_type", + "usage.reference_type as reference_type", + "usage.field_path as field_path", + "usage.sort_order as sort_order", + "source.collection as collection", + "source.content_id as content_id", + "source.content_slug as content_slug", + "source.locale as locale", + "source.translation_group as translation_group", + "source.content_status as content_status", + "source.content_deleted_at as content_deleted_at", + "source.state as state", + "source.revision_id as revision_id", + ]) + .where("usage.provider", "=", provider) + .where("usage.provider_asset_id", "=", providerAssetId) + .orderBy("source.updated_at", "desc") + .orderBy("usage.sort_order", "asc") + .orderBy("usage.id", "asc") + .execute(); + + return rows.map(rowToCurrentUsage); + } +} + +function dedupeReferences(refs: readonly ExtractedMediaUsage[]): ExtractedMediaUsage[] { + const seen = new Set(); + const deduped: ExtractedMediaUsage[] = []; + for (const ref of refs) { + const key = `${ref.fieldPath}\0${ref.provider}\0${ref.providerAssetId}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(ref); + } + return deduped; +} + +function rowToCurrentUsage( + row: Pick< + Selectable, + | "media_id" + | "provider" + | "provider_asset_id" + | "media_kind" + | "mime_type" + | "reference_type" + | "field_path" + | "sort_order" + > & + Pick< + Selectable, + | "collection" + | "content_id" + | "content_slug" + | "locale" + | "translation_group" + | "content_status" + | "content_deleted_at" + | "state" + | "revision_id" + >, +): CurrentMediaUsage { + return { + mediaId: row.media_id, + provider: row.provider, + providerAssetId: row.provider_asset_id, + mediaKind: row.media_kind, + mimeType: row.mime_type, + referenceType: row.reference_type, + fieldPath: row.field_path, + sortOrder: row.sort_order, + collection: row.collection, + contentId: row.content_id, + contentSlug: row.content_slug, + locale: row.locale, + translationGroup: row.translation_group, + contentStatus: row.content_status, + contentDeletedAt: row.content_deleted_at, + state: row.state, + revisionId: row.revision_id, + }; +} diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 7b437cd7f..7c8fe171e 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -60,6 +60,38 @@ export interface MediaTable { author_id: string | null; } +export interface MediaUsageSourceTable { + source_key: string; + source_type: string; + collection: string | null; + content_id: string | null; + content_slug: string | null; + locale: string | null; + translation_group: string | null; + content_status: string | null; + content_deleted_at: string | null; + state: string; + revision_id: string | null; + current_generation: string; + created_at: Generated; + updated_at: Generated; +} + +export interface MediaUsageTable { + id: string; + source_key: string; + generation: string; + media_id: string | null; + provider: Generated; + provider_asset_id: string; + media_kind: string | null; + mime_type: string | null; + reference_type: string; + field_path: string; + sort_order: Generated; + created_at: Generated; +} + export interface UserTable { id: string; email: string; @@ -414,6 +446,8 @@ export interface Database { content_taxonomies: ContentTaxonomyTable; _emdash_taxonomy_defs: TaxonomyDefTable; media: MediaTable; + _emdash_media_usage_sources: MediaUsageSourceTable; + _emdash_media_usage: MediaUsageTable; users: UserTable; credentials: CredentialTable; auth_tokens: AuthTokenTable; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index fde5c3bb1..fc69bdcb3 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -31,6 +31,7 @@ import type { import { validateIdentifier } from "./database/validate.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; +import { replaceContentMediaUsage } from "./media/usage-index.js"; import type { SandboxedPluginInstance, SandboxRunner } from "./plugins/sandbox/types.js"; import type { ResolvedPlugin, @@ -2664,6 +2665,9 @@ export class EmDashRuntime { // Content table columns = published data (never written by saves). // Draft data lives only in the revisions table. let usesDraftRevisions = false; + let draftUsageItem: ContentItemInternal | null = null; + let draftUsageData: Record | null = null; + let draftUsageRevisionId: string | null = null; if (processedData) { try { const collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection); @@ -2693,6 +2697,7 @@ export class EmDashRuntime { if (bodyWithoutRev.skipRevision && existing.draftRevisionId) { // Autosave: update existing draft revision in place await revisionRepo.updateData(existing.draftRevisionId, mergedData); + draftUsageRevisionId = existing.draftRevisionId; } else { // Create new draft revision const revision = await revisionRepo.create({ @@ -2714,13 +2719,27 @@ export class EmDashRuntime { // Fire-and-forget: prune old revisions to prevent unbounded growth void revisionRepo.pruneOldRevisions(collection, resolvedId, 50).catch(() => {}); + draftUsageRevisionId = revision.id; } + + draftUsageItem = existing; + draftUsageData = mergedData; } } } catch { // Don't fail the update if revision creation fails } } + if (draftUsageItem && draftUsageData) { + await replaceContentMediaUsage( + this.db, + collection, + draftUsageItem, + "draft", + draftUsageData, + draftUsageRevisionId, + ); + } // Update the content table: // - If collection uses draft revisions: only update metadata (no data fields, no slug) @@ -3046,7 +3065,18 @@ export class EmDashRuntime { // columns and the next `content_get` would surface different // values (the bug that motivated this rewrite). const refetched = await handleContentGet(this.db, revision.collection, revision.entryId); - return this.hydrateDraftData(refetched); + const hydrated = await this.hydrateDraftData(refetched); + if (hydrated.success) { + await replaceContentMediaUsage( + this.db, + revision.collection, + hydrated.data.item, + "draft", + revision.data, + newDraft.id, + ); + } + return hydrated; } catch (error) { console.error("[emdash] revision restore failed:", error); return { diff --git a/packages/core/src/media/mime.ts b/packages/core/src/media/mime.ts index 495e9f62c..dbc49ced5 100644 --- a/packages/core/src/media/mime.ts +++ b/packages/core/src/media/mime.ts @@ -16,6 +16,50 @@ export function matchesMimeAllowlist(mime: string, allowList: readonly string[]) return false; } +export type MediaKind = + | "image" + | "video" + | "audio" + | "document" + | "archive" + | "font" + | "text" + | "other"; + +const DOCUMENT_MIME_TYPES = new Set([ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/rtf", +]); + +const ARCHIVE_MIME_TYPES = new Set([ + "application/zip", + "application/x-zip-compressed", + "application/x-tar", + "application/gzip", + "application/x-gzip", + "application/x-rar-compressed", + "application/x-7z-compressed", +]); + +export function mediaKindFromMime(mime: string | null | undefined): MediaKind | null { + if (!mime) return null; + const normalized = normalizeMime(mime); + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + if (normalized.startsWith("audio/")) return "audio"; + if (normalized.startsWith("font/")) return "font"; + if (normalized.startsWith("text/")) return "text"; + if (DOCUMENT_MIME_TYPES.has(normalized)) return "document"; + if (ARCHIVE_MIME_TYPES.has(normalized)) return "archive"; + return "other"; +} + export const EXTENSION_TO_MIME: Readonly> = { ".pdf": "application/pdf", ".png": "image/png", diff --git a/packages/core/src/media/usage-extractor.ts b/packages/core/src/media/usage-extractor.ts new file mode 100644 index 000000000..1ec5738d7 --- /dev/null +++ b/packages/core/src/media/usage-extractor.ts @@ -0,0 +1,207 @@ +import type { FieldType, FieldValidation } from "../schema/types.js"; +import { mediaKindFromMime, normalizeMime, type MediaKind } from "./mime.js"; +import { INTERNAL_MEDIA_PREFIX } from "./normalize.js"; + +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; + +export type MediaUsageReferenceType = + | "image_field" + | "file_field" + | "repeater_image_subfield" + | "portable_text_image"; + +export interface ExtractedMediaUsage { + mediaId: string | null; + provider: string; + providerAssetId: string; + mediaKind: MediaKind | null; + mimeType: string | null; + referenceType: MediaUsageReferenceType; + fieldPath: string; +} + +export interface MediaUsageIndexedField { + slug: string; + type: FieldType; + validation?: FieldValidation | null; +} + +export function extractContentMediaUsage( + fields: readonly MediaUsageIndexedField[], + data: Record, +): ExtractedMediaUsage[] { + const refs: ExtractedMediaUsage[] = []; + + for (const field of fields) { + const value = data[field.slug]; + if (value == null) continue; + + switch (field.type) { + case "image": + pushMediaValueRef(refs, value, "image_field", field.slug, "image"); + break; + case "file": + pushMediaValueRef(refs, value, "file_field", field.slug, null); + break; + case "repeater": + extractRepeaterRefs(refs, field, value); + break; + case "portableText": + extractPortableTextRefs(refs, value, field.slug); + break; + } + } + + return dedupeRefs(refs); +} + +function extractRepeaterRefs( + refs: ExtractedMediaUsage[], + field: MediaUsageIndexedField, + value: unknown, +) { + if (!Array.isArray(value)) return; + const imageSubFields = field.validation?.subFields?.filter( + (subField) => subField.type === "image", + ); + if (!imageSubFields?.length) return; + + for (const [index, item] of value.entries()) { + if (!isRecord(item)) continue; + for (const subField of imageSubFields) { + pushMediaValueRef( + refs, + item[subField.slug], + "repeater_image_subfield", + `${field.slug}[${index}].${subField.slug}`, + "image", + ); + } + } +} + +function extractPortableTextRefs(refs: ExtractedMediaUsage[], value: unknown, path: string) { + if (Array.isArray(value)) { + value.forEach((item, index) => extractPortableTextRefs(refs, item, `${path}[${index}]`)); + return; + } + + if (!isRecord(value)) return; + + if (value._type === "image") { + const ref = getPortableTextImageRef(value); + if (ref) { + const { fieldKey, ...target } = ref; + refs.push({ + ...target, + referenceType: "portable_text_image", + fieldPath: `${path}.asset.${fieldKey}`, + }); + } + } + + for (const [key, child] of Object.entries(value)) { + if (key === "asset" && value._type === "image") continue; + extractPortableTextRefs(refs, child, `${path}.${key}`); + } +} + +function getPortableTextImageRef( + block: Record, +): (ExtractedMediaTarget & { fieldKey: string }) | null { + const asset = block.asset; + if (!isRecord(asset)) return null; + + const fieldKey = typeof asset._ref === "string" ? "_ref" : "id"; + const value = + fieldKey === "_ref" ? { ...asset, id: asset._ref, mimeType: asset.mimeType } : asset; + const ref = getMediaValueTarget(value, "image"); + if (!ref) return null; + return { ...ref, fieldKey }; +} + +interface ExtractedMediaTarget { + mediaId: string | null; + provider: string; + providerAssetId: string; + mediaKind: MediaKind | null; + mimeType: string | null; +} + +function pushMediaValueRef( + refs: ExtractedMediaUsage[], + value: unknown, + referenceType: MediaUsageReferenceType, + fieldPath: string, + fallbackKind: MediaKind | null, +) { + const target = getMediaValueTarget(value, fallbackKind); + if (!target) return; + refs.push({ ...target, referenceType, fieldPath }); +} + +function getMediaValueTarget( + value: unknown, + fallbackKind: MediaKind | null, +): ExtractedMediaTarget | null { + if (typeof value === "string") { + if (!isLocalMediaId(value)) return null; + return { + mediaId: value, + provider: "local", + providerAssetId: value, + mediaKind: fallbackKind, + mimeType: null, + }; + } + + if (!isRecord(value)) return null; + + const provider = getProvider(value.provider); + const id = value.id; + if (typeof id !== "string" || !isStructuredAssetId(id)) return null; + if (provider === "local" && !isLocalMediaId(id)) return null; + + const mimeType = typeof value.mimeType === "string" ? normalizeMime(value.mimeType) : null; + return { + mediaId: provider === "local" ? id : null, + provider, + providerAssetId: id, + mediaKind: mediaKindFromMime(mimeType) ?? fallbackKind, + mimeType, + }; +} + +function getProvider(value: unknown): string { + if (typeof value !== "string") return "local"; + const provider = value.trim(); + return provider.length > 0 ? provider : "local"; +} + +function isLocalMediaId(value: string): boolean { + return isStructuredAssetId(value) && !value.startsWith(INTERNAL_MEDIA_PREFIX); +} + +function isStructuredAssetId(value: string): boolean { + return value.length > 0 && !isUrl(value) && !value.startsWith(INTERNAL_MEDIA_PREFIX); +} + +function isUrl(value: string): boolean { + return URL_SCHEME_RE.test(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function dedupeRefs(refs: ExtractedMediaUsage[]): ExtractedMediaUsage[] { + const seen = new Set(); + const deduped: ExtractedMediaUsage[] = []; + for (const ref of refs) { + const key = `${ref.fieldPath}\0${ref.provider}\0${ref.providerAssetId}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(ref); + } + return deduped; +} diff --git a/packages/core/src/media/usage-index.ts b/packages/core/src/media/usage-index.ts new file mode 100644 index 000000000..1da9e6851 --- /dev/null +++ b/packages/core/src/media/usage-index.ts @@ -0,0 +1,262 @@ +import { sql, type Kysely } from "kysely"; + +import { + MediaUsageRepository, + type MediaUsageState, +} from "../database/repositories/media-usage.js"; +import { RevisionRepository } from "../database/repositories/revision.js"; +import type { ContentItem } from "../database/repositories/types.js"; +import type { Database } from "../database/types.js"; +import { validateIdentifier } from "../database/validate.js"; +import type { FieldType, FieldValidation } from "../schema/types.js"; +import { extractContentMediaUsage, type MediaUsageIndexedField } from "./usage-extractor.js"; + +const INDEXED_FIELD_TYPES = ["image", "file", "repeater", "portableText"] as const; +const CONTENT_SYSTEM_COLUMNS = new Set([ + "id", + "slug", + "status", + "author_id", + "primary_byline_id", + "created_at", + "updated_at", + "published_at", + "scheduled_at", + "deleted_at", + "version", + "live_revision_id", + "draft_revision_id", + "locale", + "translation_group", +]); + +export async function replaceContentMediaUsage( + db: Kysely, + collection: string, + item: ContentItem, + state: MediaUsageState, + data: Record = item.data, + revisionId?: string | null, + contentDeletedAt?: string | null, +): Promise { + const fields = await getMediaUsageFields(db, collection); + const repo = new MediaUsageRepository(db); + await replaceContentMediaUsageWithFields( + repo, + fields, + collection, + item, + state, + data, + revisionId, + contentDeletedAt, + ); +} + +export async function replaceCollectionMediaUsage( + db: Kysely, + collection: string, +): Promise { + validateIdentifier(collection, "collection slug"); + const fields = await getMediaUsageFields(db, collection); + const repo = new MediaUsageRepository(db); + + if (fields.length === 0) { + await repo.deleteCollectionUsage(collection); + return; + } + + const tableName = `ec_${collection}`; + const result = await sql>` + SELECT * FROM ${sql.ref(tableName)} + `.execute(db); + const revisionRepo = new RevisionRepository(db); + + for (const row of result.rows) { + const item = rowToContentItem(collection, row); + const contentDeletedAt = stringOrNull(row.deleted_at); + + if (item.status === "published") { + await replaceContentMediaUsageWithFields( + repo, + fields, + collection, + item, + "live", + item.data, + item.liveRevisionId, + contentDeletedAt, + ); + + if (item.draftRevisionId) { + const draft = await revisionRepo.findById(item.draftRevisionId); + if (draft) { + await replaceContentMediaUsageWithFields( + repo, + fields, + collection, + item, + "draft", + draft.data, + draft.id, + contentDeletedAt, + ); + } else { + await repo.deleteContentUsage(collection, item.id, "draft"); + } + } else { + await repo.deleteContentUsage(collection, item.id, "draft"); + } + continue; + } + + const draft = item.draftRevisionId ? await revisionRepo.findById(item.draftRevisionId) : null; + await replaceContentMediaUsageWithFields( + repo, + fields, + collection, + item, + "draft", + draft?.data ?? item.data, + draft?.id ?? null, + contentDeletedAt, + ); + await repo.deleteContentUsage(collection, item.id, "live"); + } +} + +async function replaceContentMediaUsageWithFields( + repo: MediaUsageRepository, + fields: readonly MediaUsageIndexedField[], + collection: string, + item: ContentItem, + state: MediaUsageState, + data: Record, + revisionId?: string | null, + contentDeletedAt?: string | null, +): Promise { + if (fields.length === 0) { + await repo.deleteContentUsage(collection, item.id, state); + return; + } + + await repo.replaceContentUsage({ + collection, + contentId: item.id, + contentSlug: item.slug, + locale: item.locale, + translationGroup: item.translationGroup, + contentStatus: item.status, + contentDeletedAt: contentDeletedAt ?? null, + state, + revisionId, + references: extractContentMediaUsage(fields, data), + }); +} + +export async function deleteContentMediaUsage( + db: Kysely, + collection: string, + contentId: string, + state?: MediaUsageState, +): Promise { + await new MediaUsageRepository(db).deleteContentUsage(collection, contentId, state); +} + +async function getMediaUsageFields( + db: Kysely, + collection: string, +): Promise { + const rows = await db + .selectFrom("_emdash_fields") + .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") + .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"]) + .where("_emdash_collections.slug", "=", collection) + .where("_emdash_fields.type", "in", INDEXED_FIELD_TYPES) + .orderBy("_emdash_fields.sort_order", "asc") + .execute(); + + return rows.flatMap((row) => { + if (!isIndexedFieldType(row.type)) return []; + return [ + { + slug: row.slug, + type: row.type, + validation: parseFieldValidation(row.validation), + }, + ]; + }); +} + +function isIndexedFieldType(value: string): value is FieldType { + switch (value) { + case "image": + case "file": + case "repeater": + case "portableText": + return true; + default: + return false; + } +} + +function parseFieldValidation(value: string | null): FieldValidation | null { + if (!value) return null; + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? (parsed as FieldValidation) : null; + } catch { + return null; + } +} + +function rowToContentItem(collection: string, row: Record): ContentItem { + const data: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (!CONTENT_SYSTEM_COLUMNS.has(key) && value !== null) { + data[key] = deserializeContentValue(value); + } + } + + return { + id: stringOrEmpty(row.id), + type: collection, + slug: stringOrNull(row.slug), + status: stringOrEmpty(row.status) || "draft", + data, + authorId: stringOrNull(row.author_id), + primaryBylineId: stringOrNull(row.primary_byline_id), + createdAt: stringOrEmpty(row.created_at), + updatedAt: stringOrEmpty(row.updated_at), + publishedAt: stringOrNull(row.published_at), + scheduledAt: stringOrNull(row.scheduled_at), + liveRevisionId: stringOrNull(row.live_revision_id), + draftRevisionId: stringOrNull(row.draft_revision_id), + version: numberOrDefault(row.version, 1), + locale: stringOrNull(row.locale), + translationGroup: stringOrNull(row.translation_group), + }; +} + +function deserializeContentValue(value: unknown): unknown { + if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +} + +function stringOrEmpty(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function numberOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" ? value : fallback; +} diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index c7a9b5339..0687b0268 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -5,23 +5,27 @@ * */ -import type { Kysely } from "kysely"; +import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; import { ContentRepository } from "../database/repositories/content.js"; import { MediaRepository } from "../database/repositories/media.js"; import { OptionsRepository } from "../database/repositories/options.js"; import { PluginStorageRepository } from "../database/repositories/plugin-storage.js"; +import { RevisionRepository } from "../database/repositories/revision.js"; import { SeoRepository } from "../database/repositories/seo.js"; +import type { ContentItem as RepositoryContentItem } from "../database/repositories/types.js"; import { UserRepository } from "../database/repositories/user.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; +import { validateIdentifier } from "../database/validate.js"; import { resolveAndValidateExternalUrl, SsrfError, stripCredentialHeaders, } from "../import/ssrf.js"; import { enrichImageMetadata } from "../media/enrich.js"; +import { replaceContentMediaUsage } from "../media/usage-index.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import { CronAccessImpl } from "./cron.js"; @@ -193,6 +197,63 @@ async function assertSeoEnabled( return hasSeo; } +function getContentMediaUsageState(item: Pick): "live" | "draft" { + return item.status === "published" ? "live" : "draft"; +} + +async function replacePluginContentMediaUsage( + db: Kysely, + collection: string, + item: RepositoryContentItem, + contentDeletedAt?: string | null, +): Promise { + const state = getContentMediaUsageState(item); + await replaceContentMediaUsage( + db, + collection, + item, + state, + item.data, + state === "live" ? item.liveRevisionId : null, + contentDeletedAt ?? null, + ); +} + +async function replacePluginContentMediaUsageSources( + db: Kysely, + collection: string, + item: RepositoryContentItem, + contentDeletedAt?: string | null, +): Promise { + await replacePluginContentMediaUsage(db, collection, item, contentDeletedAt); + + if (item.status !== "published" || !item.draftRevisionId) return; + const draft = await new RevisionRepository(db).findById(item.draftRevisionId); + if (!draft) return; + await replaceContentMediaUsage( + db, + collection, + item, + "draft", + draft.data, + draft.id, + contentDeletedAt ?? null, + ); +} + +async function getContentDeletedAt( + db: Kysely, + collection: string, + contentId: string, +): Promise { + validateIdentifier(collection, "collection slug"); + const tableName = `ec_${collection}`; + const result = await sql<{ deleted_at: string | null }>` + SELECT deleted_at FROM ${sql.ref(tableName)} WHERE id = ${contentId} + `.execute(db); + return result.rows[0]?.deleted_at ?? null; +} + /** * Create read-only content access */ @@ -305,6 +366,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces type: collection, data: fields, }); + await replacePluginContentMediaUsage(trx, collection, item); const result: ContentItem = { id: item.id, @@ -351,6 +413,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces if (!existing) throw new Error("Content not found"); return existing; })(); + if (hasFieldUpdates) await replacePluginContentMediaUsage(trx, collection, item); const result: ContentItem = { id: item.id, @@ -376,8 +439,22 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces }, async delete(collection: string, id: string): Promise { - const contentRepo = new ContentRepository(db); - return contentRepo.delete(collection, id); + return withTransaction(db, async (trx) => { + const contentRepo = new ContentRepository(trx); + const deleted = await contentRepo.delete(collection, id); + if (deleted) { + const item = await contentRepo.findByIdIncludingTrashed(collection, id); + if (item) { + await replacePluginContentMediaUsageSources( + trx, + collection, + item, + await getContentDeletedAt(trx, collection, id), + ); + } + } + return deleted; + }); }, }; } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 390624086..e645080d0 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -4,9 +4,11 @@ import { sql } from "kysely"; import { ulid } from "ulidx"; import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js"; +import { MediaUsageRepository } from "../database/repositories/media-usage.js"; import { withTransaction } from "../database/transaction.js"; import type { CollectionTable, Database, FieldTable } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; +import { replaceCollectionMediaUsage } from "../media/usage-index.js"; import { FTSManager } from "../search/fts-manager.js"; import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js"; import { @@ -59,11 +61,21 @@ const VALID_COLLECTION_SUPPORTS: ReadonlySet = new Set = new Set([ + "image", + "file", + "repeater", + "portableText", +]); function isCollectionSupport(value: unknown): value is CollectionSupport { return typeof value === "string" && VALID_COLLECTION_SUPPORTS.has(value); } +function isMediaUsageIndexedFieldType(type: FieldType): boolean { + return MEDIA_USAGE_INDEXED_FIELD_TYPES.has(type); +} + /** * Parse a collection's `supports` column (stored as a JSON array of * CollectionSupport keys). Unknown/invalid entries are filtered out so the @@ -376,6 +388,7 @@ export class SchemaRegistry { // Delete the collection record (fields will cascade) await trx.deleteFrom("_emdash_collections").where("id", "=", existing.id).execute(); + await new MediaUsageRepository(trx).deleteCollectionUsage(slug); }); } @@ -506,6 +519,14 @@ export class SchemaRegistry { await this.syncSearchState(collectionSlug, trx); } + if ( + input.required && + input.defaultValue !== undefined && + isMediaUsageIndexedFieldType(input.type) + ) { + await replaceCollectionMediaUsage(trx, collectionSlug); + } + return field; }); } @@ -612,6 +633,13 @@ export class SchemaRegistry { await this.syncSearchState(collectionSlug, trx); } + const mediaUsageRelevant = + (input.type !== undefined || input.validation !== undefined) && + (isMediaUsageIndexedFieldType(field.type) || isMediaUsageIndexedFieldType(updated.type)); + if (mediaUsageRelevant) { + await replaceCollectionMediaUsage(trx, collectionSlug); + } + return updated; }); } @@ -681,6 +709,10 @@ export class SchemaRegistry { // Drop column from content table — safe now because FTS triggers are gone await this.dropColumn(collectionSlug, fieldSlug, trx); + + if (isMediaUsageIndexedFieldType(field.type)) { + await replaceCollectionMediaUsage(trx, collectionSlug); + } }); } diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index f060a93db..8881b6459 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -16,11 +16,13 @@ import { MediaRepository } from "../database/repositories/media.js"; import { RedirectRepository } from "../database/repositories/redirect.js"; import { RevisionRepository } from "../database/repositories/revision.js"; import { TaxonomyRepository } from "../database/repositories/taxonomy.js"; +import type { ContentItem as RepositoryContentItem } from "../database/repositories/types.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; import type { MediaValue } from "../fields/types.js"; import { getI18nConfig } from "../i18n/config.js"; import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js"; +import { deleteContentMediaUsage, replaceContentMediaUsage } from "../media/usage-index.js"; import { SchemaRegistry } from "../schema/registry.js"; import { FTSManager } from "../search/fts-manager.js"; import { setSiteSettings } from "../settings/index.js"; @@ -39,6 +41,29 @@ import type { const FILE_EXTENSION_PATTERN = /\.([a-z0-9]+)(?:\?|$)/i; import { validateSeed } from "./validate.js"; +function getSeedContentMediaUsageState( + item: Pick, +): "live" | "draft" { + return item.status === "published" ? "live" : "draft"; +} + +async function replaceSeedContentMediaUsage( + db: Kysely, + collection: string, + item: RepositoryContentItem, +): Promise { + const state = getSeedContentMediaUsageState(item); + await replaceContentMediaUsage( + db, + collection, + item, + state, + item.data, + state === "live" ? item.liveRevisionId : null, + ); + await deleteContentMediaUsage(db, collection, item.id, state === "live" ? "draft" : "live"); +} + /** Pattern to remove file extensions */ const EXTENSION_PATTERN = /\.[^.]+$/; @@ -456,7 +481,7 @@ export async function applySeed( const trxBylineRepo = new BylineRepository(trx); const trxRevisionRepo = new RevisionRepository(trx); - await trxContentRepo.update(collectionSlug, existing.id, { + let item = await trxContentRepo.update(collectionSlug, existing.id, { status, data: resolvedData, }); @@ -485,8 +510,9 @@ export async function applySeed( data: resolvedData, }); await trxContentRepo.setDraftRevision(collectionSlug, existing.id, draft.id); - await trxContentRepo.publish(collectionSlug, existing.id); + item = await trxContentRepo.publish(collectionSlug, existing.id); } + await replaceSeedContentMediaUsage(trx, collectionSlug, item); }); seedIdMap.set(entry.id, existing.id); @@ -522,7 +548,7 @@ export async function applySeed( const trxContentRepo = new ContentRepository(trx); const trxBylineRepo = new BylineRepository(trx); - const item = await trxContentRepo.create({ + let item = await trxContentRepo.create({ type: collectionSlug, slug: entry.slug, status, @@ -539,9 +565,11 @@ export async function applySeed( // revision so the admin UI shows "Unpublish" instead of "Save & Publish" // and `live_revision_id` is populated for downstream queries. if (status === "published") { - await trxContentRepo.publish(collectionSlug, item.id); + item = await trxContentRepo.publish(collectionSlug, item.id); } + await replaceSeedContentMediaUsage(trx, collectionSlug, item); + return item; }); diff --git a/packages/core/tests/integration/content/media-usage-index.test.ts b/packages/core/tests/integration/content/media-usage-index.test.ts new file mode 100644 index 000000000..cd912522d --- /dev/null +++ b/packages/core/tests/integration/content/media-usage-index.test.ts @@ -0,0 +1,584 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleContentCreate, + handleContentDelete, + handleContentDiscardDraft, + handleContentPermanentDelete, + handleContentDuplicate, + handleContentPublish, + handleContentRestore, + handleContentSchedule, + handleContentUnschedule, + handleContentUnpublish, + handleContentUpdate, +} from "../../../src/api/handlers/content.js"; +import { handleRevisionRestore } from "../../../src/api/handlers/revision.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { MediaRepository, type MediaItem } from "../../../src/database/repositories/media.js"; +import { RevisionRepository } from "../../../src/database/repositories/revision.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; +import { replaceContentMediaUsage } from "../../../src/media/usage-index.js"; +import { createContentAccessWithWrite } from "../../../src/plugins/context.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { applySeed } from "../../../src/seed/apply.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +function compareNullableString(a: string | null, b: string | null): number { + return (a ?? "").localeCompare(b ?? ""); +} + +describeEachDialect("content media usage indexing", (dialect) => { + let ctx: DialectTestContext; + let mediaA: MediaItem; + let mediaB: MediaItem; + let fileMedia: MediaItem; + let bodyMedia: MediaItem; + let galleryMedia: MediaItem; + let usageRepo: MediaUsageRepository; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + usageRepo = new MediaUsageRepository(ctx.db); + setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] }); + + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ + slug: "posts", + label: "Posts", + labelSingular: "Post", + supports: ["revisions"], + }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createField("posts", { + slug: "hero", + label: "Hero", + type: "image", + translatable: false, + }); + await registry.createField("posts", { slug: "attachment", label: "Attachment", type: "file" }); + await registry.createField("posts", { slug: "body", label: "Body", type: "portableText" }); + await registry.createField("posts", { + slug: "gallery", + label: "Gallery", + type: "repeater", + validation: { subFields: [{ slug: "image", label: "Image", type: "image" }] }, + }); + + const mediaRepo = new MediaRepository(ctx.db); + mediaA = await mediaRepo.create({ + filename: "a.jpg", + mimeType: "image/jpeg", + storageKey: "a.jpg", + }); + mediaB = await mediaRepo.create({ + filename: "b.jpg", + mimeType: "image/jpeg", + storageKey: "b.jpg", + }); + fileMedia = await mediaRepo.create({ + filename: "doc.pdf", + mimeType: "application/pdf", + storageKey: "doc.pdf", + }); + bodyMedia = await mediaRepo.create({ + filename: "body.jpg", + mimeType: "image/jpeg", + storageKey: "body.jpg", + }); + galleryMedia = await mediaRepo.create({ + filename: "gallery.jpg", + mimeType: "image/jpeg", + storageKey: "gallery.jpg", + }); + }); + + afterEach(async () => { + setI18nConfig(null); + await teardownForDialect(ctx); + }); + + it("indexes media usage on content create", async () => { + const created = await createPostWithMedia(mediaA.id); + expect(created.success).toBe(true); + + expect((await usageRepo.findCurrentByMediaId(mediaA.id))[0]?.fieldPath).toBe("hero"); + expect((await usageRepo.findCurrentByMediaId(mediaA.id))[0]?.state).toBe("draft"); + expect((await usageRepo.findCurrentByMediaId(fileMedia.id))[0]?.fieldPath).toBe("attachment"); + expect((await usageRepo.findCurrentByMediaId(bodyMedia.id))[0]?.fieldPath).toBe( + "body[0].asset._ref", + ); + expect((await usageRepo.findCurrentByMediaId(galleryMedia.id))[0]?.fieldPath).toBe( + "gallery[0].image", + ); + }); + + it("indexes structured provider media references", async () => { + const created = await handleContentCreate(ctx.db, "posts", { + slug: "provider-media", + data: { + title: "Provider Media", + hero: { + id: "cf_image_1", + provider: "cloudflare-images", + mimeType: "image/webp", + }, + attachment: { id: "mux_asset_1", provider: "mux", mimeType: "video/mp4" }, + }, + }); + expect(created.success).toBe(true); + + expect(await usageRepo.findCurrentByMediaId("cf_image_1")).toHaveLength(0); + const imageUsage = await usageRepo.findCurrentByProviderAsset( + "cloudflare-images", + "cf_image_1", + ); + expect(imageUsage).toHaveLength(1); + expect(imageUsage[0]?.mediaId).toBeNull(); + expect(imageUsage[0]?.mediaKind).toBe("image"); + expect(imageUsage[0]?.mimeType).toBe("image/webp"); + + const videoUsage = await usageRepo.findCurrentByProviderAsset("mux", "mux_asset_1"); + expect(videoUsage).toHaveLength(1); + expect(videoUsage[0]?.mediaKind).toBe("video"); + expect(videoUsage[0]?.fieldPath).toBe("attachment"); + }); + + it("replaces stale usage on content update", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const updated = await handleContentUpdate(ctx.db, "posts", created.data.item.id, { + data: { hero: { id: mediaB.id, provider: "local" } }, + }); + expect(updated.success).toBe(true); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + expect((await usageRepo.findCurrentByMediaId(mediaB.id))[0]?.fieldPath).toBe("hero"); + }); + + it("refreshes usage metadata on slug-only update", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const updated = await handleContentUpdate(ctx.db, "posts", created.data.item.id, { + slug: "renamed-post", + }); + expect(updated.success).toBe(true); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.contentSlug).toBe("renamed-post"); + }); + + it("preserves live revision metadata on published content refresh", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + const published = await handleContentPublish(ctx.db, "posts", created.data.item.id); + expect(published.success).toBe(true); + if (!published.success) throw new Error("publish failed"); + expect(published.data.item.liveRevisionId).toBeTruthy(); + + const updated = await handleContentUpdate(ctx.db, "posts", created.data.item.id, { + slug: "published-renamed-post", + }); + expect(updated.success).toBe(true); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.state).toBe("live"); + expect(mediaAUsage[0]?.revisionId).toBe(published.data.item.liveRevisionId); + }); + + it("removes stale old-state usage on direct status update", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + const published = await handleContentPublish(ctx.db, "posts", created.data.item.id); + expect(published.success).toBe(true); + + const updated = await handleContentUpdate(ctx.db, "posts", created.data.item.id, { + status: "draft", + }); + expect(updated.success).toBe(true); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.state).toBe("draft"); + }); + + it("keeps live usage while staged draft usage changes, then promotes on publish", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + const initialPublish = await handleContentPublish(ctx.db, "posts", created.data.item.id); + expect(initialPublish.success).toBe(true); + + const contentRepo = new ContentRepository(ctx.db); + const revisionRepo = new RevisionRepository(ctx.db); + const draftData = { title: "Draft", hero: { id: mediaB.id, provider: "local" } }; + const draft = await revisionRepo.create({ + collection: "posts", + entryId: created.data.item.id, + data: draftData, + }); + await contentRepo.setDraftRevision("posts", created.data.item.id, draft.id); + const staged = await contentRepo.findById("posts", created.data.item.id); + if (!staged) throw new Error("staged item missing"); + await replaceContentMediaUsage(ctx.db, "posts", staged, "draft", draftData, draft.id); + + expect((await usageRepo.findCurrentByMediaId(mediaA.id))[0]?.state).toBe("live"); + expect((await usageRepo.findCurrentByMediaId(mediaB.id))[0]?.state).toBe("draft"); + + const published = await handleContentPublish(ctx.db, "posts", created.data.item.id); + expect(published.success).toBe(true); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + const mediaBUsage = await usageRepo.findCurrentByMediaId(mediaB.id); + expect(mediaBUsage).toHaveLength(1); + expect(mediaBUsage[0]?.state).toBe("live"); + }); + + it("clears draft usage on discard draft", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const contentRepo = new ContentRepository(ctx.db); + const revisionRepo = new RevisionRepository(ctx.db); + const draftData = { title: "Draft", hero: { id: mediaB.id, provider: "local" } }; + const draft = await revisionRepo.create({ + collection: "posts", + entryId: created.data.item.id, + data: draftData, + }); + await contentRepo.setDraftRevision("posts", created.data.item.id, draft.id); + const staged = await contentRepo.findById("posts", created.data.item.id); + if (!staged) throw new Error("staged item missing"); + await replaceContentMediaUsage(ctx.db, "posts", staged, "draft", draftData, draft.id); + + const discarded = await handleContentDiscardDraft(ctx.db, "posts", created.data.item.id); + expect(discarded.success).toBe(true); + + expect((await usageRepo.findCurrentByMediaId(mediaA.id))[0]?.state).toBe("draft"); + expect(await usageRepo.findCurrentByMediaId(mediaB.id)).toHaveLength(0); + }); + + it("reindexes sibling locales when non-translatable media fields sync", async () => { + const en = await handleContentCreate(ctx.db, "posts", { + slug: "shared-en", + locale: "en", + data: { title: "Shared", hero: { id: mediaA.id, provider: "local" } }, + }); + if (!en.success) throw new Error("create en failed"); + const fr = await handleContentCreate(ctx.db, "posts", { + slug: "shared-fr", + locale: "fr", + translationOf: en.data.item.id, + data: { title: "Partage", hero: { id: mediaA.id, provider: "local" } }, + }); + if (!fr.success) throw new Error("create fr failed"); + + const updated = await handleContentUpdate(ctx.db, "posts", en.data.item.id, { + data: { hero: { id: mediaB.id, provider: "local" } }, + }); + expect(updated.success).toBe(true); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + const mediaBUsage = await usageRepo.findCurrentByMediaId(mediaB.id); + expect(mediaBUsage.map((usage) => usage.contentId).toSorted(compareNullableString)).toEqual( + [en.data.item.id, fr.data.item.id].toSorted(compareNullableString), + ); + }); + + it("reindexes trashed sibling locales when non-translatable media fields sync", async () => { + const en = await handleContentCreate(ctx.db, "posts", { + slug: "trashed-shared-en", + locale: "en", + data: { title: "Shared", hero: { id: mediaA.id, provider: "local" } }, + }); + if (!en.success) throw new Error("create en failed"); + const fr = await handleContentCreate(ctx.db, "posts", { + slug: "trashed-shared-fr", + locale: "fr", + translationOf: en.data.item.id, + data: { title: "Partage", hero: { id: mediaA.id, provider: "local" } }, + }); + if (!fr.success) throw new Error("create fr failed"); + + const deleted = await handleContentDelete(ctx.db, "posts", fr.data.item.id); + expect(deleted.success).toBe(true); + + const updated = await handleContentUpdate(ctx.db, "posts", en.data.item.id, { + data: { hero: { id: mediaB.id, provider: "local" } }, + }); + expect(updated.success).toBe(true); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + const mediaBUsage = await usageRepo.findCurrentByMediaId(mediaB.id); + expect(mediaBUsage.map((usage) => usage.contentId).toSorted(compareNullableString)).toEqual( + [en.data.item.id, fr.data.item.id].toSorted(compareNullableString), + ); + expect( + mediaBUsage.find((usage) => usage.contentId === fr.data.item.id)?.contentDeletedAt, + ).toBeTruthy(); + }); + + it("indexes media usage for plugin content writes", async () => { + const content = createContentAccessWithWrite(ctx.db); + const created = await content.create("posts", { + title: "Plugin Entry", + hero: { id: mediaA.id, provider: "local" }, + }); + + expect((await usageRepo.findCurrentByMediaId(mediaA.id))[0]?.contentId).toBe(created.id); + + await content.update("posts", created.id, { hero: { id: mediaB.id, provider: "local" } }); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + const mediaBUsage = await usageRepo.findCurrentByMediaId(mediaB.id); + expect(mediaBUsage).toHaveLength(1); + expect(mediaBUsage[0]?.contentId).toBe(created.id); + + expect(await content.delete("posts", created.id)).toBe(true); + expect((await usageRepo.findCurrentByMediaId(mediaB.id))[0]?.contentDeletedAt).toBeTruthy(); + }); + + it("indexes media usage for seed content create and update", async () => { + await applySeed( + ctx.db, + { + version: "1", + content: { + posts: [ + { + id: "seed-post", + slug: "seed-post", + status: "published", + data: { title: "Seed Post", hero: { id: mediaA.id, provider: "local" } }, + }, + ], + }, + }, + { includeContent: true }, + ); + + let mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.state).toBe("live"); + + await applySeed( + ctx.db, + { + version: "1", + content: { + posts: [ + { + id: "seed-post", + slug: "seed-post", + status: "published", + data: { title: "Seed Post", hero: { id: mediaB.id, provider: "local" } }, + }, + ], + }, + }, + { includeContent: true, onConflict: "update" }, + ); + + mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(0); + const mediaBUsage = await usageRepo.findCurrentByMediaId(mediaB.id); + expect(mediaBUsage).toHaveLength(1); + expect(mediaBUsage[0]?.state).toBe("live"); + }); + + it("updates live usage when restoring a revision through the handler path", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + const published = await handleContentPublish(ctx.db, "posts", created.data.item.id); + expect(published.success).toBe(true); + const revisionRepo = new RevisionRepository(ctx.db); + const revision = await revisionRepo.create({ + collection: "posts", + entryId: created.data.item.id, + data: { title: "Restored", hero: { id: mediaB.id, provider: "local" } }, + }); + + const restored = await handleRevisionRestore(ctx.db, revision.id, "user1"); + expect(restored.success).toBe(true); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + const mediaBUsage = await usageRepo.findCurrentByMediaId(mediaB.id); + expect(mediaBUsage).toHaveLength(1); + expect(mediaBUsage[0]?.state).toBe("live"); + }); + + it("moves published usage to draft when content is unpublished", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + const published = await handleContentPublish(ctx.db, "posts", created.data.item.id); + expect(published.success).toBe(true); + + const unpublished = await handleContentUnpublish(ctx.db, "posts", created.data.item.id); + expect(unpublished.success).toBe(true); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.state).toBe("draft"); + }); + + it("keeps draft usage when published content without revisions is unpublished", async () => { + const created = await handleContentCreate(ctx.db, "posts", { + slug: "direct-published", + status: "published", + data: { title: "Published", hero: { id: mediaA.id, provider: "local" } }, + }); + if (!created.success) throw new Error("create failed"); + expect(created.data.item.liveRevisionId).toBeNull(); + expect(created.data.item.draftRevisionId).toBeNull(); + + const unpublished = await handleContentUnpublish(ctx.db, "posts", created.data.item.id); + expect(unpublished.success).toBe(true); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.state).toBe("draft"); + }); + + it("refreshes usage status metadata on schedule and unschedule", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const scheduledAt = new Date(Date.now() + 60_000).toISOString(); + const scheduled = await handleContentSchedule( + ctx.db, + "posts", + created.data.item.id, + scheduledAt, + ); + expect(scheduled.success).toBe(true); + let mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage[0]?.state).toBe("draft"); + expect(mediaAUsage[0]?.contentStatus).toBe("scheduled"); + + const unscheduled = await handleContentUnschedule(ctx.db, "posts", created.data.item.id); + expect(unscheduled.success).toBe(true); + mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage[0]?.contentStatus).toBe("draft"); + }); + + it("refreshes usage deleted metadata on soft delete and restore", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const deleted = await handleContentDelete(ctx.db, "posts", created.data.item.id); + expect(deleted.success).toBe(true); + let mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage[0]?.contentDeletedAt).toBeTruthy(); + + const restored = await handleContentRestore(ctx.db, "posts", created.data.item.id); + expect(restored.success).toBe(true); + mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage[0]?.contentDeletedAt).toBeNull(); + }); + + it("indexes media usage for duplicated content", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const duplicated = await handleContentDuplicate(ctx.db, "posts", created.data.item.id); + expect(duplicated.success).toBe(true); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage.map((usage) => usage.contentId).toSorted(compareNullableString)).toEqual( + [created.data.item.id, duplicated.data.item.id].toSorted(compareNullableString), + ); + }); + + it("clears usage when a media-bearing field is deleted", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + await new SchemaRegistry(ctx.db).deleteField("posts", "hero"); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + expect(await usageRepo.findCurrentByMediaId(fileMedia.id)).toHaveLength(1); + }); + + it("indexes existing rows when adding a media field with a default value", async () => { + const created = await handleContentCreate(ctx.db, "posts", { + slug: "default-media-field", + data: { title: "Default Media Field" }, + }); + expect(created.success).toBe(true); + + await new SchemaRegistry(ctx.db).createField("posts", { + slug: "default_hero", + label: "Default Hero", + type: "image", + required: true, + defaultValue: { id: mediaA.id, provider: "local" }, + }); + + const mediaAUsage = await usageRepo.findCurrentByMediaId(mediaA.id); + expect(mediaAUsage).toHaveLength(1); + expect(mediaAUsage[0]?.contentId).toBe(created.data.item.id); + expect(mediaAUsage[0]?.fieldPath).toBe("default_hero"); + }); + + it("clears usage when a media-bearing field changes to a non-media type", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + await new SchemaRegistry(ctx.db).updateField("posts", "hero", { type: "string" }); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + }); + + it("clears usage when a collection is force-deleted", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + await new SchemaRegistry(ctx.db).deleteCollection("posts", { force: true }); + + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + }); + + it("clears usage on permanent delete", async () => { + const created = await createPostWithMedia(mediaA.id); + if (!created.success) throw new Error("create failed"); + + const deleted = await handleContentDelete(ctx.db, "posts", created.data.item.id); + expect(deleted.success).toBe(true); + + const permanentlyDeleted = await handleContentPermanentDelete( + ctx.db, + "posts", + created.data.item.id, + ); + expect(permanentlyDeleted.success).toBe(true); + expect(await usageRepo.findCurrentByMediaId(mediaA.id)).toHaveLength(0); + }); + + function createPostWithMedia(heroMediaId: string) { + return handleContentCreate(ctx.db, "posts", { + slug: `post-${heroMediaId}`, + data: { + title: "Hello", + hero: { id: heroMediaId, provider: "local" }, + attachment: { id: fileMedia.id, provider: "local" }, + body: [ + { + _type: "image", + _key: "body-image", + asset: { _ref: bodyMedia.id }, + }, + ], + gallery: [{ image: { id: galleryMedia.id, provider: "local" } }], + }, + }); + } +}); diff --git a/packages/core/tests/integration/database/media-usage-index.test.ts b/packages/core/tests/integration/database/media-usage-index.test.ts new file mode 100644 index 000000000..7e18a4db6 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-index.test.ts @@ -0,0 +1,141 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("Media usage index schema", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("creates usage source and usage tables", async () => { + for (const table of ["_emdash_media_usage_sources", "_emdash_media_usage"] as const) { + const rows = await ctx.db + .selectFrom(table as keyof Database) + .selectAll() + .execute(); + expect(Array.isArray(rows), `table ${table} should exist`).toBe(true); + } + }); + + it("accepts a source row and a usage occurrence", async () => { + await ctx.db + .insertInto("_emdash_media_usage_sources") + .values({ + source_key: "content:posts:entry1:live", + source_type: "content", + collection: "posts", + content_id: "entry1", + content_slug: "hello", + locale: "en", + translation_group: "entry1", + content_status: "published", + content_deleted_at: null, + state: "live", + revision_id: null, + current_generation: "gen1", + }) + .execute(); + + await ctx.db + .insertInto("_emdash_media_usage") + .values({ + id: "usage1", + source_key: "content:posts:entry1:live", + generation: "gen1", + media_id: "media1", + provider: "local", + provider_asset_id: "media1", + media_kind: "image", + mime_type: "image/jpeg", + reference_type: "image_field", + field_path: "hero", + sort_order: 0, + }) + .execute(); + + const rows = await ctx.db + .selectFrom("_emdash_media_usage") + .selectAll() + .where("media_id", "=", "media1") + .execute(); + expect(rows).toHaveLength(1); + }); + + it("creates expected sqlite indexes", async () => { + if (ctx.dialect !== "sqlite") return; + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' + `.execute(ctx.db); + const names = new Set(result.rows.map((row) => row.name)); + + for (const indexName of [ + "idx__emdash_media_usage_sources_content", + "idx__emdash_media_usage_sources_translation_group", + "idx__emdash_media_usage_sources_state", + "idx__emdash_media_usage_media", + "idx__emdash_media_usage_provider_asset", + "idx__emdash_media_usage_source_generation", + ]) { + expect(names.has(indexName), `missing index ${indexName}`).toBe(true); + } + }); + + it("down() drops tables and up() recreates them", async () => { + const { down, up } = await import("../../../src/database/migrations/046_media_usage_index.js"); + + await down(ctx.db); + await expect(sql`SELECT 1 FROM _emdash_media_usage`.execute(ctx.db)).rejects.toThrow(); + await expect(sql`SELECT 1 FROM _emdash_media_usage_sources`.execute(ctx.db)).rejects.toThrow(); + + await up(ctx.db); + const rows = await ctx.db.selectFrom("_emdash_media_usage_sources").selectAll().execute(); + expect(Array.isArray(rows)).toBe(true); + }); + + it("backfills existing content usage when migration runs", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "articles", label: "Articles" }); + await registry.createField("articles", { slug: "title", label: "Title", type: "string" }); + await registry.createField("articles", { slug: "hero", label: "Hero", type: "image" }); + + const media = await new MediaRepository(ctx.db).create({ + filename: "hero.jpg", + mimeType: "image/jpeg", + storageKey: "hero.jpg", + }); + const content = await new ContentRepository(ctx.db).create({ + type: "articles", + slug: "migration-backfill", + status: "published", + data: { title: "Migration Backfill", hero: { id: media.id, provider: "local" } }, + }); + + const { down, up } = await import("../../../src/database/migrations/046_media_usage_index.js"); + await down(ctx.db); + await up(ctx.db); + + const usage = await new MediaUsageRepository(ctx.db).findCurrentByMediaId(media.id); + expect(usage).toHaveLength(1); + expect(usage[0]?.contentId).toBe(content.id); + expect(usage[0]?.state).toBe("live"); + expect(usage[0]?.fieldPath).toBe("hero"); + }); +}); diff --git a/packages/core/tests/integration/database/media-usage-repository.test.ts b/packages/core/tests/integration/database/media-usage-repository.test.ts new file mode 100644 index 000000000..50709e251 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-repository.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + MediaUsageRepository, + MEDIA_USAGE_INSERT_CHUNK_SIZE, + MEDIA_USAGE_INSERT_VALUES_PER_ROW, +} from "../../../src/database/repositories/media-usage.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("MediaUsageRepository", (dialect) => { + let ctx: DialectTestContext; + let repo: MediaUsageRepository; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + repo = new MediaUsageRepository(ctx.db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("replaces current usage for one content source", async () => { + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + contentSlug: "hello", + locale: "en", + translationGroup: "entry1", + contentStatus: "draft", + state: "live", + references: [localImageRef("media_a")], + }); + + expect(await repo.findCurrentByMediaId("media_a")).toHaveLength(1); + + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + contentSlug: "hello", + locale: "en", + translationGroup: "entry1", + contentStatus: "draft", + state: "live", + references: [localImageRef("media_b")], + }); + + expect(await repo.findCurrentByMediaId("media_a")).toHaveLength(0); + const mediaB = await repo.findCurrentByMediaId("media_b"); + expect(mediaB).toHaveLength(1); + expect(mediaB[0]?.fieldPath).toBe("hero"); + expect(mediaB[0]?.provider).toBe("local"); + expect(mediaB[0]?.providerAssetId).toBe("media_b"); + }); + + it("indexes structured provider asset references", async () => { + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + contentSlug: "hello", + state: "draft", + references: [ + { + mediaId: null, + provider: "mux", + providerAssetId: "asset_1", + mediaKind: "video", + mimeType: "video/mp4", + referenceType: "file_field", + fieldPath: "trailer", + }, + ], + }); + + expect(await repo.findCurrentByMediaId("asset_1")).toHaveLength(0); + const usage = await repo.findCurrentByProviderAsset("mux", "asset_1"); + expect(usage).toHaveLength(1); + expect(usage[0]?.mediaId).toBeNull(); + expect(usage[0]?.mediaKind).toBe("video"); + expect(usage[0]?.mimeType).toBe("video/mp4"); + }); + + it("keeps live and draft sources separate", async () => { + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + contentSlug: "hello", + state: "live", + references: [localImageRef("media_live")], + }); + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + contentSlug: "hello", + state: "draft", + references: [localImageRef("media_draft")], + }); + + expect((await repo.findCurrentByMediaId("media_live"))[0]?.state).toBe("live"); + expect((await repo.findCurrentByMediaId("media_draft"))[0]?.state).toBe("draft"); + }); + + it("keeps usage insert batches within D1's bind limit", async () => { + expect(MEDIA_USAGE_INSERT_CHUNK_SIZE).toBe(9); + expect(MEDIA_USAGE_INSERT_CHUNK_SIZE * MEDIA_USAGE_INSERT_VALUES_PER_ROW).toBeLessThanOrEqual( + 100, + ); + + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + contentSlug: "hello", + state: "draft", + references: Array.from({ length: 10 }, (_, index) => ({ + ...localImageRef(`media_${index}`), + fieldPath: `gallery[${index}]`, + })), + }); + + for (let index = 0; index < 10; index++) { + expect(await repo.findCurrentByMediaId(`media_${index}`)).toHaveLength(1); + } + }); + + it("empty replacements clear current usage for that source", async () => { + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + state: "live", + references: [localImageRef("media_a")], + }); + + await repo.replaceContentUsage({ + collection: "posts", + contentId: "entry1", + state: "live", + references: [], + }); + + expect(await repo.findCurrentByMediaId("media_a")).toHaveLength(0); + }); + + it("stale generation cleanup preserves the source's current generation", async () => { + const sourceKey = MediaUsageRepository.contentSourceKey("posts", "entry1", "live"); + await ctx.db + .insertInto("_emdash_media_usage_sources") + .values({ + source_key: sourceKey, + source_type: "content", + collection: "posts", + content_id: "entry1", + content_slug: "hello", + state: "live", + current_generation: "gen_new", + }) + .execute(); + await ctx.db + .insertInto("_emdash_media_usage") + .values([ + { + id: "usage_old", + source_key: sourceKey, + generation: "gen_old", + media_id: "media_old", + provider: "local", + provider_asset_id: "media_old", + reference_type: "image_field", + field_path: "hero", + }, + { + id: "usage_new", + source_key: sourceKey, + generation: "gen_new", + media_id: "media_new", + provider: "local", + provider_asset_id: "media_new", + reference_type: "image_field", + field_path: "hero", + }, + ]) + .execute(); + + await repo.deleteStaleGenerationsForSource(sourceKey); + + const staleRows = await ctx.db + .selectFrom("_emdash_media_usage") + .select("id") + .where("id", "=", "usage_old") + .execute(); + expect(staleRows).toHaveLength(0); + expect(await repo.findCurrentByMediaId("media_new")).toHaveLength(1); + }); +}); + +function localImageRef(mediaId: string) { + return { + mediaId, + provider: "local", + providerAssetId: mediaId, + mediaKind: "image" as const, + mimeType: null, + referenceType: "image_field" as const, + fieldPath: "hero", + }; +} diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 91105ac8f..7b5c56cf7 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -128,6 +128,7 @@ describe("Database Migrations (Integration)", () => { "043_content_references", "044_comment_reactions", "045_taxonomy_parent_group", + "046_media_usage_index", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/unit/media/usage-extractor.test.ts b/packages/core/tests/unit/media/usage-extractor.test.ts new file mode 100644 index 000000000..c12160497 --- /dev/null +++ b/packages/core/tests/unit/media/usage-extractor.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { + extractContentMediaUsage, + type MediaUsageIndexedField, +} from "../../../src/media/usage-extractor.js"; + +const fields: MediaUsageIndexedField[] = [ + { slug: "hero", type: "image" }, + { slug: "attachment", type: "file" }, + { + slug: "gallery", + type: "repeater", + validation: { subFields: [{ slug: "image", label: "Image", type: "image" }] }, + }, + { slug: "body", type: "portableText" }, +]; + +describe("extractContentMediaUsage", () => { + it("extracts local media references from supported field shapes", () => { + const refs = extractContentMediaUsage(fields, { + hero: { id: "media_hero", provider: "local" }, + attachment: { id: "media_file", mimeType: "application/pdf" }, + gallery: [{ image: { id: "media_gallery", provider: "local" } }], + body: [ + { + _type: "image", + _key: "image1", + asset: { _ref: "media_body", url: "/_emdash/api/media/file/key.jpg" }, + }, + ], + }); + + expect(refs).toEqual([ + { + mediaId: "media_hero", + provider: "local", + providerAssetId: "media_hero", + mediaKind: "image", + mimeType: null, + referenceType: "image_field", + fieldPath: "hero", + }, + { + mediaId: "media_file", + provider: "local", + providerAssetId: "media_file", + mediaKind: "document", + mimeType: "application/pdf", + referenceType: "file_field", + fieldPath: "attachment", + }, + { + mediaId: "media_gallery", + provider: "local", + providerAssetId: "media_gallery", + mediaKind: "image", + mimeType: null, + referenceType: "repeater_image_subfield", + fieldPath: "gallery[0].image", + }, + { + mediaId: "media_body", + provider: "local", + providerAssetId: "media_body", + mediaKind: "image", + mimeType: null, + referenceType: "portable_text_image", + fieldPath: "body[0].asset._ref", + }, + ]); + }); + + it("extracts structured non-local provider references", () => { + const refs = extractContentMediaUsage(fields, { + hero: { id: "cf_image_1", provider: "cloudflare-images", mimeType: "image/webp" }, + attachment: { id: "mux_asset_1", provider: "mux", mimeType: "video/mp4" }, + body: [ + { + _type: "image", + _key: "provider-image", + asset: { _ref: "remote_img_1", provider: "cloudinary", mimeType: "image/jpeg" }, + }, + ], + }); + + expect(refs).toMatchObject([ + { + mediaId: null, + provider: "cloudflare-images", + providerAssetId: "cf_image_1", + mediaKind: "image", + mimeType: "image/webp", + }, + { + mediaId: null, + provider: "mux", + providerAssetId: "mux_asset_1", + mediaKind: "video", + mimeType: "video/mp4", + }, + { + mediaId: null, + provider: "cloudinary", + providerAssetId: "remote_img_1", + mediaKind: "image", + mimeType: "image/jpeg", + }, + ]); + }); + + it("ignores external URLs and malformed media values", () => { + const refs = extractContentMediaUsage(fields, { + hero: { id: "", provider: "cloudflare-images" }, + attachment: "https://example.com/file.pdf", + gallery: [{ image: { id: 123, provider: "local" } }], + body: [ + { _type: "image", _key: "external", asset: { _ref: "https://example.com/a.jpg" } }, + { _type: "image", _key: "url", asset: { url: "https://example.com/image.jpg" } }, + ], + }); + + expect(refs).toEqual([]); + }); + + it("dedupes repeated references at the same field path", () => { + const refs = extractContentMediaUsage(fields, { + hero: { id: "media_hero", provider: "local" }, + body: [], + }); + + expect(refs).toHaveLength(1); + }); +}); diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index 297e616ca..e0fd40c6f 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -120,7 +120,7 @@ "select * from \"_emdash_migrations\" limit ?": 1, "select * from \"_emdash_redirects\" where \"enabled\" = ?": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, "select count(*) as \"count\" from \"_emdash_collections\"": 1, "SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1, "UPDATE _emdash_cron_tasks SET status = 'idle', locked_at = NULL WHERE status = 'running' AND locked_at < ?": 1 @@ -131,7 +131,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 }, "GET /posts (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, @@ -177,7 +177,7 @@ "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC LIMIT ?": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, "select count(\"id\") as \"count\" from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ?": 1, "select count(*) as \"count\" from \"_emdash_collections\"": 1, "SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1, @@ -196,7 +196,7 @@ "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC LIMIT ?": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, "select count(\"id\") as \"count\" from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ?": 1 }, "GET /rss.xml (cold)": { diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index ce1f0152a..875a298a1 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -78,7 +78,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 }, "GET /pages/about (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, @@ -86,7 +86,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1 }, "GET /posts (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, @@ -117,7 +117,7 @@ "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC LIMIT ?": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, "select count(\"id\") as \"count\" from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ?": 1 }, "GET /posts/building-for-the-long-term (warm)": { @@ -133,7 +133,7 @@ "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_posts\".id AND t.locale = \"ec_posts\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_posts\".id AND b.locale = \"ec_posts\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"published_at\" DESC, \"id\" DESC LIMIT ?": 1, - "SELECT c.*, \"s\".\"seo_title\" AS \"_emdash_seo_title\", \"s\".\"seo_description\" AS \"_emdash_seo_description\", \"s\".\"seo_image\" AS \"_emdash_seo_image\", \"s\".\"seo_canonical\" AS \"_emdash_seo_canonical\", \"s\".\"seo_no_index\" AS \"_emdash_seo_no_index\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c LEFT JOIN \"_emdash_seo\" AS s ON s.collection = ? AND s.content_id = c.id WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, + "SELECT c.*, (SELECT json_object('seo_title', s.seo_title, 'seo_description', s.seo_description, 'seo_image', s.seo_image, 'seo_canonical', s.seo_canonical, 'seo_no_index', s.seo_no_index) FROM \"_emdash_seo\" AS s WHERE s.collection = ? AND s.content_id = \"c\".id LIMIT 1) AS \"_emdash_seo\", (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"c\".id AND t.locale = \"c\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"c\".id AND b.locale = \"c\".locale) AS \"_emdash_bylines\" FROM \"ec_posts\" AS c WHERE c.deleted_at IS NULL AND (c.slug = ? OR c.id = ?) LIMIT 1": 1, "select count(\"id\") as \"count\" from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ?": 1 }, "GET /rss.xml (cold)": {