b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
866 lines
24 KiB
TypeScript
866 lines
24 KiB
TypeScript
import { sql } from "kysely";
|
|
import { ulid } from "ulidx";
|
|
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
|
|
import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js";
|
|
import { RevisionRepository } from "../../../src/database/repositories/revision.js";
|
|
import {
|
|
CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
deleteContentMediaUsage,
|
|
markContentMediaUsageCollectionStale,
|
|
refreshContentMediaUsage,
|
|
} from "../../../src/media/usage/content-refresh.js";
|
|
import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import {
|
|
describeEachDialect,
|
|
setupForDialect,
|
|
teardownForDialect,
|
|
type DialectTestContext,
|
|
} from "../../utils/test-db.js";
|
|
|
|
describeEachDialect("content media usage refresh", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
let registry: SchemaRegistry;
|
|
let usageRepo: MediaUsageRepository;
|
|
let revisionRepo: RevisionRepository;
|
|
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialect(dialect);
|
|
registry = new SchemaRegistry(ctx.db);
|
|
usageRepo = new MediaUsageRepository(ctx.db);
|
|
revisionRepo = new RevisionRepository(ctx.db);
|
|
|
|
await registry.createCollection({ slug: "posts", label: "Posts" });
|
|
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
|
|
await registry.createField("posts", { slug: "hero", label: "Hero", type: "image" });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("refreshes a columns source and replaces its current usage", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "hello-world",
|
|
status: "published",
|
|
data: {
|
|
title: "Hello World",
|
|
hero: { id: "media-old", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
const columnsKey = sourceKey(item.id, "columns");
|
|
|
|
const first = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(first).toEqual({
|
|
success: true,
|
|
refreshedSourceCount: 1,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 0,
|
|
});
|
|
const firstSource = await usageRepo.findSource(columnsKey);
|
|
expect(firstSource).toEqual(
|
|
expect.objectContaining({
|
|
sourceKey: columnsKey,
|
|
sourceCompleteness: "complete",
|
|
contentTitle: "Hello World",
|
|
sourceFingerprint: expect.stringMatching(/^[a-f0-9]{16}$/),
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-old")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceKey: columnsKey }),
|
|
occurrence: expect.objectContaining({ fieldPath: "hero", mediaId: "media-old" }),
|
|
}),
|
|
]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
lastErrorCode: "CONTENT_USAGE_STALE",
|
|
}),
|
|
);
|
|
|
|
await updatePostHero(ctx, item.id, {
|
|
id: "media-new",
|
|
provider: "local",
|
|
mimeType: "image/webp",
|
|
});
|
|
const second = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(second).toEqual({
|
|
success: true,
|
|
refreshedSourceCount: 1,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 0,
|
|
});
|
|
expect((await usageRepo.findSource(columnsKey))?.currentGeneration).not.toBe(
|
|
firstSource?.currentGeneration,
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-old")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-new")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceKey: columnsKey }),
|
|
occurrence: expect.objectContaining({ fieldPath: "hero", mediaId: "media-new" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("refreshes columns and draft overlay sources when a draft exists", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "live-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Live Title",
|
|
hero: { id: "media-live", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
const draft = await revisionRepo.create({
|
|
collection: "posts",
|
|
entryId: item.id,
|
|
data: {
|
|
title: "Draft Title",
|
|
hero: { id: "media-draft", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
await setDraftRevision(ctx, item.id, draft.id);
|
|
|
|
const result = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
refreshedSourceCount: 2,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 0,
|
|
});
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "columns"))).toEqual(
|
|
expect.objectContaining({ sourceVariant: "columns", contentTitle: "Live Title" }),
|
|
);
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toEqual(
|
|
expect.objectContaining({
|
|
sourceVariant: "draft_overlay",
|
|
contentTitle: "Draft Title",
|
|
revisionId: draft.id,
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([
|
|
expect.objectContaining({
|
|
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("deletes a stale draft overlay source after a successful columns-only refresh", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "live-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Live Title",
|
|
hero: { id: "media-live", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
const draft = await revisionRepo.create({
|
|
collection: "posts",
|
|
entryId: item.id,
|
|
data: { hero: { id: "media-draft", provider: "local", mimeType: "image/webp" } },
|
|
});
|
|
await setDraftRevision(ctx, item.id, draft.id);
|
|
await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).not.toBeNull();
|
|
|
|
await clearDraftRevision(ctx, item.id);
|
|
const result = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
refreshedSourceCount: 1,
|
|
deletedSourceCount: 1,
|
|
failedSourceCount: 0,
|
|
});
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "columns"))).not.toBeNull();
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toBeNull();
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([]);
|
|
});
|
|
|
|
it("marks coverage stale instead of clobbering a newer source generation", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "guarded-replace-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Guarded Replace Post",
|
|
hero: { id: "media-old", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
await installSourceReplacementConflictTrigger(ctx);
|
|
await updatePostHero(ctx, item.id, {
|
|
id: "media-stale-refresh",
|
|
provider: "local",
|
|
mimeType: "image/webp",
|
|
});
|
|
|
|
const result = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
refreshedSourceCount: 0,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 0,
|
|
errorCode: "CONTENT_USAGE_GENERATION_CONFLICT",
|
|
});
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-concurrent-generation")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ contentId: item.id }) }),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-stale-refresh")).toEqual([]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
lastErrorCode: "CONTENT_USAGE_GENERATION_CONFLICT",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("retries a replace generation conflict before marking coverage stale", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "retry-guarded-replace-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Retry Guarded Replace Post",
|
|
hero: { id: "media-old", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
await installOneTimeSourceReplacementConflictTrigger(ctx);
|
|
await updatePostHero(ctx, item.id, {
|
|
id: "media-after-retry",
|
|
provider: "local",
|
|
mimeType: "image/webp",
|
|
});
|
|
|
|
const result = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
refreshedSourceCount: 1,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 0,
|
|
});
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-after-retry")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ contentId: item.id }) }),
|
|
]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-concurrent-generation")).toEqual([]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).not.toEqual(expect.objectContaining({ lastErrorCode: "CONTENT_USAGE_GENERATION_CONFLICT" }));
|
|
});
|
|
|
|
it("does not delete a draft overlay source that changed after observation", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "guarded-delete-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Guarded Delete Post",
|
|
hero: { id: "media-live", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
const draft = await revisionRepo.create({
|
|
collection: "posts",
|
|
entryId: item.id,
|
|
data: { hero: { id: "media-draft", provider: "local", mimeType: "image/webp" } },
|
|
});
|
|
await setDraftRevision(ctx, item.id, draft.id);
|
|
await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
await clearDraftRevision(ctx, item.id);
|
|
await installDraftOverlayDeletionConflictTrigger(ctx);
|
|
|
|
const result = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
refreshedSourceCount: 1,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 0,
|
|
errorCode: "CONTENT_USAGE_GENERATION_CONFLICT",
|
|
});
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toEqual(
|
|
expect.objectContaining({
|
|
currentGeneration: expect.stringMatching(/^concurrent-draft-generation-/),
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-concurrent-draft-generation")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ contentId: item.id }) }),
|
|
]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
lastErrorCode: "CONTENT_USAGE_GENERATION_CONFLICT",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("deletes every source for a content item", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "live-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Live Title",
|
|
hero: { id: "media-live", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
const draft = await revisionRepo.create({
|
|
collection: "posts",
|
|
entryId: item.id,
|
|
data: { hero: { id: "media-draft", provider: "local", mimeType: "image/webp" } },
|
|
});
|
|
await setDraftRevision(ctx, item.id, draft.id);
|
|
await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
const result = await deleteContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
refreshedSourceCount: 0,
|
|
deletedSourceCount: 2,
|
|
failedSourceCount: 0,
|
|
});
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "columns"))).toBeNull();
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toBeNull();
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([]);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([]);
|
|
});
|
|
|
|
it("marks draft snapshot failures without replacing current usage", async () => {
|
|
const item = await insertPost(ctx, {
|
|
slug: "live-post",
|
|
status: "published",
|
|
data: {
|
|
title: "Live Title",
|
|
hero: { id: "media-live", provider: "local", mimeType: "image/webp" },
|
|
},
|
|
});
|
|
await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
const revisionId = ulid();
|
|
await sql`
|
|
INSERT INTO revisions (id, collection, entry_id, data, author_id)
|
|
VALUES (${revisionId}, ${"posts"}, ${item.id}, ${"{"}, ${null})
|
|
`.execute(ctx.db);
|
|
await setDraftRevision(ctx, item.id, revisionId);
|
|
|
|
const result = await refreshContentMediaUsage(ctx.db, "posts", item.id);
|
|
|
|
expect(result).toEqual({
|
|
success: false,
|
|
refreshedSourceCount: 0,
|
|
deletedSourceCount: 0,
|
|
failedSourceCount: 1,
|
|
errorCode: "DRAFT_REVISION_INVALID",
|
|
});
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "columns"))).toEqual(
|
|
expect.objectContaining({ sourceCompleteness: "complete" }),
|
|
);
|
|
expect(await usageRepo.findSource(sourceKey(item.id, "draft_overlay"))).toEqual(
|
|
expect.objectContaining({
|
|
sourceCompleteness: "failed",
|
|
lastErrorCode: "DRAFT_REVISION_INVALID",
|
|
}),
|
|
);
|
|
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([
|
|
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
|
]);
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
lastErrorCode: "DRAFT_REVISION_INVALID",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("marks collection coverage stale while preserving existing status metadata", async () => {
|
|
await usageRepo.upsertIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
status: "partial",
|
|
schemaVersion: 7,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
completedAt: "2026-01-01T00:00:02.000Z",
|
|
cursor: "cursor-1",
|
|
indexedSourceCount: 12,
|
|
failedSourceCount: 3,
|
|
lastErrorCode: "OLD_ERROR",
|
|
updatedAt: "2026-01-01T00:00:03.000Z",
|
|
});
|
|
|
|
await markContentMediaUsageCollectionStale(ctx.db, "posts", "SCHEMA_FIELD_CHANGED");
|
|
|
|
expect(
|
|
await usageRepo.findIndexStatus({
|
|
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
|
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
|
scopeKey: "posts",
|
|
}),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
status: "stale",
|
|
schemaVersion: 7,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
completedAt: "2026-01-01T00:00:02.000Z",
|
|
cursor: "cursor-1",
|
|
indexedSourceCount: 12,
|
|
failedSourceCount: 3,
|
|
lastErrorCode: "SCHEMA_FIELD_CHANGED",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
interface TestPostInput {
|
|
slug: string;
|
|
status: string;
|
|
locale?: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
interface TestPost {
|
|
id: string;
|
|
slug: string;
|
|
status: string;
|
|
translationGroup: string;
|
|
}
|
|
|
|
async function insertPost(ctx: DialectTestContext, input: TestPostInput): Promise<TestPost> {
|
|
const id = ulid();
|
|
const now = new Date().toISOString();
|
|
await sql`
|
|
INSERT INTO ${sql.ref("ec_posts")} (
|
|
id,
|
|
slug,
|
|
status,
|
|
created_at,
|
|
updated_at,
|
|
version,
|
|
locale,
|
|
translation_group,
|
|
title,
|
|
hero
|
|
) VALUES (
|
|
${id},
|
|
${input.slug},
|
|
${input.status},
|
|
${now},
|
|
${now},
|
|
${1},
|
|
${input.locale ?? "en"},
|
|
${id},
|
|
${serializeFieldValue(input.data.title)},
|
|
${serializeFieldValue(input.data.hero)}
|
|
)
|
|
`.execute(ctx.db);
|
|
|
|
return {
|
|
id,
|
|
slug: input.slug,
|
|
status: input.status,
|
|
translationGroup: id,
|
|
};
|
|
}
|
|
|
|
async function updatePostHero(
|
|
ctx: DialectTestContext,
|
|
contentId: string,
|
|
hero: Record<string, unknown>,
|
|
): Promise<void> {
|
|
await sql`
|
|
UPDATE ${sql.ref("ec_posts")}
|
|
SET hero = ${serializeFieldValue(hero)},
|
|
updated_at = ${new Date().toISOString()}
|
|
WHERE id = ${contentId}
|
|
`.execute(ctx.db);
|
|
}
|
|
|
|
async function setDraftRevision(
|
|
ctx: DialectTestContext,
|
|
contentId: string,
|
|
revisionId: string,
|
|
): Promise<void> {
|
|
await sql`
|
|
UPDATE ${sql.ref("ec_posts")}
|
|
SET draft_revision_id = ${revisionId},
|
|
updated_at = ${new Date().toISOString()}
|
|
WHERE id = ${contentId}
|
|
`.execute(ctx.db);
|
|
}
|
|
|
|
async function clearDraftRevision(ctx: DialectTestContext, contentId: string): Promise<void> {
|
|
await sql`
|
|
UPDATE ${sql.ref("ec_posts")}
|
|
SET draft_revision_id = ${null},
|
|
updated_at = ${new Date().toISOString()}
|
|
WHERE id = ${contentId}
|
|
`.execute(ctx.db);
|
|
}
|
|
|
|
async function installSourceReplacementConflictTrigger(ctx: DialectTestContext): Promise<void> {
|
|
if (ctx.dialect === "postgres") {
|
|
await sql`
|
|
CREATE FUNCTION media_usage_replace_conflict()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
conflict_generation text;
|
|
BEGIN
|
|
IF NEW.generation NOT LIKE 'concurrent-generation-%'
|
|
AND NEW.source_key LIKE 'content:posts:%:columns' THEN
|
|
conflict_generation := 'concurrent-generation-' || NEW.generation;
|
|
INSERT INTO _emdash_media_usage (
|
|
id,
|
|
source_key,
|
|
generation,
|
|
field_slug,
|
|
field_path,
|
|
occurrence_index,
|
|
reference_type,
|
|
media_id,
|
|
provider,
|
|
provider_asset_id,
|
|
media_kind,
|
|
mime_type,
|
|
created_at
|
|
) VALUES (
|
|
'concurrent-generation-occurrence-' || NEW.generation,
|
|
NEW.source_key,
|
|
conflict_generation,
|
|
'hero',
|
|
'hero',
|
|
0,
|
|
'image_field',
|
|
'media-concurrent-generation',
|
|
'local',
|
|
'media-concurrent-generation',
|
|
'image',
|
|
'image/webp',
|
|
'2026-01-01T00:00:00.000Z'
|
|
)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
|
|
UPDATE _emdash_media_usage_sources
|
|
SET current_generation = conflict_generation
|
|
WHERE source_key = NEW.source_key;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
`.execute(ctx.db);
|
|
await sql`
|
|
CREATE TRIGGER media_usage_replace_conflict
|
|
AFTER INSERT ON _emdash_media_usage
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION media_usage_replace_conflict()
|
|
`.execute(ctx.db);
|
|
return;
|
|
}
|
|
|
|
await sql`
|
|
CREATE TRIGGER media_usage_replace_conflict
|
|
AFTER INSERT ON _emdash_media_usage
|
|
WHEN NEW.generation NOT LIKE 'concurrent-generation-%'
|
|
AND NEW.source_key LIKE 'content:posts:%:columns'
|
|
BEGIN
|
|
INSERT INTO _emdash_media_usage (
|
|
id,
|
|
source_key,
|
|
generation,
|
|
field_slug,
|
|
field_path,
|
|
occurrence_index,
|
|
reference_type,
|
|
media_id,
|
|
provider,
|
|
provider_asset_id,
|
|
media_kind,
|
|
mime_type,
|
|
created_at
|
|
) VALUES (
|
|
'concurrent-generation-occurrence-' || NEW.generation,
|
|
NEW.source_key,
|
|
'concurrent-generation-' || NEW.generation,
|
|
'hero',
|
|
'hero',
|
|
0,
|
|
'image_field',
|
|
'media-concurrent-generation',
|
|
'local',
|
|
'media-concurrent-generation',
|
|
'image',
|
|
'image/webp',
|
|
'2026-01-01T00:00:00.000Z'
|
|
);
|
|
|
|
UPDATE _emdash_media_usage_sources
|
|
SET current_generation = 'concurrent-generation-' || NEW.generation
|
|
WHERE source_key = NEW.source_key;
|
|
END
|
|
`.execute(ctx.db);
|
|
}
|
|
|
|
async function installOneTimeSourceReplacementConflictTrigger(
|
|
ctx: DialectTestContext,
|
|
): Promise<void> {
|
|
if (ctx.dialect === "postgres") {
|
|
await sql`
|
|
CREATE FUNCTION media_usage_replace_conflict_once()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF NEW.generation <> 'concurrent-generation'
|
|
AND NEW.source_key LIKE 'content:posts:%:columns'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM _emdash_media_usage WHERE id = 'concurrent-generation-occurrence'
|
|
) THEN
|
|
INSERT INTO _emdash_media_usage (
|
|
id,
|
|
source_key,
|
|
generation,
|
|
field_slug,
|
|
field_path,
|
|
occurrence_index,
|
|
reference_type,
|
|
media_id,
|
|
provider,
|
|
provider_asset_id,
|
|
media_kind,
|
|
mime_type,
|
|
created_at
|
|
) VALUES (
|
|
'concurrent-generation-occurrence',
|
|
NEW.source_key,
|
|
'concurrent-generation',
|
|
'hero',
|
|
'hero',
|
|
0,
|
|
'image_field',
|
|
'media-concurrent-generation',
|
|
'local',
|
|
'media-concurrent-generation',
|
|
'image',
|
|
'image/webp',
|
|
'2026-01-01T00:00:00.000Z'
|
|
);
|
|
|
|
UPDATE _emdash_media_usage_sources
|
|
SET current_generation = 'concurrent-generation'
|
|
WHERE source_key = NEW.source_key;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
`.execute(ctx.db);
|
|
await sql`
|
|
CREATE TRIGGER media_usage_replace_conflict_once
|
|
AFTER INSERT ON _emdash_media_usage
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION media_usage_replace_conflict_once()
|
|
`.execute(ctx.db);
|
|
return;
|
|
}
|
|
|
|
await sql`
|
|
CREATE TRIGGER media_usage_replace_conflict_once
|
|
AFTER INSERT ON _emdash_media_usage
|
|
WHEN NEW.generation != 'concurrent-generation'
|
|
AND NEW.source_key LIKE 'content:posts:%:columns'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM _emdash_media_usage WHERE id = 'concurrent-generation-occurrence'
|
|
)
|
|
BEGIN
|
|
INSERT INTO _emdash_media_usage (
|
|
id,
|
|
source_key,
|
|
generation,
|
|
field_slug,
|
|
field_path,
|
|
occurrence_index,
|
|
reference_type,
|
|
media_id,
|
|
provider,
|
|
provider_asset_id,
|
|
media_kind,
|
|
mime_type,
|
|
created_at
|
|
) VALUES (
|
|
'concurrent-generation-occurrence',
|
|
NEW.source_key,
|
|
'concurrent-generation',
|
|
'hero',
|
|
'hero',
|
|
0,
|
|
'image_field',
|
|
'media-concurrent-generation',
|
|
'local',
|
|
'media-concurrent-generation',
|
|
'image',
|
|
'image/webp',
|
|
'2026-01-01T00:00:00.000Z'
|
|
);
|
|
|
|
UPDATE _emdash_media_usage_sources
|
|
SET current_generation = 'concurrent-generation'
|
|
WHERE source_key = NEW.source_key;
|
|
END
|
|
`.execute(ctx.db);
|
|
}
|
|
|
|
async function installDraftOverlayDeletionConflictTrigger(ctx: DialectTestContext): Promise<void> {
|
|
if (ctx.dialect === "postgres") {
|
|
await sql`
|
|
CREATE FUNCTION media_usage_draft_delete_conflict()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
draft_source_key text;
|
|
conflict_generation text;
|
|
BEGIN
|
|
IF NEW.generation NOT LIKE 'concurrent-draft-generation-%'
|
|
AND NEW.source_key LIKE 'content:posts:%:columns' THEN
|
|
draft_source_key := replace(NEW.source_key, ':columns', ':draft_overlay');
|
|
conflict_generation := 'concurrent-draft-generation-' || NEW.generation;
|
|
INSERT INTO _emdash_media_usage (
|
|
id,
|
|
source_key,
|
|
generation,
|
|
field_slug,
|
|
field_path,
|
|
occurrence_index,
|
|
reference_type,
|
|
media_id,
|
|
provider,
|
|
provider_asset_id,
|
|
media_kind,
|
|
mime_type,
|
|
created_at
|
|
) VALUES (
|
|
'concurrent-draft-generation-occurrence-' || NEW.generation,
|
|
draft_source_key,
|
|
conflict_generation,
|
|
'hero',
|
|
'hero',
|
|
0,
|
|
'image_field',
|
|
'media-concurrent-draft-generation',
|
|
'local',
|
|
'media-concurrent-draft-generation',
|
|
'image',
|
|
'image/webp',
|
|
'2026-01-01T00:00:00.000Z'
|
|
)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
|
|
UPDATE _emdash_media_usage_sources
|
|
SET current_generation = conflict_generation
|
|
WHERE source_key = draft_source_key;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
`.execute(ctx.db);
|
|
await sql`
|
|
CREATE TRIGGER media_usage_draft_delete_conflict
|
|
AFTER INSERT ON _emdash_media_usage
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION media_usage_draft_delete_conflict()
|
|
`.execute(ctx.db);
|
|
return;
|
|
}
|
|
|
|
await sql`
|
|
CREATE TRIGGER media_usage_draft_delete_conflict
|
|
AFTER INSERT ON _emdash_media_usage
|
|
WHEN NEW.generation NOT LIKE 'concurrent-draft-generation-%'
|
|
AND NEW.source_key LIKE 'content:posts:%:columns'
|
|
BEGIN
|
|
INSERT INTO _emdash_media_usage (
|
|
id,
|
|
source_key,
|
|
generation,
|
|
field_slug,
|
|
field_path,
|
|
occurrence_index,
|
|
reference_type,
|
|
media_id,
|
|
provider,
|
|
provider_asset_id,
|
|
media_kind,
|
|
mime_type,
|
|
created_at
|
|
) VALUES (
|
|
'concurrent-draft-generation-occurrence-' || NEW.generation,
|
|
replace(NEW.source_key, ':columns', ':draft_overlay'),
|
|
'concurrent-draft-generation-' || NEW.generation,
|
|
'hero',
|
|
'hero',
|
|
0,
|
|
'image_field',
|
|
'media-concurrent-draft-generation',
|
|
'local',
|
|
'media-concurrent-draft-generation',
|
|
'image',
|
|
'image/webp',
|
|
'2026-01-01T00:00:00.000Z'
|
|
);
|
|
|
|
UPDATE _emdash_media_usage_sources
|
|
SET current_generation = 'concurrent-draft-generation-' || NEW.generation
|
|
WHERE source_key = replace(NEW.source_key, ':columns', ':draft_overlay');
|
|
END
|
|
`.execute(ctx.db);
|
|
}
|
|
|
|
function sourceKey(contentId: string, sourceVariant: "columns" | "draft_overlay"): string {
|
|
return buildContentMediaUsageSourceKey({
|
|
collectionSlug: "posts",
|
|
contentId,
|
|
sourceVariant,
|
|
});
|
|
}
|
|
|
|
function serializeFieldValue(value: unknown): unknown {
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value === "object") return JSON.stringify(value);
|
|
return value;
|
|
}
|