Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/export-seed-bylines.md
Original file line number Diff line number Diff line change
@@ -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.
112 changes: 110 additions & 2 deletions packages/core/src/cli/commands/export-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -136,7 +139,14 @@ export async function exportSeed(db: Kysely<Database>, 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"
Expand All @@ -146,12 +156,65 @@ export async function exportSeed(db: Kysely<Database>, 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<Database>,
): Promise<{ bylines: SeedByline[]; groupToSeedId: Map<string, string> }> {
const bylineRepo = new BylineRepository(db);
const bylines: SeedByline[] = [];
const groupToSeedId = new Map<string, string>();
const usedSeedIds = new Set<string>();

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.
*
Expand Down Expand Up @@ -601,6 +664,7 @@ async function exportContent(
db: Kysely<Database>,
collections: SeedCollection[],
includeCollections: string[] | null,
bylineGroupToSeedId: Map<string, string>,
i18nEnabled: boolean,
): Promise<Record<string, SeedContentEntry[]>> {
const content: Record<string, SeedContentEntry[]> = {};
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<Database>,
collection: string,
entryId: string,
groupToSeedId: Map<string, string>,
): Promise<SeedBylineCredit[]> {
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
*/
Expand Down
164 changes: 164 additions & 0 deletions packages/core/tests/unit/seed/export-bylines.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>;

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);
}
});
});
Loading