diff --git a/.changeset/export-seed-bylines.md b/.changeset/export-seed-bylines.md new file mode 100644 index 000000000..a14021c65 --- /dev/null +++ b/.changeset/export-seed-bylines.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fix `emdash export-seed` omitting bylines. The exporter now emits byline profiles as the root `bylines[]` array (one entry per translation group, since `SeedByline` has no locale axis) and, when content is exported, attaches each entry's ordered byline credits as `bylines[]` referencing those profiles. Credits are read straight from `_emdash_content_bylines` (whose `byline_id` already stores the translation group), so the exported seed round-trips back through `applySeed` with profiles and credits intact. diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 870d3a15a..9e76e5733 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -13,6 +13,7 @@ import { sql } from "kysely"; import { createDatabase } from "../../database/connection.js"; import { runMigrations } from "../../database/migrations/runner.js"; +import { BylineRepository } from "../../database/repositories/byline.js"; import { ContentRepository } from "../../database/repositories/content.js"; import { MediaRepository } from "../../database/repositories/media.js"; import { OptionsRepository } from "../../database/repositories/options.js"; @@ -33,6 +34,8 @@ import type { SeedWidgetArea, SeedWidget, SeedContentEntry, + SeedByline, + SeedBylineCredit, } from "../../seed/types.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { slugify } from "../../utils/slugify.js"; @@ -136,7 +139,14 @@ export async function exportSeed(db: Kysely, withContent?: string): Pr // 5. Export widget areas seed.widgetAreas = await exportWidgetAreas(db); - // 6. Export content (if requested) + // 6. Export byline profiles. The returned map (translation_group -> seed-local + // id) lets content credits below reference the same ids the root list emits. + const { bylines, groupToSeedId } = await exportBylines(db); + if (bylines.length > 0) { + seed.bylines = bylines; + } + + // 7. Export content (if requested) if (withContent !== undefined) { const collections = withContent === "" || withContent === "true" @@ -146,12 +156,65 @@ export async function exportSeed(db: Kysely, withContent?: string): Pr .map((s) => s.trim()) .filter(Boolean); - seed.content = await exportContent(db, seed.collections || [], collections, i18nEnabled); + seed.content = await exportContent( + db, + seed.collections || [], + collections, + groupToSeedId, + i18nEnabled, + ); } return seed; } +/** + * Export byline profiles as root-level `bylines[]`. + * + * `SeedByline` has no locale axis, so locale siblings of the same byline + * (sharing a `translation_group`) collapse to a single profile. The returned + * `groupToSeedId` map keys on `translation_group` — the value stored in + * `_emdash_content_bylines.byline_id` — so content credits can resolve to the + * emitted seed id. + */ +async function exportBylines( + db: Kysely, +): Promise<{ bylines: SeedByline[]; groupToSeedId: Map }> { + const bylineRepo = new BylineRepository(db); + const bylines: SeedByline[] = []; + const groupToSeedId = new Map(); + const usedSeedIds = new Set(); + + let cursor: string | undefined; + do { + const result = await bylineRepo.findMany({ limit: 100, cursor }); + for (const byline of result.items) { + const group = byline.translationGroup ?? byline.id; + // One seed entry per translation group; first row seen wins. + if (groupToSeedId.has(group)) continue; + + let seedId = `byline:${byline.slug}`; + // Disambiguate the rare case of two distinct groups sharing a slug + // (slug is unique per-locale, not globally) so seed ids stay unique. + if (usedSeedIds.has(seedId)) seedId = `byline:${byline.slug}:${group}`; + usedSeedIds.add(seedId); + groupToSeedId.set(group, seedId); + + bylines.push({ + id: seedId, + slug: byline.slug, + displayName: byline.displayName, + bio: byline.bio || undefined, + websiteUrl: byline.websiteUrl || undefined, + isGuest: byline.isGuest || undefined, + }); + } + cursor = result.nextCursor; + } while (cursor); + + return { bylines, groupToSeedId }; +} + /** * Determine whether the export should emit locale-suffixed seed ids. * @@ -601,6 +664,7 @@ async function exportContent( db: Kysely, collections: SeedCollection[], includeCollections: string[] | null, + bylineGroupToSeedId: Map, i18nEnabled: boolean, ): Promise> { const content: Record = {}; @@ -696,6 +760,16 @@ async function exportContent( entry.taxonomies = taxonomies; } + // Get byline credits. Read the junction directly: its `byline_id` + // stores the translation_group, which is exactly the key in + // `bylineGroupToSeedId`. This is locale-agnostic (one row per + // credit) and avoids the locale-sibling fan-out a hydrated read + // would produce. + const bylines = await getBylineCredits(db, collection.slug, item.id, bylineGroupToSeedId); + if (bylines.length > 0) { + entry.bylines = bylines; + } + entries.push(entry); } @@ -777,6 +851,40 @@ function processDataForExport( return result; } +/** + * Get ordered byline credits for a content entry as `SeedBylineCredit[]`. + * + * The `_emdash_content_bylines.byline_id` column stores the credited byline's + * `translation_group`, so it maps straight through `groupToSeedId`. Credits + * whose group wasn't emitted in the root `bylines[]` are skipped (defensive; + * shouldn't happen for a consistent DB). + */ +async function getBylineCredits( + db: Kysely, + collection: string, + entryId: string, + groupToSeedId: Map, +): Promise { + const rows = await db + .selectFrom("_emdash_content_bylines") + .select(["byline_id", "role_label"]) + .where("collection_slug", "=", collection) + .where("content_id", "=", entryId) + .orderBy("sort_order", "asc") + .execute(); + + const credits: SeedBylineCredit[] = []; + for (const row of rows) { + const seedId = groupToSeedId.get(row.byline_id); + if (!seedId) continue; + const credit: SeedBylineCredit = { byline: seedId }; + if (row.role_label) credit.roleLabel = row.role_label; + credits.push(credit); + } + + return credits; +} + /** * Get taxonomy term assignments for a content entry */ diff --git a/packages/core/tests/unit/seed/export-bylines.test.ts b/packages/core/tests/unit/seed/export-bylines.test.ts new file mode 100644 index 000000000..a8a6bb4d7 --- /dev/null +++ b/packages/core/tests/unit/seed/export-bylines.test.ts @@ -0,0 +1,164 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { exportSeed } from "../../../src/cli/commands/export-seed.js"; +import { BylineRepository } from "../../../src/database/repositories/byline.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { applySeed } from "../../../src/seed/apply.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +describe("exportSeed: bylines", () => { + let db: Kysely; + + beforeEach(async () => { + setI18nConfig(null); + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + setI18nConfig(null); + }); + + it("exports byline profiles as root bylines[]", async () => { + const bylineRepo = new BylineRepository(db); + await bylineRepo.create({ + slug: "editorial", + displayName: "Editorial", + bio: "The editorial team", + websiteUrl: "https://example.com", + }); + await bylineRepo.create({ + slug: "guest-writer", + displayName: "Guest Writer", + isGuest: true, + }); + + const seed = await exportSeed(db); + + expect(seed.bylines).toBeDefined(); + const bylines = seed.bylines ?? []; + expect(bylines).toHaveLength(2); + + const editorial = bylines.find((b) => b.slug === "editorial"); + expect(editorial).toMatchObject({ + slug: "editorial", + displayName: "Editorial", + bio: "The editorial team", + websiteUrl: "https://example.com", + }); + expect(editorial?.id).toBeTruthy(); + + const guest = bylines.find((b) => b.slug === "guest-writer"); + expect(guest).toMatchObject({ + slug: "guest-writer", + displayName: "Guest Writer", + isGuest: true, + }); + }); + + it("exports ordered byline credits on content entries referencing root byline ids", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const bylineRepo = new BylineRepository(db); + const editorial = await bylineRepo.create({ slug: "editorial", displayName: "Editorial" }); + const guest = await bylineRepo.create({ + slug: "guest-writer", + displayName: "Guest Writer", + isGuest: true, + }); + + const contentRepo = new ContentRepository(db); + const post = await contentRepo.create({ + type: "posts", + slug: "hello", + status: "published", + data: { title: "Hello World" }, + }); + + await bylineRepo.setContentBylines("posts", post.id, [ + { bylineId: editorial.id }, + { bylineId: guest.id, roleLabel: "Guest essay" }, + ]); + + const seed = await exportSeed(db, "posts"); + + // The root byline seed ids and the credit references must match so the + // exported seed round-trips through applySeed. + const bylines = seed.bylines ?? []; + const editorialSeedId = bylines.find((b) => b.slug === "editorial")?.id; + const guestSeedId = bylines.find((b) => b.slug === "guest-writer")?.id; + expect(editorialSeedId).toBeTruthy(); + expect(guestSeedId).toBeTruthy(); + + const entry = seed.content?.posts?.find((e) => e.slug === "hello"); + expect(entry).toBeDefined(); + expect(entry?.bylines).toEqual([ + { byline: editorialSeedId }, + { byline: guestSeedId, roleLabel: "Guest essay" }, + ]); + }); + + it("omits bylines key when there are no bylines", async () => { + const seed = await exportSeed(db); + expect(seed.bylines).toBeUndefined(); + }); + + it("round-trips bylines and credits through applySeed into a fresh database", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const bylineRepo = new BylineRepository(db); + const editorial = await bylineRepo.create({ + slug: "editorial", + displayName: "Editorial", + bio: "The team", + }); + const guest = await bylineRepo.create({ + slug: "guest-writer", + displayName: "Guest Writer", + isGuest: true, + }); + + const contentRepo = new ContentRepository(db); + const post = await contentRepo.create({ + type: "posts", + slug: "hello", + status: "published", + data: { title: "Hello World" }, + }); + await bylineRepo.setContentBylines("posts", post.id, [ + { bylineId: editorial.id }, + { bylineId: guest.id, roleLabel: "Guest essay" }, + ]); + + const seed = await exportSeed(db, "posts"); + + // Apply the exported seed into a separate, empty database. + const fresh = await setupTestDatabase(); + try { + const result = await applySeed(fresh, seed, { includeContent: true }); + expect(result.bylines.created).toBe(2); + expect(result.content.created).toBe(1); + + const freshBylineRepo = new BylineRepository(fresh); + const freshContentRepo = new ContentRepository(fresh); + const entry = await freshContentRepo.findBySlug("posts", "hello"); + expect(entry).not.toBeNull(); + + const credits = await freshBylineRepo.getContentBylines("posts", entry!.id); + expect(credits).toHaveLength(2); + expect(credits[0]?.byline.slug).toBe("editorial"); + expect(credits[1]?.byline.slug).toBe("guest-writer"); + expect(credits[1]?.roleLabel).toBe("Guest essay"); + } finally { + await teardownTestDatabase(fresh); + } + }); +});