chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Byline field registry — concurrent-mutation safety under the
|
||||
* parity-aware bookend (Phase 6 of #1174). Asserts post-state
|
||||
* correctness (every row lands, final version is even) rather than
|
||||
* exact +N increments — concurrent mutators can collapse one or both
|
||||
* bookends, which is fine per the registry's class JSDoc.
|
||||
*
|
||||
* Activate Postgres parity by exporting `EMDASH_TEST_PG=1` and pointing
|
||||
* `PG_CONNECTION_STRING` at a writable test database.
|
||||
*/
|
||||
|
||||
import { beforeEach, afterEach, expect, it } from "vitest";
|
||||
|
||||
import { BylineSchemaRegistry } from "../../../src/schema/byline-registry.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("BylineSchemaRegistry concurrency", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let registry: BylineSchemaRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
registry = new BylineSchemaRegistry(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("parallel createField calls all land their rows and the post-state version is even", async () => {
|
||||
const startVersion = await registry.getVersion();
|
||||
const slugs = Array.from({ length: 10 }, (_, i) => `field_${i}`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
slugs.map((slug) => registry.createField({ slug, label: slug, type: "string" })),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === "fulfilled").length).toBe(slugs.length);
|
||||
|
||||
const reloadedSlugs = (await registry.listFields()).map((f) => f.slug);
|
||||
for (const slug of slugs) {
|
||||
expect(reloadedSlugs).toContain(slug);
|
||||
}
|
||||
|
||||
const endVersion = await registry.getVersion();
|
||||
expect(endVersion).toBeGreaterThan(startVersion);
|
||||
expect(endVersion % 2).toBe(0);
|
||||
});
|
||||
|
||||
it("parallel updateField calls all land and post-state version is even", async () => {
|
||||
const field = await registry.createField({
|
||||
slug: "job_title",
|
||||
label: "Job title",
|
||||
type: "string",
|
||||
});
|
||||
const baseline = await registry.getVersion();
|
||||
|
||||
const labels = Array.from({ length: 10 }, (_, i) => `Label ${i}`);
|
||||
await Promise.allSettled(labels.map((label) => registry.updateField("job_title", { label })));
|
||||
|
||||
const after = await registry.getVersion();
|
||||
expect(after).toBeGreaterThan(baseline);
|
||||
expect(after % 2).toBe(0);
|
||||
|
||||
// And the final state is a single, well-defined row — no duplicate
|
||||
// definitions, label is one of the inputs.
|
||||
const reloaded = await registry.getField("job_title");
|
||||
expect(reloaded?.id).toBe(field.id);
|
||||
expect(labels).toContain(reloaded?.label);
|
||||
});
|
||||
|
||||
it("mixed parallel mutations all land and post-state version is even", async () => {
|
||||
await registry.createField({ slug: "a", label: "A", type: "string" });
|
||||
await registry.createField({ slug: "b", label: "B", type: "string" });
|
||||
const baseline = await registry.getVersion();
|
||||
|
||||
// 3 mutations in parallel: one create, one update, one reorder. None
|
||||
// of them conflict — they target different rows or properties.
|
||||
const ops: Array<Promise<unknown>> = [
|
||||
registry.createField({ slug: "c", label: "C", type: "string" }),
|
||||
registry.updateField("a", { label: "Aa" }),
|
||||
];
|
||||
|
||||
await Promise.all(ops);
|
||||
// Reorder runs serially — it reads the full set, so racing it
|
||||
// against parallel creates would make the assertion non-meaningful.
|
||||
await registry.reorderFields(["c", "a", "b"]);
|
||||
|
||||
const after = await registry.getVersion();
|
||||
expect(after).toBeGreaterThan(baseline);
|
||||
expect(after % 2).toBe(0);
|
||||
|
||||
const fields = await registry.listFields();
|
||||
expect(fields.map((f) => f.slug).toSorted()).toEqual(["a", "b", "c"]);
|
||||
expect(fields.find((f) => f.slug === "a")?.label).toBe("Aa");
|
||||
});
|
||||
|
||||
it("parallel deletes against distinct fields all land and post-state version is even", async () => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await registry.createField({ slug: `del_${i}`, label: `del_${i}`, type: "string" });
|
||||
}
|
||||
const baseline = await registry.getVersion();
|
||||
|
||||
await Promise.all(Array.from({ length: 6 }, (_, i) => registry.deleteField(`del_${i}`)));
|
||||
|
||||
const after = await registry.getVersion();
|
||||
expect(after).toBeGreaterThan(baseline);
|
||||
expect(after % 2).toBe(0);
|
||||
|
||||
const remaining = (await registry.listFields()).map((f) => f.slug);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
expect(remaining).not.toContain(`del_${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("createField duplicate slugs: one succeeds, the other surfaces FIELD_EXISTS", async () => {
|
||||
const baseline = await registry.getVersion();
|
||||
|
||||
// Fire two creates with the same slug. On SQLite/D1 (serialised
|
||||
// writes) one will land first and the second will hit the
|
||||
// FIELD_EXISTS check. On PG the same property holds via the UNIQUE
|
||||
// index on slug — the loser sees a UNIQUE constraint error, but the
|
||||
// registry's getField pre-check catches the racy case for most runs.
|
||||
// We assert the *outcome*: exactly one row exists and the version
|
||||
// counter advanced by at least one (the winner).
|
||||
const results = await Promise.allSettled([
|
||||
registry.createField({ slug: "dupe", label: "Dupe A", type: "string" }),
|
||||
registry.createField({ slug: "dupe", label: "Dupe B", type: "string" }),
|
||||
]);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled");
|
||||
expect(succeeded.length).toBeGreaterThanOrEqual(1);
|
||||
const rows = await registry.listFields();
|
||||
expect(rows.filter((f) => f.slug === "dupe")).toHaveLength(1);
|
||||
expect((await registry.getVersion()) - baseline).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("Content references schema", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect); // runs all migrations
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("creates _emdash_relations and _emdash_content_references", async () => {
|
||||
for (const table of ["_emdash_relations", "_emdash_content_references"] 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 relation row and an edge row with the expected columns", async () => {
|
||||
await ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({
|
||||
id: "rel_manages",
|
||||
name: "manages",
|
||||
parent_collection: "employees",
|
||||
child_collection: "employees",
|
||||
parent_label: "Manager",
|
||||
child_label: "Direct report",
|
||||
translation_group: "rel_manages",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values({
|
||||
id: "ref_1",
|
||||
relation_group: "rel_manages",
|
||||
parent_group: "grp_alice",
|
||||
child_group: "grp_bob",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const rel = await ctx.db
|
||||
.selectFrom("_emdash_relations")
|
||||
.selectAll()
|
||||
.where("name", "=", "manages")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.locale).toBe("en"); // default locale backfill
|
||||
expect(rel.child_collection).toBe("employees");
|
||||
|
||||
const edge = await ctx.db
|
||||
.selectFrom("_emdash_content_references")
|
||||
.selectAll()
|
||||
.where("id", "=", "ref_1")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(edge.relation_group).toBe("rel_manages");
|
||||
expect(edge.sort_order).toBe(0); // default
|
||||
});
|
||||
|
||||
it("rejects a duplicate edge (same relation, parent, child)", async () => {
|
||||
await ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values({ id: "e1", relation_group: "r1", parent_group: "p1", child_group: "c1" })
|
||||
.execute();
|
||||
|
||||
await expect(
|
||||
ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values({ id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c1" })
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a duplicate relation name within one locale, allows across locales", async () => {
|
||||
const base = {
|
||||
name: "manages",
|
||||
parent_collection: "employees",
|
||||
child_collection: "employees",
|
||||
parent_label: "Manager",
|
||||
child_label: "Report",
|
||||
translation_group: "tg1",
|
||||
};
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({ id: "r_en", locale: "en", ...base })
|
||||
.execute();
|
||||
|
||||
// Same (name, locale) -> rejected
|
||||
await expect(
|
||||
ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({ id: "r_en2", locale: "en", ...base })
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Same name, different locale -> allowed
|
||||
await ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({
|
||||
id: "r_fr",
|
||||
locale: "fr",
|
||||
...base,
|
||||
parent_label: "Responsable",
|
||||
child_label: "Subordonné",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const rows = await ctx.db
|
||||
.selectFrom("_emdash_relations")
|
||||
.select(["id", "locale"])
|
||||
.where("name", "=", "manages")
|
||||
.execute();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects a duplicate (translation_group, locale), allows across locales", async () => {
|
||||
const base = {
|
||||
parent_collection: "employees",
|
||||
child_collection: "employees",
|
||||
parent_label: "Manager",
|
||||
child_label: "Report",
|
||||
translation_group: "shared_tg",
|
||||
};
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({ id: "g_en", name: "manages", locale: "en", ...base })
|
||||
.execute();
|
||||
|
||||
// Same (translation_group, locale) -> rejected by the partial unique,
|
||||
// even though `name` differs.
|
||||
await expect(
|
||||
ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({ id: "g_en2", name: "leads", locale: "en", ...base })
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Same translation_group, different locale -> allowed.
|
||||
await ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({ id: "g_fr", name: "gere", locale: "fr", ...base })
|
||||
.execute();
|
||||
|
||||
const rows = await ctx.db
|
||||
.selectFrom("_emdash_relations")
|
||||
.select(["id", "locale"])
|
||||
.where("translation_group", "=", "shared_tg")
|
||||
.execute();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects a relation with a null translation_group", async () => {
|
||||
// translation_group is NOT NULL: a relation must be addressable by edges
|
||||
// (`_emdash_content_references.relation_group` is NOT NULL), so a null
|
||||
// group would be an unreferenceable, dead row.
|
||||
await expect(
|
||||
ctx.db
|
||||
.insertInto("_emdash_relations")
|
||||
.values({
|
||||
id: "n1",
|
||||
name: "manages",
|
||||
parent_collection: "employees",
|
||||
child_collection: "employees",
|
||||
parent_label: "Manager",
|
||||
child_label: "Report",
|
||||
locale: "en",
|
||||
translation_group: null as unknown as string,
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("forward and backlink traversal return the expected rows", async () => {
|
||||
// Parent p1 references children c1, c2 (ordered); p2 also references c1.
|
||||
await ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values([
|
||||
{ id: "e1", relation_group: "r1", parent_group: "p1", child_group: "c1", sort_order: 0 },
|
||||
{ id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c2", sort_order: 1 },
|
||||
{ id: "e3", relation_group: "r1", parent_group: "p2", child_group: "c1", sort_order: 0 },
|
||||
])
|
||||
.execute();
|
||||
|
||||
// Forward: p1's children for relation r1, ordered.
|
||||
const children = await ctx.db
|
||||
.selectFrom("_emdash_content_references")
|
||||
.select("child_group")
|
||||
.where("parent_group", "=", "p1")
|
||||
.where("relation_group", "=", "r1")
|
||||
.orderBy("sort_order")
|
||||
.execute();
|
||||
expect(children.map((r) => r.child_group)).toEqual(["c1", "c2"]);
|
||||
|
||||
// Backlink: who references c1 (any parent) for relation r1.
|
||||
const parents = await ctx.db
|
||||
.selectFrom("_emdash_content_references")
|
||||
.select("parent_group")
|
||||
.where("child_group", "=", "c1")
|
||||
.where("relation_group", "=", "r1")
|
||||
.orderBy("parent_group")
|
||||
.execute();
|
||||
expect(parents.map((r) => r.parent_group)).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("allows same-collection and self references", async () => {
|
||||
// Self reference: parent_group === child_group is permitted.
|
||||
await ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values({ id: "self1", relation_group: "r1", parent_group: "x1", child_group: "x1" })
|
||||
.execute();
|
||||
|
||||
const row = await ctx.db
|
||||
.selectFrom("_emdash_content_references")
|
||||
.selectAll()
|
||||
.where("id", "=", "self1")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.parent_group).toBe(row.child_group);
|
||||
});
|
||||
|
||||
it("creates the expected indexes (sqlite)", async () => {
|
||||
if (ctx.dialect !== "sqlite") return; // index introspection is dialect-specific
|
||||
|
||||
const result = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'index'
|
||||
`.execute(ctx.db);
|
||||
const names = new Set(result.rows.map((r) => r.name));
|
||||
|
||||
for (const idx of [
|
||||
"idx__emdash_relations_locale",
|
||||
"idx__emdash_relations_translation_group",
|
||||
"idx__emdash_relations_parent_collection",
|
||||
"idx__emdash_relations_child_collection",
|
||||
"idx__emdash_relations_group_locale_unique",
|
||||
"idx__emdash_content_references_parent",
|
||||
"idx__emdash_content_references_child",
|
||||
"idx__emdash_content_references_relation",
|
||||
]) {
|
||||
expect(names.has(idx), `missing index ${idx}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("down() drops both tables and up() can recreate them", async () => {
|
||||
const { down, up } = await import("../../../src/database/migrations/043_content_references.js");
|
||||
|
||||
await down(ctx.db);
|
||||
|
||||
// Tables gone: a raw query against them should reject.
|
||||
await expect(sql`SELECT 1 FROM _emdash_content_references`.execute(ctx.db)).rejects.toThrow();
|
||||
await expect(sql`SELECT 1 FROM _emdash_relations`.execute(ctx.db)).rejects.toThrow();
|
||||
|
||||
// Re-applying up() restores them.
|
||||
await up(ctx.db);
|
||||
const rows = await ctx.db.selectFrom("_emdash_relations").selectAll().execute();
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Dialect compatibility tests
|
||||
*
|
||||
* Runs core database operations against every available dialect.
|
||||
* SQLite always runs (in-memory). Postgres runs when EMDASH_TEST_PG is set.
|
||||
*
|
||||
* These tests verify that migrations, schema registry, and content CRUD
|
||||
* work identically across dialects.
|
||||
*/
|
||||
|
||||
import { it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { MIGRATION_COUNT } from "../../../src/database/migrations/runner.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import {
|
||||
createForDialect,
|
||||
describeEachDialect,
|
||||
getMigrationStatusForDialect,
|
||||
runMigrationsForDialect,
|
||||
setupForDialect,
|
||||
setupForDialectWithCollections,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeEachDialect("Migrations", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Bare database — no migrations yet. Tests run them explicitly.
|
||||
ctx = await createForDialect(dialect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("runs all migrations and creates system tables", async () => {
|
||||
await runMigrationsForDialect(ctx);
|
||||
|
||||
const tables = [
|
||||
"revisions",
|
||||
"taxonomies",
|
||||
"content_taxonomies",
|
||||
"media",
|
||||
"users",
|
||||
"options",
|
||||
"audit_logs",
|
||||
"_emdash_migrations",
|
||||
"_emdash_collections",
|
||||
"_emdash_fields",
|
||||
"_plugin_storage",
|
||||
"_plugin_state",
|
||||
"_plugin_indexes",
|
||||
"_emdash_sections",
|
||||
"_emdash_bylines",
|
||||
"_emdash_content_bylines",
|
||||
"_emdash_byline_fields",
|
||||
"_emdash_byline_field_values",
|
||||
"_emdash_byline_field_group_values",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
const result = await ctx.db
|
||||
.selectFrom(table as keyof Database)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(Array.isArray(result), `table ${table} should exist`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks migrations in _emdash_migrations", async () => {
|
||||
await runMigrationsForDialect(ctx);
|
||||
|
||||
const migrations = await ctx.db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(migrations).toHaveLength(MIGRATION_COUNT);
|
||||
expect(migrations[0]?.name).toBe("001_initial");
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await runMigrationsForDialect(ctx);
|
||||
await runMigrationsForDialect(ctx);
|
||||
|
||||
const migrations = await ctx.db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(migrations).toHaveLength(MIGRATION_COUNT);
|
||||
});
|
||||
|
||||
it("reports correct migration status", async () => {
|
||||
const before = await getMigrationStatusForDialect(ctx);
|
||||
expect(before.pending).toContain("001_initial");
|
||||
expect(before.applied).toHaveLength(0);
|
||||
|
||||
await runMigrationsForDialect(ctx);
|
||||
|
||||
const after = await getMigrationStatusForDialect(ctx);
|
||||
expect(after.applied).toContain("001_initial");
|
||||
expect(after.pending).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeEachDialect("Schema registry", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
await runMigrationsForDialect(ctx);
|
||||
registry = new SchemaRegistry(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("creates a collection and its dynamic table", async () => {
|
||||
await registry.createCollection({
|
||||
slug: "article",
|
||||
label: "Articles",
|
||||
labelSingular: "Article",
|
||||
});
|
||||
|
||||
// Dynamic table should exist
|
||||
const rows = await ctx.db
|
||||
.selectFrom("ec_article" as keyof Database)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
|
||||
// Registry should have the collection
|
||||
const collections = await registry.listCollections();
|
||||
expect(collections.map((c) => c.slug)).toContain("article");
|
||||
});
|
||||
|
||||
it("adds fields to a collection", async () => {
|
||||
await registry.createCollection({
|
||||
slug: "post",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("post", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
await registry.createField("post", {
|
||||
slug: "body",
|
||||
label: "Body",
|
||||
type: "portableText",
|
||||
});
|
||||
|
||||
await registry.createField("post", {
|
||||
slug: "views",
|
||||
label: "Views",
|
||||
type: "integer",
|
||||
});
|
||||
|
||||
const coll = await registry.getCollectionWithFields("post");
|
||||
expect(coll).not.toBeNull();
|
||||
const slugs = coll!.fields.map((f) => f.slug);
|
||||
expect(slugs).toContain("title");
|
||||
expect(slugs).toContain("body");
|
||||
expect(slugs).toContain("views");
|
||||
});
|
||||
|
||||
it("deletes a collection and drops its table", async () => {
|
||||
await registry.createCollection({
|
||||
slug: "temp",
|
||||
label: "Temp",
|
||||
labelSingular: "Temp",
|
||||
});
|
||||
|
||||
// Verify it exists
|
||||
const before = await registry.listCollections();
|
||||
expect(before.map((c) => c.slug)).toContain("temp");
|
||||
|
||||
await registry.deleteCollection("temp");
|
||||
|
||||
const after = await registry.listCollections();
|
||||
expect(after.map((c) => c.slug)).not.toContain("temp");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeEachDialect("Content CRUD", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let repo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialectWithCollections(dialect);
|
||||
repo = new ContentRepository(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("creates and retrieves content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "hello-world",
|
||||
data: {
|
||||
title: "Hello World",
|
||||
content: [{ _type: "block", children: [{ _type: "span", text: "Content" }] }],
|
||||
},
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
expect(created.id).toBeDefined();
|
||||
expect(created.slug).toBe("hello-world");
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.data.title).toBe("Hello World");
|
||||
expect(found!.slug).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("updates content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "original",
|
||||
data: { title: "Original" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated" },
|
||||
});
|
||||
|
||||
expect(updated.data.title).toBe("Updated");
|
||||
expect(updated.slug).toBe("original");
|
||||
});
|
||||
|
||||
it("lists content with pagination", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: `post-${i}`,
|
||||
data: { title: `Post ${i}` },
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await repo.findMany("post", { limit: 3 });
|
||||
expect(result.items).toHaveLength(3);
|
||||
|
||||
if (result.nextCursor) {
|
||||
const page2 = await repo.findMany("post", {
|
||||
limit: 3,
|
||||
cursor: result.nextCursor,
|
||||
});
|
||||
expect(page2.items).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-deletes content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "to-delete",
|
||||
data: { title: "To Delete" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const deleted = await repo.delete("post", created.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "draft-post",
|
||||
data: { title: "Draft Post" },
|
||||
status: "draft",
|
||||
});
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "published-post",
|
||||
data: { title: "Published Post" },
|
||||
status: "published",
|
||||
});
|
||||
|
||||
const drafts = await repo.findMany("post", { where: { status: "draft" } });
|
||||
expect(drafts.items).toHaveLength(1);
|
||||
expect(drafts.items[0]?.data.title).toBe("Draft Post");
|
||||
|
||||
const published = await repo.findMany("post", { where: { status: "published" } });
|
||||
expect(published.items).toHaveLength(1);
|
||||
expect(published.items[0]?.data.title).toBe("Published Post");
|
||||
});
|
||||
|
||||
it("enforces unique slug within a collection", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "same-slug",
|
||||
data: { title: "First" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.create({
|
||||
type: "post",
|
||||
slug: "same-slug",
|
||||
data: { title: "Second" },
|
||||
status: "draft",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("isolates collections", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "shared-slug",
|
||||
data: { title: "A Post" },
|
||||
status: "draft",
|
||||
});
|
||||
await repo.create({
|
||||
type: "page",
|
||||
slug: "shared-slug",
|
||||
data: { title: "A Page" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const posts = await repo.findMany("post");
|
||||
const pages = await repo.findMany("page");
|
||||
|
||||
expect(posts.items).toHaveLength(1);
|
||||
expect(pages.items).toHaveLength(1);
|
||||
expect(posts.items[0]?.data.title).toBe("A Post");
|
||||
expect(pages.items[0]?.data.title).toBe("A Page");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import { listTablesLike } from "../../../src/database/dialect-helpers.js";
|
||||
import {
|
||||
type DialectTestContext,
|
||||
describeEachDialect,
|
||||
setupForDialectWithCollections,
|
||||
teardownForDialect,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
// Regression: `listTablesLike` (and the migration column-existence checks)
|
||||
// must scope to the connection's active schema, not a hardcoded `public`.
|
||||
// On a Postgres deployment using a non-public schema — including the
|
||||
// per-test schemas this harness creates — hardcoding `public` returns tables
|
||||
// from the wrong schema, or none at all. The Postgres variant of this test
|
||||
// fails against the old `WHERE table_schema = 'public'` query.
|
||||
describeEachDialect("listTablesLike schema scoping", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialectWithCollections(dialect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("finds content tables in the active schema", async () => {
|
||||
const tables = await listTablesLike(ctx.db, "ec_%");
|
||||
expect(tables.toSorted()).toEqual(["ec_page", "ec_post"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing matches the pattern", async () => {
|
||||
const tables = await listTablesLike(ctx.db, "nonexistent_%");
|
||||
expect(tables).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { MediaRepository } from "../../../src/database/repositories/media.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
// #1221: the media library lacked filename search. The repository now accepts
|
||||
// a case-insensitive `q` substring filter against the filename.
|
||||
describeEachDialect("MediaRepository.findMany filename search (#1221)", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
await repo.create({
|
||||
filename: "Summer-Vacation.png",
|
||||
mimeType: "image/png",
|
||||
storageKey: "1.png",
|
||||
});
|
||||
await repo.create({
|
||||
filename: "invoice-2024.pdf",
|
||||
mimeType: "application/pdf",
|
||||
storageKey: "2.pdf",
|
||||
});
|
||||
await repo.create({ filename: "logo.svg", mimeType: "image/svg+xml", storageKey: "3.svg" });
|
||||
await repo.create({
|
||||
filename: "100%_complete.png",
|
||||
mimeType: "image/png",
|
||||
storageKey: "4.png",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("matches a filename substring case-insensitively", async () => {
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
const result = await repo.findMany({ q: "vacation" });
|
||||
expect(result.items.map((i) => i.filename)).toEqual(["Summer-Vacation.png"]);
|
||||
});
|
||||
|
||||
it("matches by extension", async () => {
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
const result = await repo.findMany({ q: ".pdf" });
|
||||
expect(result.items.map((i) => i.filename)).toEqual(["invoice-2024.pdf"]);
|
||||
});
|
||||
|
||||
it("combines with the mimeType filter", async () => {
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
const result = await repo.findMany({ q: "logo", mimeType: "image/" });
|
||||
expect(result.items.map((i) => i.filename)).toEqual(["logo.svg"]);
|
||||
});
|
||||
|
||||
it("treats LIKE wildcards in the query literally", async () => {
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
// "100%" must match only the literal "100%_complete.png", not every row.
|
||||
const result = await repo.findMany({ q: "100%" });
|
||||
expect(result.items.map((i) => i.filename)).toEqual(["100%_complete.png"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { MediaRepository } from "../../../src/database/repositories/media.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("MediaRepository.findMany mimeType filter", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
async function seedMedia() {
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
await repo.create({ filename: "a.png", mimeType: "image/png", storageKey: "a.png" });
|
||||
await repo.create({ filename: "b.jpg", mimeType: "image/jpeg", storageKey: "b.jpg" });
|
||||
await repo.create({ filename: "c.pdf", mimeType: "application/pdf", storageKey: "c.pdf" });
|
||||
await repo.create({ filename: "d.zip", mimeType: "application/zip", storageKey: "d.zip" });
|
||||
}
|
||||
|
||||
it("filters by a single MIME prefix (existing behavior)", async () => {
|
||||
await seedMedia();
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
const result = await repo.findMany({ mimeType: "image/" });
|
||||
expect(result.items.map((i) => i.mimeType).toSorted()).toEqual(["image/jpeg", "image/png"]);
|
||||
});
|
||||
|
||||
it("filters by an array of MIME entries (prefix + exact)", async () => {
|
||||
await seedMedia();
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
const result = await repo.findMany({
|
||||
mimeType: ["image/", "application/pdf"],
|
||||
});
|
||||
expect(result.items.map((i) => i.mimeType).toSorted()).toEqual([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list when none match", async () => {
|
||||
await seedMedia();
|
||||
const repo = new MediaRepository(ctx.db);
|
||||
const result = await repo.findMany({ mimeType: ["video/"] });
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import { IdentifierError } from "../../../src/database/validate.js";
|
||||
import {
|
||||
loadContentMediaUsageFields,
|
||||
MediaUsageFieldDiscoveryError,
|
||||
} from "../../../src/media/usage/content-fields.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("content media usage field discovery", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
registry = new SchemaRegistry(ctx.db);
|
||||
await registry.createCollection({ slug: "posts", label: "Posts" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("discovers V1 extraction fields and display fields separately", async () => {
|
||||
await registry.createField("posts", { slug: "name", label: "Name", type: "string" });
|
||||
await registry.createField("posts", { slug: "body", label: "Body", type: "portableText" });
|
||||
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
|
||||
await registry.createField("posts", { slug: "raw_data", label: "Raw Data", type: "json" });
|
||||
await registry.createField("posts", { slug: "attachment", label: "Attachment", type: "file" });
|
||||
await registry.createField("posts", { slug: "hero", label: "Hero", type: "image" });
|
||||
await registry.createField("posts", {
|
||||
slug: "sections",
|
||||
label: "Sections",
|
||||
type: "repeater",
|
||||
validation: {
|
||||
subFields: [
|
||||
{ slug: "caption", type: "text", label: "Caption" },
|
||||
{ slug: "image", type: "image", label: "Image" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const discovery = await loadContentMediaUsageFields(ctx.db, "posts");
|
||||
|
||||
expect(discovery.displayFieldSlugs).toEqual(["title", "name"]);
|
||||
expect(discovery.extractionFields).toEqual([
|
||||
{ slug: "attachment", type: "file" },
|
||||
{ slug: "body", type: "portableText" },
|
||||
{ slug: "hero", type: "image" },
|
||||
{
|
||||
slug: "sections",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "image", type: "image" }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters unsupported repeater subfields and excludes repeaters without images", async () => {
|
||||
await registry.createField("posts", {
|
||||
slug: "sections",
|
||||
label: "Sections",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] },
|
||||
});
|
||||
await registry.createField("posts", {
|
||||
slug: "downloads",
|
||||
label: "Downloads",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "placeholder", type: "image", label: "Placeholder" }] },
|
||||
});
|
||||
|
||||
await ctx.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({
|
||||
validation: JSON.stringify({
|
||||
subFields: [
|
||||
{ slug: "download", type: "file", label: "Download" },
|
||||
{ slug: "image", type: "image", label: "Image" },
|
||||
{ slug: "caption", type: "text", label: "Caption" },
|
||||
],
|
||||
}),
|
||||
})
|
||||
.where("slug", "=", "sections")
|
||||
.execute();
|
||||
await ctx.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({
|
||||
validation: JSON.stringify({
|
||||
subFields: [{ slug: "download", type: "file", label: "Download" }],
|
||||
}),
|
||||
})
|
||||
.where("slug", "=", "downloads")
|
||||
.execute();
|
||||
|
||||
const discovery = await loadContentMediaUsageFields(ctx.db, "posts");
|
||||
|
||||
expect(discovery.extractionFields).toEqual([
|
||||
{
|
||||
slug: "sections",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "image", type: "image" }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed on malformed repeater validation", async () => {
|
||||
await registry.createField("posts", {
|
||||
slug: "sections",
|
||||
label: "Sections",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] },
|
||||
});
|
||||
await ctx.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({ validation: "{" })
|
||||
.where("slug", "=", "sections")
|
||||
.execute();
|
||||
|
||||
await expect(loadContentMediaUsageFields(ctx.db, "posts")).rejects.toThrow(
|
||||
MediaUsageFieldDiscoveryError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects supported fields with invalid slugs before they can become column refs", async () => {
|
||||
const collection = await registry.getCollection("posts");
|
||||
expect(collection).not.toBeNull();
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_fields")
|
||||
.values({
|
||||
id: "invalid-media-field",
|
||||
collection_id: collection!.id,
|
||||
slug: "bad-slug",
|
||||
label: "Bad Slug",
|
||||
type: "image",
|
||||
column_type: "TEXT",
|
||||
required: 0,
|
||||
unique: 0,
|
||||
default_value: null,
|
||||
validation: null,
|
||||
widget: null,
|
||||
options: null,
|
||||
sort_order: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await expect(loadContentMediaUsageFields(ctx.db, "posts")).rejects.toThrow(IdentifierError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,865 @@
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,636 @@
|
||||
import { sql } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import { RevisionRepository } from "../../../src/database/repositories/revision.js";
|
||||
import { loadContentMediaUsageSnapshots } from "../../../src/media/usage/content-snapshots.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 snapshots", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let registry: SchemaRegistry;
|
||||
let revisionRepo: RevisionRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
registry = new SchemaRegistry(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" });
|
||||
await registry.createField("posts", { slug: "attachment", label: "Attachment", type: "file" });
|
||||
await registry.createField("posts", {
|
||||
slug: "sections",
|
||||
label: "Sections",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] },
|
||||
});
|
||||
await registry.createField("posts", { slug: "body", label: "Body", type: "portableText" });
|
||||
await registry.createField("posts", { slug: "raw_data", label: "Raw Data", type: "json" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("builds a columns snapshot from stored content fields", async () => {
|
||||
const item = await insertPost(ctx, {
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
locale: "en",
|
||||
data: {
|
||||
title: "Hello World",
|
||||
hero: { id: "media-hero", provider: "local", mimeType: "image/webp" },
|
||||
attachment: { id: "media-file", provider: "local", mimeType: "application/pdf" },
|
||||
sections: [{ image: { id: "media-section", provider: "local" } }],
|
||||
body: [{ _type: "image", asset: { _ref: "media-body" } }],
|
||||
raw_data: { id: "media-ignored" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
expect(result.snapshots).toHaveLength(1);
|
||||
const snapshot = result.snapshots[0]!;
|
||||
|
||||
expect(snapshot.source).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceKey: buildContentMediaUsageSourceKey({
|
||||
collectionSlug: "posts",
|
||||
contentId: item.id,
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
sourceType: "content",
|
||||
collectionSlug: "posts",
|
||||
contentId: item.id,
|
||||
sourceVariant: "columns",
|
||||
locale: "en",
|
||||
translationGroup: item.translationGroup,
|
||||
contentSlug: "hello-world",
|
||||
contentTitle: "Hello World",
|
||||
contentStatus: "published",
|
||||
contentScheduledAt: null,
|
||||
contentDeletedAt: null,
|
||||
revisionId: null,
|
||||
sourceUpdatedAt: item.updatedAt,
|
||||
sourceVersion: item.version,
|
||||
}),
|
||||
);
|
||||
expect(snapshot.fields).toEqual([
|
||||
{ slug: "attachment", type: "file" },
|
||||
{ slug: "body", type: "portableText" },
|
||||
{ slug: "hero", type: "image" },
|
||||
{
|
||||
slug: "sections",
|
||||
type: "repeater",
|
||||
validation: { subFields: [{ slug: "image", type: "image" }] },
|
||||
},
|
||||
]);
|
||||
expect(snapshot.occurrences).toEqual([
|
||||
expect.objectContaining({ fieldPath: "attachment", mediaId: "media-file" }),
|
||||
expect.objectContaining({ fieldPath: "body[0].asset._ref", mediaId: "media-body" }),
|
||||
expect.objectContaining({ fieldPath: "hero", mediaId: "media-hero" }),
|
||||
expect.objectContaining({ fieldPath: "sections[0].image", mediaId: "media-section" }),
|
||||
]);
|
||||
expect(snapshot.occurrences).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ mediaId: "media-ignored" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a typed not-found result for missing content", async () => {
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", "missing-content");
|
||||
|
||||
expect(result).toEqual({ success: false, error: "CONTENT_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("keeps JSON-looking stored display strings as strings", async () => {
|
||||
const title = '{"headline":"Columns"}';
|
||||
const item = await insertPost(ctx, {
|
||||
slug: "json-title",
|
||||
status: "published",
|
||||
data: { title },
|
||||
});
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
expect(getSnapshot(result, "columns").source.contentTitle).toBe(title);
|
||||
});
|
||||
|
||||
it("builds columns and draft overlay snapshots for a pending draft revision", 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: {
|
||||
_slug: "draft-post",
|
||||
title: "Draft Title",
|
||||
hero: { id: "media-draft", provider: "local", mimeType: "image/webp" },
|
||||
},
|
||||
});
|
||||
await setDraftRevision(ctx, item.id, draft.id);
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
expect(result.snapshots.map((snapshot) => snapshot.source.sourceVariant)).toEqual([
|
||||
"columns",
|
||||
"draft_overlay",
|
||||
]);
|
||||
|
||||
const columns = result.snapshots.find(
|
||||
(snapshot) => snapshot.source.sourceVariant === "columns",
|
||||
)!;
|
||||
const overlay = result.snapshots.find(
|
||||
(snapshot) => snapshot.source.sourceVariant === "draft_overlay",
|
||||
)!;
|
||||
|
||||
expect(columns.source).toEqual(
|
||||
expect.objectContaining({
|
||||
contentSlug: "live-post",
|
||||
contentTitle: "Live Title",
|
||||
revisionId: null,
|
||||
sourceKey: buildContentMediaUsageSourceKey({
|
||||
collectionSlug: "posts",
|
||||
contentId: item.id,
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(columns.occurrences).toEqual([
|
||||
expect.objectContaining({ fieldPath: "hero", mediaId: "media-live" }),
|
||||
]);
|
||||
|
||||
expect(overlay.source).toEqual(
|
||||
expect.objectContaining({
|
||||
contentSlug: "draft-post",
|
||||
contentTitle: "Draft Title",
|
||||
revisionId: draft.id,
|
||||
sourceKey: buildContentMediaUsageSourceKey({
|
||||
collectionSlug: "posts",
|
||||
contentId: item.id,
|
||||
sourceVariant: "draft_overlay",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(overlay.occurrences).toEqual([
|
||||
expect.objectContaining({ fieldPath: "hero", mediaId: "media-draft" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges draft overlays over columns when the draft changes unrelated fields", 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" },
|
||||
});
|
||||
await setDraftRevision(ctx, item.id, draft.id);
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
const overlay = result.snapshots.find(
|
||||
(snapshot) => snapshot.source.sourceVariant === "draft_overlay",
|
||||
)!;
|
||||
expect(overlay.source.contentTitle).toBe("Draft Title");
|
||||
expect(overlay.occurrences).toEqual([
|
||||
expect.objectContaining({ fieldPath: "hero", mediaId: "media-live" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves column display fields when a draft changes only media", 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);
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
const overlay = result.snapshots.find(
|
||||
(snapshot) => snapshot.source.sourceVariant === "draft_overlay",
|
||||
)!;
|
||||
expect(overlay.source.contentTitle).toBe("Live Title");
|
||||
expect(overlay.occurrences).toEqual([
|
||||
expect.objectContaining({ fieldPath: "hero", mediaId: "media-draft" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps JSON-looking revision display strings as strings", async () => {
|
||||
const item = await insertPost(ctx, {
|
||||
slug: "live-post",
|
||||
status: "published",
|
||||
data: { title: "Live Title" },
|
||||
});
|
||||
const draftTitle = '{"headline":"Draft"}';
|
||||
const draft = await revisionRepo.create({
|
||||
collection: "posts",
|
||||
entryId: item.id,
|
||||
data: { title: draftTitle },
|
||||
});
|
||||
await setDraftRevision(ctx, item.id, draft.id);
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
const overlay = result.snapshots.find(
|
||||
(snapshot) => snapshot.source.sourceVariant === "draft_overlay",
|
||||
)!;
|
||||
expect(overlay.source.contentTitle).toBe(draftTitle);
|
||||
});
|
||||
|
||||
it("honors draft nulls that clear media fields", 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: null },
|
||||
});
|
||||
await setDraftRevision(ctx, item.id, draft.id);
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
const overlay = result.snapshots.find(
|
||||
(snapshot) => snapshot.source.sourceVariant === "draft_overlay",
|
||||
)!;
|
||||
expect(overlay.occurrences).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails when draft_revision_id belongs to another content row", async () => {
|
||||
const item = await insertPost(ctx, {
|
||||
slug: "live-post",
|
||||
status: "published",
|
||||
data: { title: "Live Title" },
|
||||
});
|
||||
const other = await insertPost(ctx, {
|
||||
slug: "other-post",
|
||||
status: "published",
|
||||
data: { title: "Other Title" },
|
||||
});
|
||||
const draft = await revisionRepo.create({
|
||||
collection: "posts",
|
||||
entryId: other.id,
|
||||
data: { title: "Other Draft" },
|
||||
});
|
||||
await setDraftRevision(ctx, item.id, draft.id);
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: "DRAFT_REVISION_MISMATCH",
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
snapshots: [
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "columns" }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails with a columns snapshot when draft_revision_id is missing", 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 setDraftRevision(ctx, item.id, "missing_revision", { allowMissing: true });
|
||||
|
||||
const result = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: "DRAFT_REVISION_NOT_FOUND",
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
snapshots: [
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "columns" }),
|
||||
occurrences: [expect.objectContaining({ mediaId: "media-live" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.source).not.toHaveProperty("sourceFingerprint");
|
||||
});
|
||||
|
||||
it("fails when draft revision data is invalid JSON", async () => {
|
||||
const item = await insertPost(ctx, {
|
||||
slug: "live-post",
|
||||
status: "published",
|
||||
data: { title: "Live Title" },
|
||||
});
|
||||
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 loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: "DRAFT_REVISION_INVALID",
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
snapshots: [
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "columns" }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.source).not.toHaveProperty("sourceFingerprint");
|
||||
});
|
||||
|
||||
it("adds stable source schema versions and fingerprints to snapshots", 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 firstResult = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
const secondResult = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(secondResult.success).toBe(true);
|
||||
if (!firstResult.success) throw new Error(firstResult.error);
|
||||
if (!secondResult.success) throw new Error(secondResult.error);
|
||||
const firstColumns = getSnapshot(firstResult, "columns");
|
||||
const firstOverlay = getSnapshot(firstResult, "draft_overlay");
|
||||
const secondColumns = getSnapshot(secondResult, "columns");
|
||||
const secondOverlay = getSnapshot(secondResult, "draft_overlay");
|
||||
|
||||
expect(firstColumns.source.schemaVersion).toBe(1);
|
||||
expect(firstOverlay.source.schemaVersion).toBe(1);
|
||||
expect(firstColumns.source.sourceFingerprint).toEqual(expect.stringMatching(/^[a-f0-9]{16}$/));
|
||||
expect(firstOverlay.source.sourceFingerprint).toEqual(expect.stringMatching(/^[a-f0-9]{16}$/));
|
||||
expect(firstOverlay.source.sourceFingerprint).not.toBe(firstColumns.source.sourceFingerprint);
|
||||
expect(secondColumns.source.sourceFingerprint).toBe(firstColumns.source.sourceFingerprint);
|
||||
expect(secondOverlay.source.sourceFingerprint).toBe(firstOverlay.source.sourceFingerprint);
|
||||
});
|
||||
|
||||
it("changes fingerprints when extraction-relevant values or fields change", 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 initial = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
expect(initial.success).toBe(true);
|
||||
if (!initial.success) throw new Error(initial.error);
|
||||
const initialFingerprint = getSnapshot(initial, "columns").source.sourceFingerprint;
|
||||
|
||||
await updatePostHero(ctx, item.id, {
|
||||
id: "media-updated",
|
||||
provider: "local",
|
||||
mimeType: "image/webp",
|
||||
});
|
||||
const valueChanged = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
expect(valueChanged.success).toBe(true);
|
||||
if (!valueChanged.success) throw new Error(valueChanged.error);
|
||||
const valueChangedFingerprint = getSnapshot(valueChanged, "columns").source.sourceFingerprint;
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "thumbnail",
|
||||
label: "Thumbnail",
|
||||
type: "image",
|
||||
});
|
||||
const fieldChanged = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
expect(fieldChanged.success).toBe(true);
|
||||
if (!fieldChanged.success) throw new Error(fieldChanged.error);
|
||||
const fieldChangedFingerprint = getSnapshot(fieldChanged, "columns").source.sourceFingerprint;
|
||||
|
||||
expect(valueChangedFingerprint).not.toBe(initialFingerprint);
|
||||
expect(fieldChangedFingerprint).not.toBe(valueChangedFingerprint);
|
||||
});
|
||||
|
||||
it("keeps fingerprints stable for non-extraction schema metadata changes", async () => {
|
||||
const item = await insertPost(ctx, {
|
||||
slug: "live-post",
|
||||
status: "published",
|
||||
data: {
|
||||
title: "Live Title",
|
||||
hero: { id: "media-live", provider: "local", mimeType: "image/webp" },
|
||||
attachment: { id: "file-live", provider: "local", mimeType: "application/pdf" },
|
||||
},
|
||||
});
|
||||
const before = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
expect(before.success).toBe(true);
|
||||
if (!before.success) throw new Error(before.error);
|
||||
const beforeFingerprint = getSnapshot(before, "columns").source.sourceFingerprint;
|
||||
|
||||
await ctx.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({
|
||||
label: "Hero Image",
|
||||
required: 1,
|
||||
validation: JSON.stringify({ allowedMimeTypes: ["image/png"] }),
|
||||
sort_order: 999,
|
||||
})
|
||||
.where("slug", "=", "hero")
|
||||
.execute();
|
||||
await ctx.db
|
||||
.updateTable("_emdash_fields")
|
||||
.set({ sort_order: -999 })
|
||||
.where("slug", "=", "attachment")
|
||||
.execute();
|
||||
|
||||
const after = await loadContentMediaUsageSnapshots(ctx.db, "posts", item.id);
|
||||
expect(after.success).toBe(true);
|
||||
if (!after.success) throw new Error(after.error);
|
||||
|
||||
expect(getSnapshot(after, "columns").source.sourceFingerprint).toBe(beforeFingerprint);
|
||||
});
|
||||
});
|
||||
|
||||
interface TestPostInput {
|
||||
slug: string;
|
||||
status: string;
|
||||
locale?: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TestPost {
|
||||
id: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
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,
|
||||
attachment,
|
||||
sections,
|
||||
body,
|
||||
raw_data
|
||||
) VALUES (
|
||||
${id},
|
||||
${input.slug},
|
||||
${input.status},
|
||||
${now},
|
||||
${now},
|
||||
${1},
|
||||
${input.locale ?? "en"},
|
||||
${id},
|
||||
${serializeFieldValue(input.data.title)},
|
||||
${serializeFieldValue(input.data.hero)},
|
||||
${serializeFieldValue(input.data.attachment)},
|
||||
${serializeFieldValue(input.data.sections)},
|
||||
${serializeFieldValue(input.data.body)},
|
||||
${serializeFieldValue(input.data.raw_data)}
|
||||
)
|
||||
`.execute(ctx.db);
|
||||
|
||||
return {
|
||||
id,
|
||||
slug: input.slug,
|
||||
status: input.status,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
translationGroup: id,
|
||||
};
|
||||
}
|
||||
|
||||
async function setDraftRevision(
|
||||
ctx: DialectTestContext,
|
||||
contentId: string,
|
||||
revisionId: string,
|
||||
options: { allowMissing?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (options.allowMissing) await allowMissingDraftRevision(ctx);
|
||||
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 allowMissingDraftRevision(ctx: DialectTestContext): Promise<void> {
|
||||
if (ctx.dialect === "postgres") {
|
||||
await sql`
|
||||
ALTER TABLE ${sql.ref("ec_posts")}
|
||||
DROP CONSTRAINT IF EXISTS ec_posts_draft_revision_id_fkey
|
||||
`.execute(ctx.db);
|
||||
return;
|
||||
}
|
||||
|
||||
await sql`PRAGMA foreign_keys = OFF`.execute(ctx.db);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function getSnapshot(
|
||||
result: Extract<Awaited<ReturnType<typeof loadContentMediaUsageSnapshots>>, { success: true }>,
|
||||
sourceVariant: "columns" | "draft_overlay",
|
||||
) {
|
||||
const snapshot = result.snapshots.find(
|
||||
(candidate) => candidate.source.sourceVariant === sourceVariant,
|
||||
);
|
||||
if (!snapshot) throw new Error(`Missing ${sourceVariant} snapshot`);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function serializeFieldValue(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
const EXPECTED_INDEXES = [
|
||||
"idx__emdash_media_usage_sources_content",
|
||||
"idx__emdash_media_usage_sources_variant",
|
||||
"idx__emdash_media_usage_sources_locale",
|
||||
"idx__emdash_media_usage_sources_deleted",
|
||||
"idx__emdash_media_usage_sources_translation_group",
|
||||
"idx__emdash_media_usage_media_id",
|
||||
"idx__emdash_media_usage_provider_asset",
|
||||
"idx__emdash_media_usage_source_generation",
|
||||
"idx__emdash_media_usage_unique_occurrence",
|
||||
] as const;
|
||||
|
||||
describeEachDialect("media usage index migration", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("creates usage source and occurrence tables through registered migrations", async () => {
|
||||
const sources = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_sources")
|
||||
.select("source_key")
|
||||
.execute();
|
||||
const usage = await ctx.db.selectFrom("_emdash_media_usage").select("id").execute();
|
||||
|
||||
expect(Array.isArray(sources)).toBe(true);
|
||||
expect(Array.isArray(usage)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a content usage source and one occurrence", async () => {
|
||||
const sourceKey = "content:posts:entry1:columns";
|
||||
const generation = "gen1";
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_sources")
|
||||
.values({
|
||||
source_key: sourceKey,
|
||||
source_type: "content",
|
||||
collection_slug: "posts",
|
||||
content_id: "entry1",
|
||||
source_variant: "columns",
|
||||
content_slug: "hello-world",
|
||||
content_title: "Hello World",
|
||||
locale: "en",
|
||||
translation_group: "tg1",
|
||||
content_status: "published",
|
||||
content_scheduled_at: null,
|
||||
content_deleted_at: null,
|
||||
revision_id: "rev1",
|
||||
current_generation: generation,
|
||||
schema_version: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage")
|
||||
.values({
|
||||
id: "usage1",
|
||||
source_key: sourceKey,
|
||||
generation,
|
||||
field_slug: "hero",
|
||||
field_path: "hero",
|
||||
occurrence_index: 0,
|
||||
reference_type: "image_field",
|
||||
media_id: "media1",
|
||||
provider: "local",
|
||||
provider_asset_id: "media1",
|
||||
media_kind: "image",
|
||||
mime_type: "image/jpeg",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const rows = await ctx.db
|
||||
.selectFrom("_emdash_media_usage")
|
||||
.select(["source_key", "media_id", "provider", "provider_asset_id", "field_path"])
|
||||
.where("media_id", "=", "media1")
|
||||
.execute();
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
source_key: sourceKey,
|
||||
media_id: "media1",
|
||||
provider: "local",
|
||||
provider_asset_id: "media1",
|
||||
field_path: "hero",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates expected indexes", async () => {
|
||||
const indexNames = await listIndexNames(ctx);
|
||||
|
||||
for (const indexName of EXPECTED_INDEXES) {
|
||||
expect(indexNames.has(indexName), `missing index ${indexName}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("down() drops tables and up() recreates them", async () => {
|
||||
const migration = await import("../../../src/database/migrations/046_media_usage_index.js");
|
||||
|
||||
await migration.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 migration.up(ctx.db);
|
||||
|
||||
const sources = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_sources")
|
||||
.select("source_key")
|
||||
.execute();
|
||||
expect(Array.isArray(sources)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function listIndexNames(ctx: DialectTestContext): Promise<Set<string>> {
|
||||
if (ctx.dialect === "sqlite") {
|
||||
const result = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'index'
|
||||
`.execute(ctx.db);
|
||||
return new Set(result.rows.map((row) => row.name));
|
||||
}
|
||||
|
||||
const result = await sql<{ name: string }>`
|
||||
SELECT indexname AS name FROM pg_indexes WHERE schemaname = current_schema()
|
||||
`.execute(ctx.db);
|
||||
return new Set(result.rows.map((row) => row.name));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js";
|
||||
import { RevisionRepository } from "../../../src/database/repositories/revision.js";
|
||||
import type { EmDashRuntime } from "../../../src/emdash-runtime.js";
|
||||
import { setI18nConfig } from "../../../src/i18n/config.js";
|
||||
import {
|
||||
CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
||||
CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
||||
} from "../../../src/media/usage/content-refresh.js";
|
||||
import { buildContentMediaUsageSourceKey } from "../../../src/media/usage/source-key.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { createTestRuntime } from "../../utils/mcp-runtime.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("runtime content media usage refresh", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let runtime: EmDashRuntime;
|
||||
let usageRepo: MediaUsageRepository;
|
||||
let revisionRepo: RevisionRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
setI18nConfig(null);
|
||||
ctx = await setupForDialect(dialect);
|
||||
const registry = new SchemaRegistry(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" });
|
||||
await registry.createField("posts", {
|
||||
slug: "shared_hero",
|
||||
label: "Shared Hero",
|
||||
type: "image",
|
||||
translatable: false,
|
||||
});
|
||||
await registry.createCollection({ slug: "plain_posts", label: "Plain Posts", supports: [] });
|
||||
await registry.createField("plain_posts", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
await registry.createField("plain_posts", { slug: "hero", label: "Hero", type: "image" });
|
||||
await registry.createCollection({
|
||||
slug: "localized_posts",
|
||||
label: "Localized Posts",
|
||||
supports: [],
|
||||
});
|
||||
await registry.createField("localized_posts", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
translatable: false,
|
||||
});
|
||||
await registry.createField("localized_posts", {
|
||||
slug: "hero",
|
||||
label: "Hero",
|
||||
type: "image",
|
||||
});
|
||||
await registry.createField("localized_posts", {
|
||||
slug: "shared_hero",
|
||||
label: "Shared Hero",
|
||||
type: "image",
|
||||
translatable: false,
|
||||
});
|
||||
await registry.createField("localized_posts", {
|
||||
slug: "summary",
|
||||
label: "Summary",
|
||||
type: "string",
|
||||
translatable: false,
|
||||
});
|
||||
|
||||
runtime = createTestRuntime(ctx.db);
|
||||
usageRepo = new MediaUsageRepository(ctx.db);
|
||||
revisionRepo = new RevisionRepository(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setI18nConfig(null);
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("refreshes columns usage after runtime content create", async () => {
|
||||
const created = await runtime.handleContentCreate("plain_posts", {
|
||||
slug: "created-post",
|
||||
data: {
|
||||
title: "Created Post",
|
||||
hero: mediaRef("media-created"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
const contentId = created.data.item.id;
|
||||
expect(await usageRepo.findSource(sourceKey("plain_posts", contentId, "columns"))).toEqual(
|
||||
expect.objectContaining({
|
||||
contentTitle: "Created Post",
|
||||
sourceCompleteness: "complete",
|
||||
}),
|
||||
);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-created")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId, sourceVariant: "columns" }),
|
||||
occurrence: expect.objectContaining({ fieldPath: "hero", mediaId: "media-created" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes columns usage after runtime non-revision content update", async () => {
|
||||
const created = await runtime.handleContentCreate("plain_posts", {
|
||||
slug: "updated-post",
|
||||
data: {
|
||||
title: "Updated Post",
|
||||
hero: mediaRef("media-old"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
|
||||
const updated = await runtime.handleContentUpdate("plain_posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-new") },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-old")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-new")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
contentId: created.data.item.id,
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes draft overlay usage after runtime revision-enabled content update", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "drafted-post",
|
||||
data: {
|
||||
title: "Drafted Post",
|
||||
hero: mediaRef("media-live"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
|
||||
const updated = await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-draft") },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
||||
expect.objectContaining({ sourceVariant: "columns", contentTitle: "Drafted Post" }),
|
||||
);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toEqual(expect.objectContaining({ sourceVariant: "draft_overlay" }));
|
||||
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("marks coverage stale when a failed draft update has already advanced stored draft data", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "failed-metadata-draft-post",
|
||||
data: {
|
||||
title: "Failed Metadata Draft Post",
|
||||
hero: mediaRef("media-live"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
const contentId = created.data.item.id;
|
||||
await usageRepo.upsertIndexStatus({
|
||||
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
||||
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
||||
scopeKey: "posts",
|
||||
status: "complete",
|
||||
lastErrorCode: null,
|
||||
});
|
||||
|
||||
const updated = await runtime.handleContentUpdate("posts", contentId, {
|
||||
data: { hero: mediaRef("media-unrefreshed-draft") },
|
||||
bylines: [{ bylineId: "missing-byline" }],
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(false);
|
||||
expect((await revisionRepo.findByEntry("posts", contentId, { limit: 1 }))[0]?.data).toEqual(
|
||||
expect.objectContaining({ hero: mediaRef("media-unrefreshed-draft") }),
|
||||
);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-unrefreshed-draft")).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_STALE",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps runtime updates successful when draft overlay usage refresh fails", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "failed-refresh-post",
|
||||
data: {
|
||||
title: "Failed Refresh Post",
|
||||
hero: mediaRef("media-live-before-failure"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
const contentId = created.data.item.id;
|
||||
|
||||
const firstDraft = await runtime.handleContentUpdate("posts", contentId, {
|
||||
data: { hero: mediaRef("media-draft-before-failure") },
|
||||
});
|
||||
expect(firstDraft.success).toBe(true);
|
||||
const draftSourceBefore = await usageRepo.findSource(
|
||||
sourceKey("posts", contentId, "draft_overlay"),
|
||||
);
|
||||
expect(draftSourceBefore).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceCompleteness: "complete",
|
||||
lastErrorCode: null,
|
||||
}),
|
||||
);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-before-failure")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId, sourceVariant: "draft_overlay" }),
|
||||
}),
|
||||
]);
|
||||
await corruptFuturePostDraftRevisionSnapshots(ctx);
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const updated = await runtime
|
||||
.handleContentUpdate("posts", contentId, {
|
||||
data: { hero: mediaRef("media-draft-after-failure") },
|
||||
})
|
||||
.finally(() => {
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
const draftSourceAfter = await usageRepo.findSource(
|
||||
sourceKey("posts", contentId, "draft_overlay"),
|
||||
);
|
||||
expect(draftSourceAfter).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceCompleteness: "failed",
|
||||
lastErrorCode: "DRAFT_REVISION_INVALID",
|
||||
}),
|
||||
);
|
||||
expect(draftSourceAfter?.currentGeneration).toBe(draftSourceBefore?.currentGeneration);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-before-failure")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
contentId,
|
||||
sourceVariant: "draft_overlay",
|
||||
lastErrorCode: "DRAFT_REVISION_INVALID",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-after-failure")).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: "DRAFT_REVISION_INVALID",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes columns usage for duplicated content", async () => {
|
||||
const created = await runtime.handleContentCreate("plain_posts", {
|
||||
slug: "original-post",
|
||||
data: {
|
||||
title: "Original Post",
|
||||
hero: mediaRef("media-copy"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
|
||||
const duplicated = await runtime.handleContentDuplicate("plain_posts", created.data.item.id);
|
||||
|
||||
expect(duplicated.success).toBe(true);
|
||||
if (!duplicated.success) throw new Error(duplicated.error.message);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-copy")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
contentId: duplicated.data.item.id,
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes draft overlay usage after runtime revision restore", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "restored-post",
|
||||
data: {
|
||||
title: "Restored Post",
|
||||
hero: mediaRef("media-live"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
|
||||
const firstDraft = await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-restored") },
|
||||
});
|
||||
expect(firstDraft.success).toBe(true);
|
||||
const revisionToRestore = (
|
||||
await revisionRepo.findByEntry("posts", created.data.item.id, { limit: 1 })
|
||||
)[0];
|
||||
expect(revisionToRestore).toBeDefined();
|
||||
const secondDraft = await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-current-draft") },
|
||||
});
|
||||
expect(secondDraft.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-current-draft")).toHaveLength(1);
|
||||
|
||||
const restored = await runtime.handleRevisionRestore(revisionToRestore!.id, "user-1");
|
||||
|
||||
expect(restored.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-current-draft")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-restored")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes usage when publishing a draft overlay", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "publish-post",
|
||||
data: {
|
||||
title: "Publish Post",
|
||||
hero: mediaRef("media-live"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-draft") },
|
||||
});
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const published = await runtime.handleContentPublish("posts", created.data.item.id);
|
||||
|
||||
expect(published.success).toBe(true);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toBeNull();
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft")).toEqual([
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes usage when unpublishing creates a draft overlay", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "unpublish-post",
|
||||
data: {
|
||||
title: "Unpublish Post",
|
||||
hero: mediaRef("media-unpublish"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
const published = await runtime.handleContentPublish("posts", created.data.item.id);
|
||||
expect(published.success).toBe(true);
|
||||
|
||||
const unpublished = await runtime.handleContentUnpublish("posts", created.data.item.id);
|
||||
|
||||
expect(unpublished.success).toBe(true);
|
||||
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
||||
expect.objectContaining({ contentStatus: "draft", sourceVariant: "columns" }),
|
||||
);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toEqual(expect.objectContaining({ contentStatus: "draft", sourceVariant: "draft_overlay" }));
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-unpublish")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes schedule metadata on schedule and unschedule", async () => {
|
||||
const created = await runtime.handleContentCreate("plain_posts", {
|
||||
slug: "scheduled-post",
|
||||
data: {
|
||||
title: "Scheduled Post",
|
||||
hero: mediaRef("media-schedule"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
const scheduledAt = new Date(Date.now() + 86_400_000).toISOString();
|
||||
|
||||
const scheduled = await runtime.handleContentSchedule(
|
||||
"plain_posts",
|
||||
created.data.item.id,
|
||||
scheduledAt,
|
||||
);
|
||||
|
||||
expect(scheduled.success).toBe(true);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("plain_posts", created.data.item.id, "columns")),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
contentStatus: "scheduled",
|
||||
contentScheduledAt: scheduledAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const unscheduled = await runtime.handleContentUnschedule("plain_posts", created.data.item.id);
|
||||
|
||||
expect(unscheduled.success).toBe(true);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("plain_posts", created.data.item.id, "columns")),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
contentStatus: "draft",
|
||||
contentScheduledAt: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes usage when discarding a draft overlay", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "discard-post",
|
||||
data: {
|
||||
title: "Discard Post",
|
||||
hero: mediaRef("media-live"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-discard") },
|
||||
});
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).not.toBeNull();
|
||||
|
||||
const discarded = await runtime.handleContentDiscardDraft("posts", created.data.item.id);
|
||||
|
||||
expect(discarded.success).toBe(true);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toBeNull();
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-discard")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes usage when scheduled publish runs through the runtime", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "scheduled-publish-post",
|
||||
data: {
|
||||
title: "Scheduled Publish Post",
|
||||
hero: mediaRef("media-live"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-scheduled-draft") },
|
||||
});
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
await sql`
|
||||
UPDATE ${sql.ref("ec_posts")}
|
||||
SET status = ${"scheduled"},
|
||||
scheduled_at = ${dueAt}
|
||||
WHERE id = ${created.data.item.id}
|
||||
`.execute(ctx.db);
|
||||
|
||||
const result = await runtime.publishScheduled();
|
||||
|
||||
expect(result).toEqual([{ collection: "posts", id: created.data.item.id }]);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toBeNull();
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-live")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-scheduled-draft")).toEqual([
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes trash metadata while preserving usage on soft delete", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "trashed-post",
|
||||
data: {
|
||||
title: "Trashed Post",
|
||||
hero: mediaRef("media-live-trash"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-draft-trash") },
|
||||
});
|
||||
|
||||
const deleted = await runtime.handleContentDelete("posts", "trashed-post");
|
||||
|
||||
expect(deleted.success).toBe(true);
|
||||
if (!deleted.success) throw new Error(deleted.error.message);
|
||||
expect(deleted.data.id).toBe(created.data.item.id);
|
||||
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
||||
expect.objectContaining({
|
||||
contentDeletedAt: expect.any(String),
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
contentDeletedAt: expect.any(String),
|
||||
sourceVariant: "draft_overlay",
|
||||
}),
|
||||
);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-live-trash")).toEqual([
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
||||
]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-trash")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes trash metadata when restoring content", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "restore-trash-post",
|
||||
data: {
|
||||
title: "Restore Trash Post",
|
||||
hero: mediaRef("media-live-restore"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-draft-restore") },
|
||||
});
|
||||
const deleted = await runtime.handleContentDelete("posts", created.data.item.id);
|
||||
expect(deleted.success).toBe(true);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toEqual(expect.objectContaining({ contentDeletedAt: expect.any(String) }));
|
||||
|
||||
const restored = await runtime.handleContentRestore("posts", "restore-trash-post");
|
||||
|
||||
expect(restored.success).toBe(true);
|
||||
expect(await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns"))).toEqual(
|
||||
expect.objectContaining({
|
||||
contentDeletedAt: null,
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
contentDeletedAt: null,
|
||||
sourceVariant: "draft_overlay",
|
||||
}),
|
||||
);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-live-restore")).toEqual([
|
||||
expect.objectContaining({ source: expect.objectContaining({ sourceVariant: "columns" }) }),
|
||||
]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-restore")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ sourceVariant: "draft_overlay" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes usage sources and current occurrences on permanent delete", async () => {
|
||||
const created = await runtime.handleContentCreate("posts", {
|
||||
slug: "permanent-delete-post",
|
||||
data: {
|
||||
title: "Permanent Delete Post",
|
||||
hero: mediaRef("media-live-permanent"),
|
||||
},
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) throw new Error(created.error.message);
|
||||
await runtime.handleContentUpdate("posts", created.data.item.id, {
|
||||
data: { hero: mediaRef("media-draft-permanent") },
|
||||
});
|
||||
const deleted = await runtime.handleContentDelete("posts", created.data.item.id);
|
||||
expect(deleted.success).toBe(true);
|
||||
|
||||
const permanentlyDeleted = await runtime.handleContentPermanentDelete(
|
||||
"posts",
|
||||
"permanent-delete-post",
|
||||
);
|
||||
|
||||
expect(permanentlyDeleted.success).toBe(true);
|
||||
if (!permanentlyDeleted.success) throw new Error(permanentlyDeleted.error.message);
|
||||
expect(permanentlyDeleted.data.id).toBe(created.data.item.id);
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "columns")),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await usageRepo.findSource(sourceKey("posts", created.data.item.id, "draft_overlay")),
|
||||
).toBeNull();
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-live-permanent")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-permanent")).toEqual([]);
|
||||
});
|
||||
|
||||
it("refreshes i18n siblings when a non-translatable image syncs across locales", async () => {
|
||||
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
||||
const { enId, frId } = await createLocalizedPostsPair(runtime, "shared-image", {
|
||||
enSharedHero: "media-shared-old-en",
|
||||
frSharedHero: "media-shared-old-fr",
|
||||
});
|
||||
|
||||
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
||||
data: { shared_hero: mediaRef("media-shared-new") },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-shared-old-en")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-shared-old-fr")).toEqual([]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-shared-new")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId: enId, sourceVariant: "columns" }),
|
||||
occurrence: expect.objectContaining({ fieldPath: "shared_hero" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId: frId, sourceVariant: "columns" }),
|
||||
occurrence: expect.objectContaining({ fieldPath: "shared_hero" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not refresh i18n siblings for translatable image updates", async () => {
|
||||
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
||||
const { enId, frId } = await createLocalizedPostsPair(runtime, "translatable-image", {
|
||||
enHero: "media-hero-old-en",
|
||||
frHero: "media-hero-old-fr",
|
||||
});
|
||||
const frSourceBefore = await usageRepo.findSource(
|
||||
sourceKey("localized_posts", frId, "columns"),
|
||||
);
|
||||
expect(frSourceBefore).not.toBeNull();
|
||||
|
||||
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
||||
data: { hero: mediaRef("media-hero-new-en") },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-hero-new-en")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId: enId, sourceVariant: "columns" }),
|
||||
}),
|
||||
]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-hero-old-fr")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId: frId, sourceVariant: "columns" }),
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns")))
|
||||
?.currentGeneration,
|
||||
).toBe(frSourceBefore?.currentGeneration);
|
||||
});
|
||||
|
||||
it("does not refresh i18n siblings for non-usage non-translatable updates", async () => {
|
||||
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
||||
const { enId, frId } = await createLocalizedPostsPair(runtime, "non-usage-sync");
|
||||
const frSourceBefore = await usageRepo.findSource(
|
||||
sourceKey("localized_posts", frId, "columns"),
|
||||
);
|
||||
expect(frSourceBefore).not.toBeNull();
|
||||
|
||||
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
||||
data: { summary: "Shared summary" },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(
|
||||
(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns")))
|
||||
?.currentGeneration,
|
||||
).toBe(frSourceBefore?.currentGeneration);
|
||||
});
|
||||
|
||||
it("does not refresh i18n siblings for revision-enabled draft saves", async () => {
|
||||
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
||||
const en = await runtime.handleContentCreate("posts", {
|
||||
slug: "draft-sibling-en",
|
||||
locale: "en",
|
||||
data: {
|
||||
title: "English Draft Sibling",
|
||||
shared_hero: mediaRef("media-draft-sibling-old-en"),
|
||||
},
|
||||
});
|
||||
expect(en.success).toBe(true);
|
||||
if (!en.success) throw new Error(en.error.message);
|
||||
const fr = await runtime.handleContentCreate("posts", {
|
||||
slug: "draft-sibling-fr",
|
||||
locale: "fr",
|
||||
translationOf: en.data.item.id,
|
||||
data: {
|
||||
title: "French Draft Sibling",
|
||||
shared_hero: mediaRef("media-draft-sibling-old-fr"),
|
||||
},
|
||||
});
|
||||
expect(fr.success).toBe(true);
|
||||
if (!fr.success) throw new Error(fr.error.message);
|
||||
const frSourceBefore = await usageRepo.findSource(
|
||||
sourceKey("posts", fr.data.item.id, "columns"),
|
||||
);
|
||||
expect(frSourceBefore).not.toBeNull();
|
||||
|
||||
const updated = await runtime.handleContentUpdate("posts", en.data.item.id, {
|
||||
data: { shared_hero: mediaRef("media-draft-sibling-new") },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-sibling-new")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
contentId: en.data.item.id,
|
||||
sourceVariant: "draft_overlay",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-draft-sibling-old-fr")).toEqual([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
contentId: fr.data.item.id,
|
||||
sourceVariant: "columns",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
(await usageRepo.findSource(sourceKey("posts", fr.data.item.id, "columns")))
|
||||
?.currentGeneration,
|
||||
).toBe(frSourceBefore?.currentGeneration);
|
||||
});
|
||||
|
||||
it("refreshes trashed i18n siblings when non-translatable fields sync to them", async () => {
|
||||
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
||||
const { enId, frId } = await createLocalizedPostsPair(runtime, "trashed-sibling", {
|
||||
enSharedHero: "media-trash-old-en",
|
||||
frSharedHero: "media-trash-old-fr",
|
||||
});
|
||||
const deleted = await runtime.handleContentDelete("localized_posts", frId);
|
||||
expect(deleted.success).toBe(true);
|
||||
|
||||
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
||||
data: { shared_hero: mediaRef("media-trash-new") },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-trash-old-fr")).toEqual([]);
|
||||
expect(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns"))).toEqual(
|
||||
expect.objectContaining({
|
||||
contentDeletedAt: expect.any(String),
|
||||
contentId: frId,
|
||||
}),
|
||||
);
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-trash-new")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({ contentId: enId, contentDeletedAt: null }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
contentId: frId,
|
||||
contentDeletedAt: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes i18n sibling source metadata for non-translatable display fields", async () => {
|
||||
setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] });
|
||||
const { enId, frId } = await createLocalizedPostsPair(runtime, "shared-title", {
|
||||
enTitle: "English Title",
|
||||
frTitle: "French Title",
|
||||
});
|
||||
|
||||
const updated = await runtime.handleContentUpdate("localized_posts", enId, {
|
||||
data: { title: "Shared Title" },
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(await usageRepo.findSource(sourceKey("localized_posts", enId, "columns"))).toEqual(
|
||||
expect.objectContaining({ contentTitle: "Shared Title" }),
|
||||
);
|
||||
expect(await usageRepo.findSource(sourceKey("localized_posts", frId, "columns"))).toEqual(
|
||||
expect.objectContaining({ contentTitle: "Shared Title" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function createLocalizedPostsPair(
|
||||
runtime: EmDashRuntime,
|
||||
slugPrefix: string,
|
||||
input: {
|
||||
enTitle?: string;
|
||||
frTitle?: string;
|
||||
enHero?: string;
|
||||
frHero?: string;
|
||||
enSharedHero?: string;
|
||||
frSharedHero?: string;
|
||||
} = {},
|
||||
): Promise<{ enId: string; frId: string }> {
|
||||
const en = await runtime.handleContentCreate("localized_posts", {
|
||||
slug: `${slugPrefix}-en`,
|
||||
locale: "en",
|
||||
data: {
|
||||
title: input.enTitle ?? "English Post",
|
||||
hero: mediaRef(input.enHero ?? `${slugPrefix}-hero-en`),
|
||||
shared_hero: mediaRef(input.enSharedHero ?? `${slugPrefix}-shared-en`),
|
||||
},
|
||||
});
|
||||
expect(en.success).toBe(true);
|
||||
if (!en.success) throw new Error(en.error.message);
|
||||
|
||||
const fr = await runtime.handleContentCreate("localized_posts", {
|
||||
slug: `${slugPrefix}-fr`,
|
||||
locale: "fr",
|
||||
translationOf: en.data.item.id,
|
||||
data: {
|
||||
title: input.frTitle ?? "French Post",
|
||||
hero: mediaRef(input.frHero ?? `${slugPrefix}-hero-fr`),
|
||||
shared_hero: mediaRef(input.frSharedHero ?? `${slugPrefix}-shared-fr`),
|
||||
},
|
||||
});
|
||||
expect(fr.success).toBe(true);
|
||||
if (!fr.success) throw new Error(fr.error.message);
|
||||
|
||||
return { enId: en.data.item.id, frId: fr.data.item.id };
|
||||
}
|
||||
|
||||
function mediaRef(id: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
provider: "local",
|
||||
mimeType: "image/webp",
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceKey(
|
||||
collectionSlug: string,
|
||||
contentId: string,
|
||||
sourceVariant: "columns" | "draft_overlay",
|
||||
): string {
|
||||
return buildContentMediaUsageSourceKey({ collectionSlug, contentId, sourceVariant });
|
||||
}
|
||||
|
||||
async function corruptFuturePostDraftRevisionSnapshots(ctx: DialectTestContext): Promise<void> {
|
||||
if (ctx.dialect === "postgres") {
|
||||
await sql`
|
||||
CREATE FUNCTION corrupt_posts_draft_revision()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.draft_revision_id IS NOT NULL THEN
|
||||
UPDATE revisions SET data = '{' WHERE id = NEW.draft_revision_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
`.execute(ctx.db);
|
||||
await sql`
|
||||
CREATE TRIGGER corrupt_posts_draft_revision
|
||||
AFTER UPDATE OF draft_revision_id ON ec_posts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION corrupt_posts_draft_revision()
|
||||
`.execute(ctx.db);
|
||||
return;
|
||||
}
|
||||
|
||||
await sql`
|
||||
CREATE TRIGGER corrupt_posts_draft_revision
|
||||
AFTER UPDATE OF draft_revision_id ON ec_posts
|
||||
WHEN NEW.draft_revision_id IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE revisions SET data = '{' WHERE id = NEW.draft_revision_id;
|
||||
END
|
||||
`.execute(ctx.db);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import { rewriteUrls } from "../../../src/astro/routes/api/import/wordpress/rewrite-urls.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js";
|
||||
import {
|
||||
CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
||||
CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
||||
markContentMediaUsageCollectionStaleSafely,
|
||||
} from "../../../src/media/usage/content-refresh.js";
|
||||
import {
|
||||
buildContentMediaUsageSourceKey,
|
||||
type MediaUsageContentSourceVariant,
|
||||
} from "../../../src/media/usage/source-key.js";
|
||||
import { createContentAccessWithWrite } from "../../../src/plugins/context.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { applySeed } from "../../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../../src/seed/types.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("media usage stale marking for bypass writes", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let registry: SchemaRegistry;
|
||||
let usageRepo: MediaUsageRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
registry = new SchemaRegistry(ctx.db);
|
||||
usageRepo = new MediaUsageRepository(ctx.db);
|
||||
await createCollectionWithFields("posts");
|
||||
await createCollectionWithFields("pages");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("marks touched collections stale after seed content writes", async () => {
|
||||
await markComplete("posts");
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "seed-post",
|
||||
slug: "seed-post",
|
||||
data: {
|
||||
title: "Seed Post",
|
||||
hero: mediaRef("media-seed"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(ctx.db, seed, { includeContent: true });
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
});
|
||||
|
||||
it("marks seed-touched collections stale even when a later content entry fails", async () => {
|
||||
await markComplete("posts");
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "seed-created-before-failure",
|
||||
slug: "duplicate-seed-slug",
|
||||
data: { title: "Created Before Failure" },
|
||||
},
|
||||
{
|
||||
id: "seed-conflict",
|
||||
slug: "duplicate-seed-slug",
|
||||
data: { title: "Conflict" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
applySeed(ctx.db, seed, { includeContent: true, onConflict: "error" }),
|
||||
).rejects.toThrow(/Conflict: content/);
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
});
|
||||
|
||||
it("marks collections stale after plugin content direct writes", async () => {
|
||||
await markComplete("posts");
|
||||
const content = createContentAccessWithWrite(ctx.db);
|
||||
|
||||
await content.create("posts", {
|
||||
title: "Plugin Post",
|
||||
hero: mediaRef("media-plugin-create"),
|
||||
});
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
await markComplete("posts");
|
||||
|
||||
const repo = new ContentRepository(ctx.db);
|
||||
const item = await repo.create({
|
||||
type: "posts",
|
||||
slug: "plugin-update",
|
||||
data: { title: "Plugin Update", hero: mediaRef("media-plugin-old") },
|
||||
});
|
||||
|
||||
await content.update("posts", item.id, { hero: mediaRef("media-plugin-new") });
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
await markComplete("posts");
|
||||
|
||||
expect(await content.delete("posts", item.id)).toBe(true);
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
});
|
||||
|
||||
it("marks collections stale after schema field mutations", async () => {
|
||||
await markComplete("posts");
|
||||
|
||||
await registry.createField("posts", { slug: "deck", label: "Deck", type: "string" });
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
await markComplete("posts");
|
||||
|
||||
await registry.updateField("posts", "hero", { label: "Hero Image" });
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
await markComplete("posts");
|
||||
|
||||
await registry.deleteField("posts", "deck");
|
||||
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
});
|
||||
|
||||
it("marks registered orphaned tables stale", async () => {
|
||||
await sql`CREATE TABLE ec_orphan_posts (id text primary key)`.execute(ctx.db);
|
||||
|
||||
await registry.registerOrphanedTable("orphan_posts");
|
||||
|
||||
await expectCollectionStatus("orphan_posts", "stale");
|
||||
});
|
||||
|
||||
it("deletes collection usage sources after collection deletion", async () => {
|
||||
await markComplete("posts");
|
||||
await usageRepo.replaceSource(contentSource("posts", "entry-1", "columns"), [
|
||||
occurrence("hero", "media-collection-delete"),
|
||||
]);
|
||||
expect(await usageRepo.findSource(sourceKey("posts", "entry-1", "columns"))).not.toBeNull();
|
||||
|
||||
await registry.deleteCollection("posts", { force: true });
|
||||
|
||||
expect(await usageRepo.findSource(sourceKey("posts", "entry-1", "columns"))).toBeNull();
|
||||
expect(await usageRepo.findCurrentUsageByMediaId("media-collection-delete")).toEqual([]);
|
||||
expect(await findCollectionStatus("posts")).toBeNull();
|
||||
});
|
||||
|
||||
it("retries failed WordPress rewrite stale marks once after the rewrite pass", async () => {
|
||||
const repo = new ContentRepository(ctx.db);
|
||||
const oldUrl = "https://example.com/wp-content/uploads/2026/01/hero.jpg";
|
||||
await repo.create({
|
||||
type: "posts",
|
||||
slug: "rewrite-retry-post",
|
||||
data: { title: "Rewrite Retry Post", body: `<img src="${oldUrl}">` },
|
||||
});
|
||||
await markComplete("posts");
|
||||
let attempts = 0;
|
||||
|
||||
const result = await rewriteUrls(
|
||||
ctx.db,
|
||||
{ [oldUrl]: "/_emdash/media/file/imported/hero.jpg" },
|
||||
() => undefined,
|
||||
["posts"],
|
||||
async (db, collectionSlug, lastErrorCode) => {
|
||||
attempts++;
|
||||
if (attempts === 1) return false;
|
||||
return markContentMediaUsageCollectionStaleSafely(db, collectionSlug, lastErrorCode);
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.byCollection).toEqual({ posts: 1 });
|
||||
expect(attempts).toBe(2);
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
});
|
||||
|
||||
it("marks only rewritten WordPress URL collections stale", async () => {
|
||||
const repo = new ContentRepository(ctx.db);
|
||||
const oldUrl = "https://example.com/wp-content/uploads/2026/01/hero.jpg";
|
||||
await repo.create({
|
||||
type: "posts",
|
||||
slug: "rewrite-post",
|
||||
data: { title: "Rewrite Post", body: `<img src="${oldUrl}">` },
|
||||
});
|
||||
await repo.create({
|
||||
type: "pages",
|
||||
slug: "clean-page",
|
||||
data: { title: "Clean Page", body: "No matching media URL" },
|
||||
});
|
||||
await markComplete("posts");
|
||||
await markComplete("pages");
|
||||
|
||||
const result = await rewriteUrls(
|
||||
ctx.db,
|
||||
{ [oldUrl]: "/_emdash/media/file/imported/hero.jpg" },
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(result.byCollection).toEqual({ posts: 1 });
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
await expectCollectionStatus("pages", "complete");
|
||||
});
|
||||
|
||||
it("marks earlier WordPress rewrite collections stale when a later collection fails", async () => {
|
||||
const repo = new ContentRepository(ctx.db);
|
||||
const oldUrl = "https://example.com/wp-content/uploads/2026/01/hero.jpg";
|
||||
await repo.create({
|
||||
type: "posts",
|
||||
slug: "rewrite-before-error",
|
||||
data: { title: "Rewrite Before Error", body: `<img src="${oldUrl}">` },
|
||||
});
|
||||
await registry.createCollection({ slug: "zz_broken", label: "Broken" });
|
||||
const broken = await registry.getCollection("zz_broken");
|
||||
expect(broken).not.toBeNull();
|
||||
await ctx.db
|
||||
.insertInto("_emdash_fields")
|
||||
.values({
|
||||
id: "broken_field",
|
||||
collection_id: broken!.id,
|
||||
slug: "bad_repeater",
|
||||
label: "Bad Repeater",
|
||||
type: "repeater",
|
||||
column_type: "JSON",
|
||||
required: 0,
|
||||
unique: 0,
|
||||
default_value: null,
|
||||
validation: "{",
|
||||
widget: null,
|
||||
options: null,
|
||||
sort_order: 0,
|
||||
searchable: 0,
|
||||
translatable: 1,
|
||||
})
|
||||
.execute();
|
||||
await markComplete("posts");
|
||||
let staleMarkAttempts = 0;
|
||||
|
||||
await expect(
|
||||
rewriteUrls(
|
||||
ctx.db,
|
||||
{ [oldUrl]: "/_emdash/media/file/imported/hero.jpg" },
|
||||
() => undefined,
|
||||
["posts", "zz_broken"],
|
||||
async (db, collectionSlug, lastErrorCode) => {
|
||||
if (collectionSlug !== "posts") {
|
||||
return markContentMediaUsageCollectionStaleSafely(db, collectionSlug, lastErrorCode);
|
||||
}
|
||||
staleMarkAttempts++;
|
||||
if (staleMarkAttempts === 1) return false;
|
||||
return markContentMediaUsageCollectionStaleSafely(db, collectionSlug, lastErrorCode);
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(staleMarkAttempts).toBe(2);
|
||||
await expectCollectionStatus("posts", "stale");
|
||||
});
|
||||
|
||||
async function createCollectionWithFields(slug: string) {
|
||||
await registry.createCollection({ slug, label: slug });
|
||||
await registry.createField(slug, { slug: "title", label: "Title", type: "string" });
|
||||
await registry.createField(slug, { slug: "body", label: "Body", type: "text" });
|
||||
await registry.createField(slug, { slug: "hero", label: "Hero", type: "image" });
|
||||
}
|
||||
|
||||
async function markComplete(collectionSlug: string) {
|
||||
await usageRepo.upsertIndexStatus({
|
||||
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
||||
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
||||
scopeKey: collectionSlug,
|
||||
status: "complete",
|
||||
schemaVersion: 1,
|
||||
indexedSourceCount: 1,
|
||||
failedSourceCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCollectionStatus(collectionSlug: string, status: string) {
|
||||
await expect(findCollectionStatus(collectionSlug)).resolves.toEqual(
|
||||
expect.objectContaining({ status }),
|
||||
);
|
||||
}
|
||||
|
||||
async function findCollectionStatus(collectionSlug: string) {
|
||||
return usageRepo.findIndexStatus({
|
||||
adapterId: CONTENT_MEDIA_USAGE_ADAPTER_ID,
|
||||
scopeType: CONTENT_MEDIA_USAGE_COLLECTION_SCOPE,
|
||||
scopeKey: collectionSlug,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function mediaRef(id: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
provider: "local",
|
||||
mimeType: "image/webp",
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceKey(
|
||||
collectionSlug: string,
|
||||
contentId: string,
|
||||
sourceVariant: MediaUsageContentSourceVariant,
|
||||
): string {
|
||||
return buildContentMediaUsageSourceKey({ collectionSlug, contentId, sourceVariant });
|
||||
}
|
||||
|
||||
function contentSource(
|
||||
collectionSlug: string,
|
||||
contentId: string,
|
||||
sourceVariant: MediaUsageContentSourceVariant,
|
||||
) {
|
||||
return {
|
||||
sourceKey: sourceKey(collectionSlug, contentId, sourceVariant),
|
||||
sourceType: "content",
|
||||
collectionSlug,
|
||||
contentId,
|
||||
sourceVariant,
|
||||
contentSlug: "hello-world",
|
||||
contentTitle: "Hello World",
|
||||
contentStatus: "published",
|
||||
schemaVersion: 1,
|
||||
sourceCompleteness: "complete" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function occurrence(fieldSlug: string, mediaId: string) {
|
||||
return {
|
||||
fieldSlug,
|
||||
fieldPath: fieldSlug,
|
||||
referenceType: "image_field" as const,
|
||||
mediaId,
|
||||
provider: "local",
|
||||
providerAssetId: mediaId,
|
||||
mediaKind: "image" as const,
|
||||
mimeType: "image/webp",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
const EXPECTED_SOURCE_COLUMNS = [
|
||||
"source_updated_at",
|
||||
"source_version",
|
||||
"source_fingerprint",
|
||||
"source_completeness",
|
||||
"last_attempted_at",
|
||||
"last_error_code",
|
||||
] as const;
|
||||
|
||||
const EXPECTED_STATUS_COLUMNS = [
|
||||
"adapter_id",
|
||||
"scope_type",
|
||||
"scope_key",
|
||||
"status",
|
||||
"schema_version",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"cursor",
|
||||
"indexed_source_count",
|
||||
"failed_source_count",
|
||||
"last_error_code",
|
||||
"updated_at",
|
||||
] as const;
|
||||
|
||||
const EXPECTED_INDEXES = [
|
||||
"idx__emdash_media_usage_sources_completeness",
|
||||
"idx__emdash_media_usage_sources_fingerprint",
|
||||
"idx__emdash_media_usage_index_status_status",
|
||||
] as const;
|
||||
|
||||
describeEachDialect("media usage index status migration", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("is registered and creates metadata columns plus status table", async () => {
|
||||
const migrations = await ctx.db.selectFrom("_emdash_migrations").select("name").execute();
|
||||
expect(migrations.map((row) => row.name)).toContain("050_media_usage_index_status");
|
||||
|
||||
const sourceColumns = await listColumnNames(ctx, "_emdash_media_usage_sources");
|
||||
for (const columnName of EXPECTED_SOURCE_COLUMNS) {
|
||||
expect(sourceColumns.has(columnName), `missing source column ${columnName}`).toBe(true);
|
||||
}
|
||||
|
||||
const statusColumns = await listColumnNames(ctx, "_emdash_media_usage_index_status");
|
||||
for (const columnName of EXPECTED_STATUS_COLUMNS) {
|
||||
expect(statusColumns.has(columnName), `missing status column ${columnName}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies defaults for source completeness and status counters", async () => {
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_sources")
|
||||
.values({
|
||||
source_key: "content:posts:entry1:columns",
|
||||
source_type: "content",
|
||||
collection_slug: "posts",
|
||||
content_id: "entry1",
|
||||
source_variant: "columns",
|
||||
locale: "en",
|
||||
translation_group: "tg1",
|
||||
content_slug: "hello-world",
|
||||
content_title: "Hello World",
|
||||
content_status: "published",
|
||||
content_scheduled_at: null,
|
||||
content_deleted_at: null,
|
||||
revision_id: "rev1",
|
||||
current_generation: "gen1",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const source = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_sources")
|
||||
.select([
|
||||
"source_completeness",
|
||||
"source_updated_at",
|
||||
"source_version",
|
||||
"source_fingerprint",
|
||||
"last_attempted_at",
|
||||
"last_error_code",
|
||||
])
|
||||
.where("source_key", "=", "content:posts:entry1:columns")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(source).toEqual({
|
||||
source_completeness: "unknown",
|
||||
source_updated_at: null,
|
||||
source_version: null,
|
||||
source_fingerprint: null,
|
||||
last_attempted_at: null,
|
||||
last_error_code: null,
|
||||
});
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_index_status")
|
||||
.values({
|
||||
adapter_id: "content-media",
|
||||
scope_type: "collection",
|
||||
scope_key: "posts",
|
||||
status: "never",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const status = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_index_status")
|
||||
.select([
|
||||
"schema_version",
|
||||
"indexed_source_count",
|
||||
"failed_source_count",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"cursor",
|
||||
"last_error_code",
|
||||
"updated_at",
|
||||
])
|
||||
.where("adapter_id", "=", "content-media")
|
||||
.where("scope_type", "=", "collection")
|
||||
.where("scope_key", "=", "posts")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(status).toEqual({
|
||||
schema_version: 1,
|
||||
indexed_source_count: 0,
|
||||
failed_source_count: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
cursor: null,
|
||||
last_error_code: null,
|
||||
updated_at: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate status rows for the same adapter scope", async () => {
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_index_status")
|
||||
.values({
|
||||
adapter_id: "content-media",
|
||||
scope_type: "collection",
|
||||
scope_key: "posts",
|
||||
status: "running",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await expect(
|
||||
ctx.db
|
||||
.insertInto("_emdash_media_usage_index_status")
|
||||
.values({
|
||||
adapter_id: "content-media",
|
||||
scope_type: "collection",
|
||||
scope_key: "posts",
|
||||
status: "complete",
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("adds source completeness default for existing PR 1 source rows", async () => {
|
||||
const migration =
|
||||
await import("../../../src/database/migrations/050_media_usage_index_status.js");
|
||||
const sourceKey = "content:posts:pre047:columns";
|
||||
|
||||
await migration.down(ctx.db);
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_sources")
|
||||
.values({
|
||||
source_key: sourceKey,
|
||||
source_type: "content",
|
||||
collection_slug: "posts",
|
||||
content_id: "pre047",
|
||||
source_variant: "columns",
|
||||
locale: "en",
|
||||
translation_group: "tg-pre047",
|
||||
content_slug: "pre047",
|
||||
content_title: "Pre 047",
|
||||
content_status: "published",
|
||||
content_scheduled_at: null,
|
||||
content_deleted_at: null,
|
||||
revision_id: "rev-pre047",
|
||||
current_generation: "gen-pre047",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await migration.up(ctx.db);
|
||||
|
||||
const source = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_sources")
|
||||
.select([
|
||||
"source_completeness",
|
||||
"source_updated_at",
|
||||
"source_version",
|
||||
"source_fingerprint",
|
||||
"last_attempted_at",
|
||||
"last_error_code",
|
||||
])
|
||||
.where("source_key", "=", sourceKey)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(source).toEqual({
|
||||
source_completeness: "unknown",
|
||||
source_updated_at: null,
|
||||
source_version: null,
|
||||
source_fingerprint: null,
|
||||
last_attempted_at: null,
|
||||
last_error_code: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates expected indexes", async () => {
|
||||
const indexNames = await listIndexNames(ctx);
|
||||
|
||||
for (const indexName of EXPECTED_INDEXES) {
|
||||
expect(indexNames.has(indexName), `missing index ${indexName}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("up() can run again after registered migrations complete", async () => {
|
||||
const migration =
|
||||
await import("../../../src/database/migrations/050_media_usage_index_status.js");
|
||||
const sourceKey = "content:posts:entry-preserve:columns";
|
||||
const sourceMetadata = {
|
||||
source_completeness: "complete",
|
||||
source_updated_at: "2026-01-01T00:00:00.000Z",
|
||||
source_version: 7,
|
||||
source_fingerprint: "fingerprint-preserve",
|
||||
last_attempted_at: "2026-01-01T00:00:01.000Z",
|
||||
last_error_code: "PREVIOUS_ERROR",
|
||||
};
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_sources")
|
||||
.values({
|
||||
source_key: sourceKey,
|
||||
source_type: "content",
|
||||
collection_slug: "posts",
|
||||
content_id: "entry-preserve",
|
||||
source_variant: "columns",
|
||||
locale: "en",
|
||||
translation_group: "tg-preserve",
|
||||
content_slug: "preserve",
|
||||
content_title: "Preserve",
|
||||
content_status: "published",
|
||||
content_scheduled_at: null,
|
||||
content_deleted_at: null,
|
||||
revision_id: "rev-preserve",
|
||||
current_generation: "gen-preserve",
|
||||
...sourceMetadata,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const statusMetadata = {
|
||||
schema_version: 3,
|
||||
started_at: "2026-01-02T00:00:00.000Z",
|
||||
completed_at: "2026-01-02T00:00:10.000Z",
|
||||
cursor: "cursor-preserve",
|
||||
indexed_source_count: 12,
|
||||
failed_source_count: 2,
|
||||
last_error_code: "STATUS_ERROR",
|
||||
updated_at: "2026-01-02T00:00:11.000Z",
|
||||
};
|
||||
|
||||
await ctx.db
|
||||
.insertInto("_emdash_media_usage_index_status")
|
||||
.values({
|
||||
adapter_id: "content-media",
|
||||
scope_type: "collection",
|
||||
scope_key: "posts",
|
||||
status: "partial",
|
||||
...statusMetadata,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await migration.up(ctx.db);
|
||||
|
||||
const statusColumns = await listColumnNames(ctx, "_emdash_media_usage_index_status");
|
||||
expect(statusColumns.has("adapter_id")).toBe(true);
|
||||
|
||||
const source = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_sources")
|
||||
.select([
|
||||
"source_completeness",
|
||||
"source_updated_at",
|
||||
"source_version",
|
||||
"source_fingerprint",
|
||||
"last_attempted_at",
|
||||
"last_error_code",
|
||||
])
|
||||
.where("source_key", "=", sourceKey)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(source).toEqual(sourceMetadata);
|
||||
|
||||
const status = await ctx.db
|
||||
.selectFrom("_emdash_media_usage_index_status")
|
||||
.select([
|
||||
"status",
|
||||
"schema_version",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"cursor",
|
||||
"indexed_source_count",
|
||||
"failed_source_count",
|
||||
"last_error_code",
|
||||
"updated_at",
|
||||
])
|
||||
.where("adapter_id", "=", "content-media")
|
||||
.where("scope_type", "=", "collection")
|
||||
.where("scope_key", "=", "posts")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(status).toEqual({ status: "partial", ...statusMetadata });
|
||||
});
|
||||
|
||||
it("down() drops metadata and up() recreates it", async () => {
|
||||
const migration =
|
||||
await import("../../../src/database/migrations/050_media_usage_index_status.js");
|
||||
|
||||
await migration.down(ctx.db);
|
||||
|
||||
await expect(
|
||||
sql`SELECT 1 FROM _emdash_media_usage_index_status`.execute(ctx.db),
|
||||
).rejects.toThrow();
|
||||
const sourceColumnsAfterDown = await listColumnNames(ctx, "_emdash_media_usage_sources");
|
||||
expect(sourceColumnsAfterDown.has("source_completeness")).toBe(false);
|
||||
|
||||
await migration.up(ctx.db);
|
||||
|
||||
const statusColumns = await listColumnNames(ctx, "_emdash_media_usage_index_status");
|
||||
expect(statusColumns.has("adapter_id")).toBe(true);
|
||||
const sourceColumnsAfterUp = await listColumnNames(ctx, "_emdash_media_usage_sources");
|
||||
expect(sourceColumnsAfterUp.has("source_completeness")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function listColumnNames(
|
||||
ctx: DialectTestContext,
|
||||
tableName: keyof Database,
|
||||
): Promise<Set<string>> {
|
||||
const tables = await ctx.db.introspection.getTables();
|
||||
const table = tables.find((candidate) => candidate.name === tableName);
|
||||
return new Set(table?.columns.map((column) => column.name) ?? []);
|
||||
}
|
||||
|
||||
async function listIndexNames(ctx: DialectTestContext): Promise<Set<string>> {
|
||||
if (ctx.dialect === "sqlite") {
|
||||
const result = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'index'
|
||||
`.execute(ctx.db);
|
||||
return new Set(result.rows.map((row) => row.name));
|
||||
}
|
||||
|
||||
const result = await sql<{ name: string }>`
|
||||
SELECT indexname AS name FROM pg_indexes WHERE schemaname = current_schema()
|
||||
`.execute(ctx.db);
|
||||
return new Set(result.rows.map((row) => row.name));
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* MenuRepository — repository-level tests
|
||||
*
|
||||
* Mirrors the coverage style of the other repository test suites
|
||||
* (`redirect-handlers.test.ts`, `taxonomy.test.ts`, etc.). The repository is
|
||||
* the single layer responsible for snake_case ↔ camelCase mapping and for
|
||||
* cross-table operations (translation cloning, atomic set-items, delete with
|
||||
* explicit item cleanup for D1 safety).
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MenuGoneError, MenuRepository } from "../../../src/database/repositories/menu.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("MenuRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: MenuRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
repo = new MenuRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("row → entity mapping", () => {
|
||||
it("create() returns a camelCase Menu", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
expect(menu).toMatchObject({ name: "primary", label: "Primary", locale: "en" });
|
||||
expect(typeof menu.createdAt).toBe("string");
|
||||
expect(typeof menu.updatedAt).toBe("string");
|
||||
expect(menu.translationGroup).toBe(menu.id);
|
||||
// no leakage of snake_case keys
|
||||
expect(menu).not.toHaveProperty("created_at");
|
||||
expect(menu).not.toHaveProperty("updated_at");
|
||||
expect(menu).not.toHaveProperty("translation_group");
|
||||
});
|
||||
|
||||
it("createItem() returns a camelCase MenuItem", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
const item = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "Blog",
|
||||
customUrl: "/blog",
|
||||
});
|
||||
expect(item).toMatchObject({
|
||||
menuId: menu.id,
|
||||
type: "custom",
|
||||
label: "Blog",
|
||||
customUrl: "/blog",
|
||||
sortOrder: 0,
|
||||
parentId: null,
|
||||
});
|
||||
expect(item.translationGroup).toBe(item.id);
|
||||
expect(item).not.toHaveProperty("custom_url");
|
||||
expect(item).not.toHaveProperty("menu_id");
|
||||
expect(item).not.toHaveProperty("sort_order");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany() — list with item counts", () => {
|
||||
it("returns 0 when there are no items", async () => {
|
||||
await repo.create({ name: "primary", label: "Primary" });
|
||||
const rows = await repo.findMany();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.itemCount).toBe(0);
|
||||
});
|
||||
|
||||
it("counts items correctly", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.createItem(menu.id, menu.locale, { type: "custom", label: "A", customUrl: "/a" });
|
||||
await repo.createItem(menu.id, menu.locale, { type: "custom", label: "B", customUrl: "/b" });
|
||||
const rows = await repo.findMany();
|
||||
expect(rows[0]!.itemCount).toBe(2);
|
||||
});
|
||||
|
||||
it("filters by locale when supplied", async () => {
|
||||
await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.create({ name: "primary", label: "Principal", locale: "es" });
|
||||
const all = await repo.findMany();
|
||||
expect(all).toHaveLength(2);
|
||||
const en = await repo.findMany({ locale: "en" });
|
||||
expect(en).toHaveLength(1);
|
||||
expect(en[0]!.locale).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByName()", () => {
|
||||
it("returns multiple rows when the same name has translations", async () => {
|
||||
const src = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.create({
|
||||
name: "primary",
|
||||
label: "Principal",
|
||||
locale: "es",
|
||||
translationOf: src.id,
|
||||
});
|
||||
const both = await repo.findByName("primary");
|
||||
expect(both).toHaveLength(2);
|
||||
expect(both.map((m) => m.locale).toSorted()).toEqual(["en", "es"]);
|
||||
});
|
||||
|
||||
it("scopes by locale when supplied", async () => {
|
||||
const src = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.create({
|
||||
name: "primary",
|
||||
label: "Principal",
|
||||
locale: "es",
|
||||
translationOf: src.id,
|
||||
});
|
||||
const es = await repo.findByName("primary", { locale: "es" });
|
||||
expect(es).toHaveLength(1);
|
||||
expect(es[0]!.locale).toBe("es");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create() with translationOf", () => {
|
||||
it("clones items into the new locale and shares translation_group", async () => {
|
||||
const src = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.createItem(src.id, src.locale, { type: "custom", label: "Home", customUrl: "/" });
|
||||
await repo.createItem(src.id, src.locale, {
|
||||
type: "custom",
|
||||
label: "About",
|
||||
customUrl: "/about",
|
||||
});
|
||||
|
||||
const target = await repo.create({
|
||||
name: "primary",
|
||||
label: "Principal",
|
||||
locale: "es",
|
||||
translationOf: src.id,
|
||||
});
|
||||
|
||||
expect(target.translationGroup).toBe(src.translationGroup);
|
||||
|
||||
const items = await repo.findItems(target.id);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((i) => i.label)).toEqual(["Home", "About"]);
|
||||
expect(items.every((i) => i.locale === "es")).toBe(true);
|
||||
// Each cloned item shares the source item's translation_group so the
|
||||
// nav entry is treated as "the same logical item" across translations.
|
||||
const srcItems = await repo.findItems(src.id);
|
||||
const srcGroupsByLabel = new Map(srcItems.map((i) => [i.label, i.translationGroup]));
|
||||
for (const cloned of items) {
|
||||
expect(cloned.translationGroup).toBe(srcGroupsByLabel.get(cloned.label));
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when the source menu doesn't exist", async () => {
|
||||
await expect(
|
||||
repo.create({
|
||||
name: "primary",
|
||||
label: "Principal",
|
||||
locale: "es",
|
||||
translationOf: "01XXNOTREALXXXXXXXXXXXXXXX",
|
||||
}),
|
||||
).rejects.toThrow(/source/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete() removes items first (D1-safe)", () => {
|
||||
it("deletes a menu and all its items", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "Home",
|
||||
customUrl: "/",
|
||||
});
|
||||
expect(await repo.findItems(menu.id)).toHaveLength(1);
|
||||
|
||||
const deleted = await repo.delete(menu.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// No menu left, no orphaned items in the table either.
|
||||
expect(await repo.findById(menu.id)).toBeNull();
|
||||
const orphans = await db
|
||||
.selectFrom("_emdash_menu_items")
|
||||
.selectAll()
|
||||
.where("menu_id", "=", menu.id)
|
||||
.execute();
|
||||
expect(orphans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns false when the menu doesn't exist", async () => {
|
||||
const deleted = await repo.delete("01XXNOTREALXXXXXXXXXXXXXXX");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createItem() default sortOrder", () => {
|
||||
it("appends with the next available sortOrder when omitted", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
const a = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "A",
|
||||
customUrl: "/a",
|
||||
});
|
||||
const b = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "B",
|
||||
customUrl: "/b",
|
||||
});
|
||||
expect(a.sortOrder).toBe(0);
|
||||
expect(b.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it("calculates sortOrder per parent scope", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
const parent = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "Parent",
|
||||
customUrl: "/p",
|
||||
});
|
||||
const child1 = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "C1",
|
||||
customUrl: "/p/1",
|
||||
parentId: parent.id,
|
||||
});
|
||||
const child2 = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "C2",
|
||||
customUrl: "/p/2",
|
||||
parentId: parent.id,
|
||||
});
|
||||
expect(child1.sortOrder).toBe(0);
|
||||
expect(child2.sortOrder).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateItem()", () => {
|
||||
it("returns null when the item doesn't belong to the menu", async () => {
|
||||
const menu1 = await repo.create({ name: "primary", label: "Primary" });
|
||||
const menu2 = await repo.create({ name: "footer", label: "Footer" });
|
||||
const item = await repo.createItem(menu2.id, menu2.locale, {
|
||||
type: "custom",
|
||||
label: "A",
|
||||
customUrl: "/a",
|
||||
});
|
||||
// Try to update menu2's item via menu1.id — should fail.
|
||||
const result = await repo.updateItem(menu1.id, item.id, { customUrl: "/hijacked" });
|
||||
expect(result).toBeNull();
|
||||
// And the original value is untouched.
|
||||
const items = await repo.findItems(menu2.id);
|
||||
expect(items[0]!.customUrl).toBe("/a");
|
||||
});
|
||||
|
||||
it("persists customUrl updates and keeps the camelCase shape", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
const item = await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "Blog",
|
||||
customUrl: "/blog",
|
||||
});
|
||||
const updated = await repo.updateItem(menu.id, item.id, { customUrl: "/new-blog" });
|
||||
expect(updated?.customUrl).toBe("/new-blog");
|
||||
expect(updated).not.toHaveProperty("custom_url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteItem()", () => {
|
||||
it("scopes the delete by menu_id", async () => {
|
||||
const a = await repo.create({ name: "primary", label: "Primary" });
|
||||
const b = await repo.create({ name: "footer", label: "Footer" });
|
||||
const item = await repo.createItem(b.id, b.locale, {
|
||||
type: "custom",
|
||||
label: "X",
|
||||
customUrl: "/x",
|
||||
});
|
||||
|
||||
// Attempting to delete it through the wrong menu should be a no-op.
|
||||
expect(await repo.deleteItem(a.id, item.id)).toBe(false);
|
||||
expect((await repo.findItems(b.id)).length).toBe(1);
|
||||
|
||||
// Through the correct menu it deletes.
|
||||
expect(await repo.deleteItem(b.id, item.id)).toBe(true);
|
||||
expect((await repo.findItems(b.id)).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setItems()", () => {
|
||||
it("replaces existing items atomically and resolves parentIndex", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.createItem(menu.id, menu.locale, {
|
||||
type: "custom",
|
||||
label: "Old",
|
||||
customUrl: "/old",
|
||||
});
|
||||
|
||||
const { itemCount } = await repo.setItems(menu.id, menu.locale, [
|
||||
{ label: "Root", type: "custom", customUrl: "/" },
|
||||
{ label: "Child", type: "custom", customUrl: "/child", parentIndex: 0 },
|
||||
{ label: "Grandchild", type: "custom", customUrl: "/gc", parentIndex: 1 },
|
||||
]);
|
||||
expect(itemCount).toBe(3);
|
||||
|
||||
const items = await repo.findItems(menu.id);
|
||||
expect(items.map((i) => i.label)).toEqual(["Root", "Child", "Grandchild"]);
|
||||
const byLabel = new Map(items.map((i) => [i.label, i]));
|
||||
expect(byLabel.get("Root")!.parentId).toBeNull();
|
||||
expect(byLabel.get("Child")!.parentId).toBe(byLabel.get("Root")!.id);
|
||||
expect(byLabel.get("Grandchild")!.parentId).toBe(byLabel.get("Child")!.id);
|
||||
});
|
||||
|
||||
it("throws MenuGoneError when the menu disappears mid-flight (concurrent delete)", async () => {
|
||||
// Simulates the race that the original handler's in-transaction
|
||||
// `notFoundSentinel` guarded against: another caller deletes the
|
||||
// menu between the handler's `resolveMenu` lookup and the
|
||||
// repository's destructive setItems write. Without the in-tx
|
||||
// existence check we'd happily insert orphan items on D1 (FKs off).
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
// Delete the menu's row (and any items) to mimic the racing call.
|
||||
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
|
||||
await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
|
||||
|
||||
await expect(
|
||||
repo.setItems(menu.id, "en", [{ label: "Stray", type: "custom", customUrl: "/stray" }]),
|
||||
).rejects.toBeInstanceOf(MenuGoneError);
|
||||
|
||||
// No orphan items left behind by the aborted transaction.
|
||||
const orphans = await db
|
||||
.selectFrom("_emdash_menu_items")
|
||||
.selectAll()
|
||||
.where("menu_id", "=", menu.id)
|
||||
.execute();
|
||||
expect(orphans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("touches updated_at on the menu", async () => {
|
||||
const menu = await repo.create({ name: "primary", label: "Primary" });
|
||||
const before = (await repo.findById(menu.id))!.updatedAt;
|
||||
// Force a measurable gap so timestamp resolution doesn't flake.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await repo.setItems(menu.id, menu.locale, [{ label: "X", type: "custom", customUrl: "/x" }]);
|
||||
const after = (await repo.findById(menu.id))!.updatedAt;
|
||||
expect(after >= before).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderItems()", () => {
|
||||
it("ignores updates that target items outside the menu", async () => {
|
||||
const a = await repo.create({ name: "primary", label: "Primary" });
|
||||
const b = await repo.create({ name: "footer", label: "Footer" });
|
||||
const aItem = await repo.createItem(a.id, a.locale, {
|
||||
type: "custom",
|
||||
label: "A",
|
||||
customUrl: "/a",
|
||||
});
|
||||
const bItem = await repo.createItem(b.id, b.locale, {
|
||||
type: "custom",
|
||||
label: "B",
|
||||
customUrl: "/b",
|
||||
});
|
||||
|
||||
// Pass the foreign item id through menu A's reorder — it should be
|
||||
// silently ignored (the where("menu_id", "=", a.id) guard rejects it).
|
||||
await repo.reorderItems(a.id, [
|
||||
{ id: aItem.id, parentId: null, sortOrder: 5 },
|
||||
{ id: bItem.id, parentId: null, sortOrder: 99 },
|
||||
]);
|
||||
|
||||
const aItems = await repo.findItems(a.id);
|
||||
const bItems = await repo.findItems(b.id);
|
||||
expect(aItems[0]!.sortOrder).toBe(5);
|
||||
expect(bItems[0]!.sortOrder).toBe(0); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("listTranslations()", () => {
|
||||
it("returns every translation in the group", async () => {
|
||||
const src = await repo.create({ name: "primary", label: "Primary" });
|
||||
await repo.create({
|
||||
name: "primary",
|
||||
label: "Principal",
|
||||
locale: "es",
|
||||
translationOf: src.id,
|
||||
});
|
||||
|
||||
const byId = await repo.listTranslations(src.id);
|
||||
expect(byId).not.toBeNull();
|
||||
expect(byId!.translationGroup).toBe(src.translationGroup);
|
||||
expect(byId!.translations).toHaveLength(2);
|
||||
expect(byId!.translations.map((t) => t.locale).toSorted()).toEqual(["en", "es"]);
|
||||
|
||||
const byGroup = await repo.listTranslations(src.translationGroup!);
|
||||
expect(byGroup!.translations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns null when neither id nor group matches", async () => {
|
||||
const result = await repo.listTranslations("01XXNOTREALXXXXXXXXXXXXXXX");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Fail-fast Postgres migration locking (#1744).
|
||||
*
|
||||
* On Cloudflare Workers, EmDash's per-isolate init lock cannot coordinate
|
||||
* across isolates, so multiple isolates can run `runMigrations` against the
|
||||
* same Postgres database concurrently. Kysely's stock adapter serializes
|
||||
* them with a *blocking* `pg_advisory_xact_lock` — when a pending migration
|
||||
* keeps failing, every cold start parks a connection inside Postgres
|
||||
* waiting for the lock and then retries the same failing migration.
|
||||
*
|
||||
* These tests run only when EMDASH_TEST_PG is set. They simulate the
|
||||
* concurrent isolate by holding the advisory lock from a second connection
|
||||
* of the same pool (a distinct Postgres session, like another isolate's
|
||||
* connection).
|
||||
*/
|
||||
|
||||
import { sql } from "kysely";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ConcurrentMigrationTimeoutError,
|
||||
MIGRATION_COUNT,
|
||||
runMigrations,
|
||||
} from "../../../src/database/migrations/runner.js";
|
||||
import {
|
||||
createTestPostgresDatabase,
|
||||
hasPgTestDatabase,
|
||||
type PgTestContext,
|
||||
setupTestPostgresDatabase,
|
||||
teardownTestPostgresDatabase,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
/** Kysely's migration advisory lock id (mirrored in pg-migration-lock.ts). */
|
||||
const LOCK_ID = BigInt("3853314791062309107");
|
||||
|
||||
/**
|
||||
* Acquire the migration advisory lock on a dedicated session (a separate
|
||||
* connection from the context's pool) and hold it until `release()` is
|
||||
* called — simulating another isolate's in-flight migrator.
|
||||
*/
|
||||
async function holdMigrationLock(
|
||||
ctx: PgTestContext,
|
||||
): Promise<{ release: () => void; done: Promise<void> }> {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let acquired!: () => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
const acquiredPromise = new Promise<void>((resolve, reject) => {
|
||||
acquired = resolve;
|
||||
failed = reject;
|
||||
});
|
||||
const done = ctx.db
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
await sql`select pg_advisory_xact_lock(${sql.lit(LOCK_ID)})`.execute(trx);
|
||||
acquired();
|
||||
// Keep the transaction (and therefore the lock) open until released.
|
||||
await held;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
failed(error);
|
||||
throw error;
|
||||
});
|
||||
await acquiredPromise;
|
||||
return { release, done };
|
||||
}
|
||||
|
||||
describe.runIf(hasPgTestDatabase)("Fail-fast Postgres migration lock (#1744)", () => {
|
||||
it("fails fast instead of blocking when another migrator holds the lock", async () => {
|
||||
// Fresh schema, no migrations applied — the state of a database whose
|
||||
// pending migrations another isolate is (unsuccessfully) applying.
|
||||
const ctx = await createTestPostgresDatabase();
|
||||
try {
|
||||
const lock = await holdMigrationLock(ctx);
|
||||
try {
|
||||
const start = Date.now();
|
||||
// The distinct error type matters: getDatabase() exempts it from
|
||||
// the failure backoff, since the lock holder may simply be slow
|
||||
// rather than failing.
|
||||
await expect(
|
||||
runMigrations(ctx.db, { migrationTableSchema: ctx.schemaName, raceWaitMs: 300 }),
|
||||
).rejects.toThrow(ConcurrentMigrationTimeoutError);
|
||||
// The old behavior blocked inside pg_advisory_xact_lock until the
|
||||
// holder finished — i.e. forever in this test. The fail-fast path
|
||||
// must return promptly: try-lock + a bounded ~300ms poll.
|
||||
expect(Date.now() - start).toBeLessThan(5000);
|
||||
} finally {
|
||||
lock.release();
|
||||
await lock.done;
|
||||
}
|
||||
} finally {
|
||||
await teardownTestPostgresDatabase(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats the busy lock as success once the concurrent migrator finishes", async () => {
|
||||
// Fully migrated schema with the last migration row removed — the
|
||||
// bookkeeping state a waiter observes while another isolate applies
|
||||
// the final pending migration.
|
||||
const ctx = await setupTestPostgresDatabase();
|
||||
try {
|
||||
const lastRow = await sql<{ name: string; timestamp: string }>`
|
||||
SELECT name, timestamp FROM _emdash_migrations ORDER BY name DESC LIMIT 1
|
||||
`.execute(ctx.db);
|
||||
const last = lastRow.rows[0]!;
|
||||
await sql`DELETE FROM _emdash_migrations WHERE name = ${last.name}`.execute(ctx.db);
|
||||
|
||||
const lock = await holdMigrationLock(ctx);
|
||||
try {
|
||||
const migration = runMigrations(ctx.db, {
|
||||
migrationTableSchema: ctx.schemaName,
|
||||
raceWaitMs: 10_000,
|
||||
});
|
||||
// While the migrator polls for the "concurrent migrator", restore
|
||||
// the row (as if the lock holder just applied the migration)...
|
||||
await sql`
|
||||
INSERT INTO _emdash_migrations (name, timestamp)
|
||||
VALUES (${last.name}, ${last.timestamp})
|
||||
`.execute(ctx.db);
|
||||
// ...and it must settle as success without applying anything itself.
|
||||
await expect(migration).resolves.toEqual({ applied: [] });
|
||||
} finally {
|
||||
lock.release();
|
||||
await lock.done;
|
||||
}
|
||||
} finally {
|
||||
await teardownTestPostgresDatabase(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a genuinely failing migration without queueing concurrent callers", async () => {
|
||||
// The incident shape: a pending migration that fails every attempt
|
||||
// (here: re-running 001_initial against an already-built schema).
|
||||
// Concurrent callers must all fail in bounded time — none may hang on
|
||||
// the advisory lock.
|
||||
const ctx = await setupTestPostgresDatabase();
|
||||
try {
|
||||
await sql`DELETE FROM _emdash_migrations WHERE name = '001_initial'`.execute(ctx.db);
|
||||
|
||||
const start = Date.now();
|
||||
const results = await Promise.allSettled([
|
||||
runMigrations(ctx.db, { migrationTableSchema: ctx.schemaName, raceWaitMs: 500 }),
|
||||
runMigrations(ctx.db, { migrationTableSchema: ctx.schemaName, raceWaitMs: 500 }),
|
||||
]);
|
||||
expect(Date.now() - start).toBeLessThan(15_000);
|
||||
|
||||
// Both reject: the migration genuinely fails, and the concurrent
|
||||
// caller either observes the busy lock (and the holder never
|
||||
// completes) or re-runs the same failing migration itself.
|
||||
expect(results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
|
||||
// The real migration error must not be swallowed as a race.
|
||||
const messages = results.map((r) =>
|
||||
r.status === "rejected" ? String((r.reason as Error).message) : "",
|
||||
);
|
||||
expect(messages.some((m) => /Migration failed/i.test(m))).toBe(true);
|
||||
|
||||
// And the bookkeeping still reflects the failure: the deleted row
|
||||
// was not silently restored.
|
||||
const count = await sql<{ count: number }>`
|
||||
SELECT COUNT(*) as count FROM _emdash_migrations
|
||||
`.execute(ctx.db);
|
||||
expect(Number(count.rows[0]?.count)).toBe(MIGRATION_COUNT - 1);
|
||||
} finally {
|
||||
await teardownTestPostgresDatabase(ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { MIGRATION_COUNT, runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
|
||||
/**
|
||||
* Reproduces the issue from #762: when two callers run migrations
|
||||
* concurrently against the same database (e.g. two Cloudflare Workers
|
||||
* isolates handling parallel requests during a fresh deploy), the Kysely
|
||||
* Migrator races on inserting into `_emdash_migrations` and the loser
|
||||
* throws `UNIQUE constraint failed: _emdash_migrations.name`.
|
||||
*
|
||||
* The Kysely SqliteAdapter (which D1 inherits from kysely-d1) has a no-op
|
||||
* `acquireMigrationLock`, so this race is unprotected on D1.
|
||||
*
|
||||
* We simulate the race here by pointing two independent Kysely instances
|
||||
* at the same SQLite file and starting `runMigrations` on both
|
||||
* concurrently. SQLite serializes writes, but both Migrators still race
|
||||
* on the bookkeeping insert.
|
||||
*/
|
||||
describe("Migration race condition (#762)", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "emdash-migration-race-"));
|
||||
dbPath = join(tmpDir, "data.db");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should not throw when two callers run migrations concurrently", async () => {
|
||||
const dbA = createDatabase({ url: `file:${dbPath}` });
|
||||
const dbB = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
// Fire both migrators in parallel against the same database file.
|
||||
// On D1, this is what happens when two Workers isolates spin up
|
||||
// at once on first request after deploy.
|
||||
const results = await Promise.allSettled([runMigrations(dbA), runMigrations(dbB)]);
|
||||
|
||||
const failures = results.filter((r) => r.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
const messages = failures.map((f) =>
|
||||
f.status === "rejected" ? String(f.reason?.message ?? f.reason) : "",
|
||||
);
|
||||
throw new Error(
|
||||
`Concurrent runMigrations should not throw, but got ${failures.length} failure(s):\n${messages.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// And the DB must actually be fully migrated — we don't want a
|
||||
// fix that just swallows errors and leaves the schema half-built.
|
||||
const verifyDb = createDatabase({ url: `file:${dbPath}` });
|
||||
try {
|
||||
const row = await sql<{ count: number }>`
|
||||
SELECT COUNT(*) as count FROM _emdash_migrations
|
||||
`.execute(verifyDb);
|
||||
expect(Number(row.rows[0]?.count)).toBe(MIGRATION_COUNT);
|
||||
} finally {
|
||||
await verifyDb.destroy();
|
||||
}
|
||||
} finally {
|
||||
await dbA.destroy();
|
||||
await dbB.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fast-path when the migration table has more rows than this build knows about", async () => {
|
||||
// Simulates an old isolate observing a database that's already been
|
||||
// migrated by a newer build (one extra migration recorded). The
|
||||
// fast-path must treat this as "fully migrated" rather than falling
|
||||
// through to the Kysely Migrator and risking the race-recovery path.
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
try {
|
||||
await runMigrations(db);
|
||||
// Insert a phantom future migration row to simulate a newer build.
|
||||
await sql`
|
||||
INSERT INTO _emdash_migrations (name, timestamp)
|
||||
VALUES ('999_future_build', ${new Date().toISOString()})
|
||||
`.execute(db);
|
||||
|
||||
// Should be a no-op via the fast-path — no errors, no extra work.
|
||||
const result = await runMigrations(db);
|
||||
expect(result.applied).toEqual([]);
|
||||
|
||||
// Row count is still MIGRATION_COUNT + 1 (we didn't truncate).
|
||||
const row = await sql<{ count: number }>`
|
||||
SELECT COUNT(*) as count FROM _emdash_migrations
|
||||
`.execute(db);
|
||||
expect(Number(row.rows[0]?.count)).toBe(MIGRATION_COUNT + 1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should still surface unrelated migration errors", async () => {
|
||||
// Exercises the non-race error path so a regression that swallows
|
||||
// real errors is caught. We migrate once, then delete a single row
|
||||
// from `_emdash_migrations` so the migrator tries to re-run that
|
||||
// migration and fails with `table ... already exists` — a non-race
|
||||
// error that must NOT be swallowed.
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
try {
|
||||
await runMigrations(db);
|
||||
await sql`DELETE FROM _emdash_migrations WHERE name = '001_initial'`.execute(db);
|
||||
await expect(runMigrations(db)).rejects.toThrow(/Migration failed/i);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import {
|
||||
runMigrations,
|
||||
getMigrationStatus,
|
||||
MIGRATION_COUNT,
|
||||
} from "../../../src/database/migrations/runner.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections } from "../../utils/test-db.js";
|
||||
|
||||
describe("Database Migrations (Integration)", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh in-memory database for each test
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Close the database connection
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("should create all tables from migrations", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Verify all tables exist by querying them
|
||||
// Note: No generic "content" table - collections create ec_* tables dynamically
|
||||
const tables = [
|
||||
"revisions",
|
||||
"taxonomies",
|
||||
"content_taxonomies",
|
||||
"media",
|
||||
"users",
|
||||
"options",
|
||||
"audit_logs",
|
||||
"_emdash_migrations",
|
||||
"_emdash_collections",
|
||||
"_emdash_fields",
|
||||
"_plugin_storage",
|
||||
"_plugin_state",
|
||||
"_plugin_indexes",
|
||||
"_emdash_sections",
|
||||
"_emdash_bylines",
|
||||
"_emdash_content_bylines",
|
||||
"_emdash_byline_fields",
|
||||
"_emdash_byline_field_values",
|
||||
"_emdash_byline_field_group_values",
|
||||
"_emdash_media_usage_sources",
|
||||
"_emdash_media_usage",
|
||||
"_emdash_media_usage_index_status",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
// Query table to verify it exists
|
||||
const result = await db
|
||||
.selectFrom(table as keyof Database)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should track migration in _emdash_migrations table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const migrations = await db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(migrations).toHaveLength(MIGRATION_COUNT);
|
||||
expect(migrations[0]?.name).toBe("001_initial");
|
||||
expect(migrations[0]?.timestamp).toBeDefined();
|
||||
expect(migrations[1]?.name).toBe("002_media_status");
|
||||
expect(migrations[1]?.timestamp).toBeDefined();
|
||||
expect(migrations[2]?.name).toBe("003_schema_registry");
|
||||
expect(migrations[2]?.timestamp).toBeDefined();
|
||||
expect(migrations[3]?.name).toBe("004_plugins");
|
||||
expect(migrations[3]?.timestamp).toBeDefined();
|
||||
expect(migrations[4]?.name).toBe("005_menus");
|
||||
expect(migrations[4]?.timestamp).toBeDefined();
|
||||
expect(migrations[5]?.name).toBe("006_taxonomy_defs");
|
||||
expect(migrations[5]?.timestamp).toBeDefined();
|
||||
expect(migrations[6]?.name).toBe("007_widgets");
|
||||
expect(migrations[6]?.timestamp).toBeDefined();
|
||||
expect(migrations[7]?.name).toBe("008_auth");
|
||||
expect(migrations[7]?.timestamp).toBeDefined();
|
||||
expect(migrations[8]?.name).toBe("009_user_disabled");
|
||||
expect(migrations[8]?.timestamp).toBeDefined();
|
||||
expect(migrations[9]?.name).toBe("011_sections");
|
||||
expect(migrations[9]?.timestamp).toBeDefined();
|
||||
expect(migrations[10]?.name).toBe("012_search");
|
||||
expect(migrations[10]?.timestamp).toBeDefined();
|
||||
expect(migrations[11]?.name).toBe("013_scheduled_publishing");
|
||||
expect(migrations[11]?.timestamp).toBeDefined();
|
||||
expect(migrations[12]?.name).toBe("014_draft_revisions");
|
||||
expect(migrations[12]?.timestamp).toBeDefined();
|
||||
expect(migrations[13]?.name).toBe("015_indexes");
|
||||
expect(migrations[13]?.timestamp).toBeDefined();
|
||||
expect(migrations[14]?.name).toBe("016_api_tokens");
|
||||
expect(migrations[14]?.timestamp).toBeDefined();
|
||||
expect(migrations[15]?.name).toBe("017_authorization_codes");
|
||||
expect(migrations[15]?.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("should be idempotent (running twice is safe)", async () => {
|
||||
await runMigrations(db);
|
||||
await runMigrations(db);
|
||||
|
||||
const migrations = await db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
// Should still only have the same number of migration records
|
||||
expect(migrations).toHaveLength(MIGRATION_COUNT);
|
||||
});
|
||||
|
||||
it("should re-run trailing migrations when schema changes were partially applied", async () => {
|
||||
await db.destroy();
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
|
||||
// Kysely only re-runs trailing entries; include the latest migrations.
|
||||
const trailing = [
|
||||
"034_published_at_index",
|
||||
"035_bounded_404_log",
|
||||
"036_i18n_menus_and_taxonomies",
|
||||
"037_credential_algorithm",
|
||||
"038_registry_plugin_state",
|
||||
"039_fix_fts5_triggers",
|
||||
"040_byline_i18n",
|
||||
"041_content_locale_list_index",
|
||||
"042_byline_fields",
|
||||
"043_content_references",
|
||||
"044_comment_reactions",
|
||||
"045_taxonomy_parent_group",
|
||||
"046_media_usage_index",
|
||||
"047_restore_taxonomy_parent_index",
|
||||
"048_restore_content_taxonomies_term_index",
|
||||
"049_taxonomies_name_locale_index",
|
||||
"050_media_usage_index_status",
|
||||
"051_content_taxonomies_denorm",
|
||||
];
|
||||
|
||||
await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();
|
||||
|
||||
const { applied } = await runMigrations(db);
|
||||
|
||||
for (const name of trailing) expect(applied).toContain(name);
|
||||
|
||||
const migrations = await db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
expect(migrations).toHaveLength(MIGRATION_COUNT);
|
||||
});
|
||||
|
||||
it("should report correct migration status", async () => {
|
||||
const statusBefore = await getMigrationStatus(db);
|
||||
expect(statusBefore.pending).toContain("001_initial");
|
||||
expect(statusBefore.pending).toContain("002_media_status");
|
||||
expect(statusBefore.applied).toHaveLength(0);
|
||||
|
||||
await runMigrations(db);
|
||||
|
||||
const statusAfter = await getMigrationStatus(db);
|
||||
expect(statusAfter.applied).toContain("001_initial");
|
||||
expect(statusAfter.applied).toContain("002_media_status");
|
||||
expect(statusAfter.pending).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create schema registry tables", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Test collections table
|
||||
const testId = "test-collection";
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: testId,
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
label_singular: "Post",
|
||||
has_seo: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const collection = await db
|
||||
.selectFrom("_emdash_collections")
|
||||
.selectAll()
|
||||
.where("id", "=", testId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(collection).toBeDefined();
|
||||
expect(collection?.slug).toBe("posts");
|
||||
expect(collection?.label).toBe("Posts");
|
||||
expect(collection?.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on collection slug", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: "id1",
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
has_seo: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Attempting to insert duplicate slug should fail
|
||||
await expect(
|
||||
db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: "id2",
|
||||
slug: "posts",
|
||||
label: "Posts Again",
|
||||
has_seo: 0,
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should create fields table with foreign key to collections", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Create collection first
|
||||
const collectionId = "collection-1";
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: collectionId,
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
has_seo: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create field
|
||||
await db
|
||||
.insertInto("_emdash_fields")
|
||||
.values({
|
||||
id: "field-1",
|
||||
collection_id: collectionId,
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
column_type: "TEXT",
|
||||
required: 0,
|
||||
unique: 0,
|
||||
sort_order: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const fields = await db
|
||||
.selectFrom("_emdash_fields")
|
||||
.selectAll()
|
||||
.where("collection_id", "=", collectionId)
|
||||
.execute();
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0]?.slug).toBe("title");
|
||||
});
|
||||
|
||||
it("should create revisions table with collection+entry_id", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Create revision for a content entry
|
||||
await db
|
||||
.insertInto("revisions")
|
||||
.values({
|
||||
id: "rev-1",
|
||||
collection: "posts",
|
||||
entry_id: "entry-1",
|
||||
data: JSON.stringify({ title: "Revised" }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const revisions = await db
|
||||
.selectFrom("revisions")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.where("entry_id", "=", "entry-1")
|
||||
.execute();
|
||||
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0]?.collection).toBe("posts");
|
||||
});
|
||||
|
||||
it("should create users table with unique email constraint", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
role: 50, // ADMIN
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Duplicate email should fail
|
||||
await expect(
|
||||
db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-2",
|
||||
email: "test@example.com",
|
||||
role: 10, // SUBSCRIBER
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should create taxonomies table with hierarchical support", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Create parent category
|
||||
const parentId = "cat-parent";
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: parentId,
|
||||
name: "category",
|
||||
slug: "parent",
|
||||
label: "Parent Category",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create child category
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: "cat-child",
|
||||
name: "category",
|
||||
slug: "child",
|
||||
label: "Child Category",
|
||||
parent_id: parentId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const child = await db
|
||||
.selectFrom("taxonomies")
|
||||
.selectAll()
|
||||
.where("id", "=", "cat-child")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(child?.parent_id).toBe(parentId);
|
||||
});
|
||||
|
||||
it("should keep idx_taxonomies_parent after the full migration chain (regression for #1665)", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const indexes = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'taxonomies'
|
||||
`.execute(db);
|
||||
const names = new Set(indexes.rows.map((r) => r.name));
|
||||
|
||||
expect(names).toContain("idx_taxonomies_parent");
|
||||
});
|
||||
|
||||
it("should keep idx_content_taxonomies_term after the full migration chain (regression for #1701)", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const indexes = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'content_taxonomies'
|
||||
`.execute(db);
|
||||
const names = new Set(indexes.rows.map((r) => r.name));
|
||||
|
||||
expect(names).toContain("idx_content_taxonomies_term");
|
||||
});
|
||||
|
||||
it("should replace idx_taxonomies_name with composite idx_taxonomies_name_locale (#1723)", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const indexes = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'taxonomies'
|
||||
`.execute(db);
|
||||
const names = new Set(indexes.rows.map((r) => r.name));
|
||||
|
||||
// The composite (name, locale) index is added...
|
||||
expect(names).toContain("idx_taxonomies_name_locale");
|
||||
// ...and it supersedes the single-column name index (leftmost prefix
|
||||
// covers every name-only lookup), so the redundant one is dropped.
|
||||
expect(names).not.toContain("idx_taxonomies_name");
|
||||
});
|
||||
|
||||
it("plans findByName(name, locale) through the composite index instead of scanning the locale (regression for #1723)", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// One taxonomy dominates the locale; a small facet shares it. Without the
|
||||
// composite index the planner picks idx_taxonomies_locale and reads every
|
||||
// term in the locale to filter `name` in memory — per facet fetched.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: `tag-${i}`,
|
||||
name: "tag",
|
||||
slug: `tag-${i}`,
|
||||
label: `Tag ${i}`,
|
||||
locale: "en",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: `cat-${i}`,
|
||||
name: "category",
|
||||
slug: `cat-${i}`,
|
||||
label: `Category ${i}`,
|
||||
locale: "en",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Exact query shape emitted by TaxonomyRepository.findByName(name, { locale }).
|
||||
const plan = await sql<{ detail: string }>`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM "taxonomies"
|
||||
WHERE "name" = ${"category"} AND "locale" = ${"en"}
|
||||
ORDER BY "label" ASC, "id" ASC
|
||||
`.execute(db);
|
||||
const details = plan.rows.map((r) => r.detail).join("\n");
|
||||
|
||||
expect(details).toContain("idx_taxonomies_name_locale");
|
||||
expect(details).not.toContain("idx_taxonomies_locale");
|
||||
});
|
||||
|
||||
it("should create content_taxonomies junction table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const taxonomyId = "tax-1";
|
||||
|
||||
// Create taxonomy
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: taxonomyId,
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Assign taxonomy to content entry (collection + entry_id)
|
||||
await db
|
||||
.insertInto("content_taxonomies")
|
||||
.values({
|
||||
collection: "posts",
|
||||
entry_id: "entry-1",
|
||||
taxonomy_id: taxonomyId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const assignments = await db
|
||||
.selectFrom("content_taxonomies")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.where("entry_id", "=", "entry-1")
|
||||
.execute();
|
||||
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments[0]?.taxonomy_id).toBe(taxonomyId);
|
||||
});
|
||||
|
||||
it("should create media table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("media")
|
||||
.values({
|
||||
id: "media-1",
|
||||
filename: "photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
size: 1024000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
alt: "Test photo",
|
||||
storage_key: "uploads/photo.jpg",
|
||||
status: "ready",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const media = await db
|
||||
.selectFrom("media")
|
||||
.selectAll()
|
||||
.where("id", "=", "media-1")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(media).toBeDefined();
|
||||
expect(media?.width).toBe(1920);
|
||||
expect(media?.height).toBe(1080);
|
||||
});
|
||||
|
||||
it("should create options table for key-value storage", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("options")
|
||||
.values({
|
||||
name: "site_title",
|
||||
value: JSON.stringify("My Site"),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const option = await db
|
||||
.selectFrom("options")
|
||||
.selectAll()
|
||||
.where("name", "=", "site_title")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(option).toBeDefined();
|
||||
expect(JSON.parse(option!.value)).toBe("My Site");
|
||||
});
|
||||
|
||||
it("should create audit_logs table with indexes", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("audit_logs")
|
||||
.values({
|
||||
id: "log-1",
|
||||
actor_id: "user-1",
|
||||
actor_ip: "192.168.1.1",
|
||||
action: "content:create",
|
||||
resource_type: "content",
|
||||
resource_id: "post-1",
|
||||
status: "success",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const logs = await db
|
||||
.selectFrom("audit_logs")
|
||||
.selectAll()
|
||||
.where("actor_id", "=", "user-1")
|
||||
.execute();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0]?.action).toBe("content:create");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* OptionsRepository.setIfAbsent — atomic write-once semantics.
|
||||
*
|
||||
* Used by routes that must never overwrite a stored value once set
|
||||
* (e.g. the setup wizard's emdash:site_url write). Correctness under
|
||||
* concurrent writes is a security property: a non-atomic read-then-write
|
||||
* lets a second caller win the race and poison the value.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { OptionsRepository } from "../../../src/database/repositories/options.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("OptionsRepository.setIfAbsent", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: OptionsRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
repo = new OptionsRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("inserts when no row exists and returns true", async () => {
|
||||
const inserted = await repo.setIfAbsent("emdash:site_url", "https://example.com");
|
||||
expect(inserted).toBe(true);
|
||||
expect(await repo.get("emdash:site_url")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing value and returns false", async () => {
|
||||
await repo.set("emdash:site_url", "https://real.example");
|
||||
const inserted = await repo.setIfAbsent("emdash:site_url", "https://attacker.example");
|
||||
expect(inserted).toBe(false);
|
||||
expect(await repo.get("emdash:site_url")).toBe("https://real.example");
|
||||
});
|
||||
|
||||
it("treats an empty string as present (does not overwrite)", async () => {
|
||||
await repo.set("emdash:site_url", "");
|
||||
const inserted = await repo.setIfAbsent("emdash:site_url", "https://attacker.example");
|
||||
expect(inserted).toBe(false);
|
||||
expect(await repo.get("emdash:site_url")).toBe("");
|
||||
});
|
||||
|
||||
it("treats a stored null as present (does not overwrite)", async () => {
|
||||
await repo.set("emdash:site_url", null);
|
||||
const inserted = await repo.setIfAbsent("emdash:site_url", "https://attacker.example");
|
||||
expect(inserted).toBe(false);
|
||||
expect(await repo.get("emdash:site_url")).toBeNull();
|
||||
});
|
||||
|
||||
it("is atomic under concurrent callers — only one insert succeeds", async () => {
|
||||
const results = await Promise.all([
|
||||
repo.setIfAbsent("emdash:site_url", "https://a.example"),
|
||||
repo.setIfAbsent("emdash:site_url", "https://b.example"),
|
||||
repo.setIfAbsent("emdash:site_url", "https://c.example"),
|
||||
]);
|
||||
|
||||
// Exactly one caller inserted; the others saw the existing row.
|
||||
expect(results.filter((r) => r === true)).toHaveLength(1);
|
||||
expect(results.filter((r) => r === false)).toHaveLength(2);
|
||||
|
||||
// And whichever value landed first now sticks.
|
||||
const final = await repo.get("emdash:site_url");
|
||||
expect(["https://a.example", "https://b.example", "https://c.example"]).toContain(final);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { ulid } from "ulidx";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
|
||||
import { RelationRepository } from "../../../src/database/repositories/relation.js";
|
||||
import {
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describeEachDialect("RelationRepository", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let repo: RelationRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect); // runs all migrations
|
||||
repo = new RelationRepository(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
const baseInput = {
|
||||
name: "manages",
|
||||
parentCollection: "employees",
|
||||
childCollection: "employees",
|
||||
parentLabel: "Manager",
|
||||
childLabel: "Direct report",
|
||||
};
|
||||
|
||||
it("create mints an anchor row (translation_group = id, default locale)", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
expect(rel.id).toBeTruthy();
|
||||
expect(rel.translationGroup).toBe(rel.id);
|
||||
expect(rel.locale).toBe("en");
|
||||
expect(rel.name).toBe("manages");
|
||||
expect(rel.parentCollection).toBe("employees");
|
||||
expect(rel.childCollection).toBe("employees");
|
||||
|
||||
const fetched = await repo.findById(rel.id);
|
||||
expect(fetched).toEqual(rel);
|
||||
});
|
||||
|
||||
it("create with translationOf joins the group and inherits structural fields", async () => {
|
||||
const anchor = await repo.create({ ...baseInput });
|
||||
const fr = await repo.create({
|
||||
name: "ignored-name",
|
||||
parentCollection: "ignored",
|
||||
childCollection: "ignored",
|
||||
parentLabel: "Responsable",
|
||||
childLabel: "Subordonné",
|
||||
locale: "fr",
|
||||
translationOf: anchor.id,
|
||||
});
|
||||
|
||||
expect(fr.translationGroup).toBe(anchor.translationGroup);
|
||||
expect(fr.locale).toBe("fr");
|
||||
expect(fr.name).toBe("manages");
|
||||
expect(fr.parentCollection).toBe("employees");
|
||||
expect(fr.childCollection).toBe("employees");
|
||||
expect(fr.parentLabel).toBe("Responsable");
|
||||
expect(fr.childLabel).toBe("Subordonné");
|
||||
});
|
||||
|
||||
it("create with translationOf omits collections and inherits them from the source", async () => {
|
||||
const anchor = await repo.create({ ...baseInput });
|
||||
const fr = await repo.create({
|
||||
name: "ignored-name",
|
||||
parentLabel: "Responsable",
|
||||
childLabel: "Subordonné",
|
||||
locale: "fr",
|
||||
translationOf: anchor.id,
|
||||
});
|
||||
|
||||
expect(fr.translationGroup).toBe(anchor.translationGroup);
|
||||
expect(fr.parentCollection).toBe("employees");
|
||||
expect(fr.childCollection).toBe("employees");
|
||||
});
|
||||
|
||||
it("create without translationOf and without collections throws", async () => {
|
||||
await expect(
|
||||
repo.create({
|
||||
name: "manages",
|
||||
parentLabel: "Manager",
|
||||
childLabel: "Direct report",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"parentCollection and childCollection are required unless translationOf is set",
|
||||
);
|
||||
});
|
||||
|
||||
it("create with a missing translationOf source throws", async () => {
|
||||
await expect(
|
||||
repo.create({ ...baseInput, locale: "fr", translationOf: "does-not-exist" }),
|
||||
).rejects.toThrow("Source relation for translation not found");
|
||||
});
|
||||
|
||||
it("findById returns null for an unknown id", async () => {
|
||||
expect(await repo.findById("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByName filters by locale, and resolves deterministically without one", async () => {
|
||||
const anchor = await repo.create({ ...baseInput });
|
||||
await repo.create({
|
||||
...baseInput,
|
||||
locale: "fr",
|
||||
parentLabel: "Responsable",
|
||||
childLabel: "Subordonné",
|
||||
translationOf: anchor.id,
|
||||
});
|
||||
|
||||
const fr = await repo.findByName("manages", "fr");
|
||||
expect(fr?.locale).toBe("fr");
|
||||
|
||||
const any = await repo.findByName("manages");
|
||||
expect(any?.locale).toBe("en"); // lowest locale code wins deterministically
|
||||
|
||||
expect(await repo.findByName("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("findTranslations returns every locale sibling, ordered by locale", async () => {
|
||||
const anchor = await repo.create({ ...baseInput });
|
||||
await repo.create({
|
||||
...baseInput,
|
||||
locale: "fr",
|
||||
parentLabel: "Responsable",
|
||||
childLabel: "Subordonné",
|
||||
translationOf: anchor.id,
|
||||
});
|
||||
|
||||
const sibs = await repo.findTranslations(anchor.translationGroup);
|
||||
expect(sibs.map((r) => r.locale)).toEqual(["en", "fr"]);
|
||||
});
|
||||
|
||||
it("list returns relations ordered by name then id, optionally filtered by locale", async () => {
|
||||
await repo.create({ ...baseInput, name: "writes", childCollection: "posts" });
|
||||
const manages = await repo.create({ ...baseInput, name: "manages" });
|
||||
await repo.create({
|
||||
...baseInput,
|
||||
locale: "fr",
|
||||
parentLabel: "Responsable",
|
||||
childLabel: "Subordonné",
|
||||
translationOf: manages.id,
|
||||
});
|
||||
|
||||
const all = await repo.list();
|
||||
expect(all.map((r) => r.name)).toEqual(["manages", "manages", "writes"]);
|
||||
|
||||
const enOnly = await repo.list("en");
|
||||
// The 'fr' row must be filtered out — assert the filter actually removes it.
|
||||
expect(enOnly.length).toBeLessThan(all.length);
|
||||
expect(enOnly.every((r) => r.locale === "en")).toBe(true);
|
||||
});
|
||||
|
||||
it("findForCollection matches parent OR child collection", async () => {
|
||||
await repo.create({
|
||||
...baseInput,
|
||||
name: "writes",
|
||||
parentCollection: "authors",
|
||||
childCollection: "posts",
|
||||
});
|
||||
await repo.create({
|
||||
...baseInput,
|
||||
name: "tags_rel",
|
||||
parentCollection: "posts",
|
||||
childCollection: "tags",
|
||||
});
|
||||
|
||||
const forPosts = await repo.findForCollection("posts");
|
||||
// Asserted in returned order to also verify the (name, id) ORDER BY.
|
||||
expect(forPosts.map((r) => r.name)).toEqual(["tags_rel", "writes"]);
|
||||
|
||||
const forTags = await repo.findForCollection("tags");
|
||||
expect(forTags.map((r) => r.name)).toEqual(["tags_rel"]);
|
||||
});
|
||||
|
||||
it("update changes only the localized labels (no-op on missing id)", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
const updated = await repo.update(rel.id, { parentLabel: "Lead", childLabel: "Report" });
|
||||
|
||||
expect(updated?.parentLabel).toBe("Lead");
|
||||
expect(updated?.childLabel).toBe("Report");
|
||||
// Structural fields untouched.
|
||||
expect(updated?.name).toBe("manages");
|
||||
expect(updated?.parentCollection).toBe("employees");
|
||||
|
||||
expect(await repo.update("missing", { parentLabel: "x" })).toBeNull();
|
||||
});
|
||||
|
||||
it("delete of a non-last translation leaves edges intact", async () => {
|
||||
const anchor = await repo.create({ ...baseInput });
|
||||
const fr = await repo.create({
|
||||
...baseInput,
|
||||
locale: "fr",
|
||||
parentLabel: "Responsable",
|
||||
childLabel: "Subordonné",
|
||||
translationOf: anchor.id,
|
||||
});
|
||||
// Seed an edge directly (addReference arrives in Task 4).
|
||||
await ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values({
|
||||
id: ulid(),
|
||||
relation_group: anchor.translationGroup,
|
||||
parent_group: "parentG",
|
||||
child_group: "childG",
|
||||
sort_order: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
expect(await repo.delete(fr.id)).toBe(true);
|
||||
// The 'en' anchor row must survive (only the 'fr' translation was deleted)...
|
||||
expect(await repo.findById(anchor.id)).not.toBeNull();
|
||||
// ...and so must its edges.
|
||||
const edges = await ctx.db
|
||||
.selectFrom("_emdash_content_references")
|
||||
.selectAll()
|
||||
.where("relation_group", "=", anchor.translationGroup)
|
||||
.execute();
|
||||
expect(edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("delete of the last translation purges edges for that relation group", async () => {
|
||||
const anchor = await repo.create({ ...baseInput });
|
||||
await ctx.db
|
||||
.insertInto("_emdash_content_references")
|
||||
.values({
|
||||
id: ulid(),
|
||||
relation_group: anchor.translationGroup,
|
||||
parent_group: "parentG",
|
||||
child_group: "childG",
|
||||
sort_order: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
expect(await repo.delete(anchor.id)).toBe(true);
|
||||
const edges = await ctx.db
|
||||
.selectFrom("_emdash_content_references")
|
||||
.selectAll()
|
||||
.where("relation_group", "=", anchor.translationGroup)
|
||||
.execute();
|
||||
expect(edges).toHaveLength(0);
|
||||
expect(await repo.findById(anchor.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("addReference appends by sort_order and dedupes on conflict", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.addReference(rel.id, "p1", "cA");
|
||||
await repo.addReference(rel.id, "p1", "cB");
|
||||
await repo.addReference(rel.id, "p1", "cA"); // duplicate — no-op
|
||||
|
||||
const children = await repo.getChildren(rel.translationGroup, "p1");
|
||||
expect(children.map((c) => c.childGroup)).toEqual(["cA", "cB"]);
|
||||
expect(children.map((c) => c.sortOrder)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("addReference accepts a relation id OR its group, and an explicit sortOrder", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.addReference(rel.translationGroup, "p1", "cA", 5);
|
||||
const children = await repo.getChildren(rel.translationGroup, "p1");
|
||||
expect(children).toEqual([
|
||||
{
|
||||
id: expect.any(String),
|
||||
relationGroup: rel.translationGroup,
|
||||
parentGroup: "p1",
|
||||
childGroup: "cA",
|
||||
sortOrder: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getParents is the backlink view; removeReference removes one edge", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.addReference(rel.id, "p1", "shared");
|
||||
await repo.addReference(rel.id, "p2", "shared");
|
||||
|
||||
const parents = await repo.getParents(rel.translationGroup, "shared");
|
||||
expect(parents.map((p) => p.parentGroup).toSorted()).toEqual(["p1", "p2"]);
|
||||
|
||||
await repo.removeReference(rel.id, "p1", "shared");
|
||||
const after = await repo.getParents(rel.translationGroup, "shared");
|
||||
expect(after.map((p) => p.parentGroup)).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("self-reference (same group as parent and child) is allowed", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.addReference(rel.id, "self", "self");
|
||||
const children = await repo.getChildren(rel.translationGroup, "self");
|
||||
expect(children.map((c) => c.childGroup)).toEqual(["self"]);
|
||||
});
|
||||
|
||||
it("edge methods no-op for an unknown relation", async () => {
|
||||
await repo.addReference("unknown-relation", "p1", "cA");
|
||||
expect(await repo.getChildren("unknown-relation", "p1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeReference of a nonexistent edge is a no-op", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await expect(repo.removeReference(rel.id, "p1", "never-added")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("setChildren replaces the set and assigns positional sort_order", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.setChildren(rel.id, "p1", ["a", "b", "c"]);
|
||||
|
||||
let children = await repo.getChildren(rel.translationGroup, "p1");
|
||||
expect(children.map((c) => c.childGroup)).toEqual(["a", "b", "c"]);
|
||||
expect(children.map((c) => c.sortOrder)).toEqual([0, 1, 2]);
|
||||
|
||||
// Reorder + drop 'a' + add 'd'.
|
||||
await repo.setChildren(rel.id, "p1", ["c", "b", "d"]);
|
||||
children = await repo.getChildren(rel.translationGroup, "p1");
|
||||
expect(children.map((c) => c.childGroup)).toEqual(["c", "b", "d"]);
|
||||
expect(children.map((c) => c.sortOrder)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("setChildren with an empty list clears the parent's children", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.setChildren(rel.id, "p1", ["a", "b"]);
|
||||
await repo.setChildren(rel.id, "p1", []);
|
||||
expect(await repo.getChildren(rel.translationGroup, "p1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("setChildren collapses duplicate childGroups (one edge per child)", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.setChildren(rel.id, "p1", ["a", "b", "a"]);
|
||||
const children = await repo.getChildren(rel.translationGroup, "p1");
|
||||
expect(children.map((c) => c.childGroup)).toEqual(["a", "b"]);
|
||||
expect(children.map((c) => c.sortOrder)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("setChildren no-ops for an unknown relation", async () => {
|
||||
await expect(repo.setChildren("unknown-relation", "p1", ["a"])).resolves.toBeUndefined();
|
||||
expect(await repo.getChildren("unknown-relation", "p1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearReferencesForGroup removes edges where the group is parent OR child", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.addReference(rel.id, "X", "a"); // X as parent
|
||||
await repo.addReference(rel.id, "b", "X"); // X as child
|
||||
await repo.addReference(rel.id, "b", "c"); // unrelated
|
||||
|
||||
const removed = await repo.clearReferencesForGroup("X");
|
||||
expect(removed).toBe(2);
|
||||
|
||||
expect(await repo.getChildren(rel.translationGroup, "X")).toHaveLength(0);
|
||||
expect(await repo.getParents(rel.translationGroup, "X")).toHaveLength(0);
|
||||
expect(await repo.getChildren(rel.translationGroup, "b")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clearReferencesForGroup purges the group's edges across every relation", async () => {
|
||||
const relA = await repo.create({ ...baseInput, name: "rel_a" });
|
||||
const relB = await repo.create({ ...baseInput, name: "rel_b" });
|
||||
// The same content group "X" participates in edges under two relations.
|
||||
await repo.addReference(relA.id, "X", "a");
|
||||
await repo.addReference(relB.id, "b", "X");
|
||||
|
||||
const removed = await repo.clearReferencesForGroup("X");
|
||||
expect(removed).toBe(2);
|
||||
expect(await repo.getChildren(relA.translationGroup, "X")).toHaveLength(0);
|
||||
expect(await repo.getParents(relB.translationGroup, "X")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("countChildren and countParents count edges", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
await repo.addReference(rel.id, "p1", "a");
|
||||
await repo.addReference(rel.id, "p1", "b");
|
||||
await repo.addReference(rel.id, "p2", "a");
|
||||
|
||||
expect(await repo.countChildren(rel.id, "p1")).toBe(2);
|
||||
expect(await repo.countParents(rel.id, "a")).toBe(2);
|
||||
|
||||
// Unknown relation resolves to no group → zero.
|
||||
expect(await repo.countChildren("unknown-relation", "p1")).toBe(0);
|
||||
expect(await repo.countParents("unknown-relation", "a")).toBe(0);
|
||||
});
|
||||
|
||||
it("countChildrenForParents batches across more than SQL_BATCH_SIZE parents", async () => {
|
||||
const rel = await repo.create({ ...baseInput });
|
||||
const parents = Array.from({ length: 120 }, (_, i) => `p${i}`);
|
||||
for (const p of parents) await repo.addReference(rel.id, p, "child");
|
||||
|
||||
const counts = await repo.countChildrenForParents(rel.id, parents);
|
||||
expect(counts.size).toBe(120);
|
||||
expect(counts.get("p0")).toBe(1);
|
||||
expect(counts.get("p119")).toBe(1);
|
||||
|
||||
expect((await repo.countChildrenForParents(rel.id, [])).size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user