Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
106 changes: 104 additions & 2 deletions packages/core/src/cli/commands/export-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Kysely } 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 @@ -31,6 +32,8 @@ import type {
SeedWidgetArea,
SeedWidget,
SeedContentEntry,
SeedByline,
SeedBylineCredit,
} from "../../seed/types.js";
import { slugify } from "../../utils/slugify.js";

Expand Down Expand Up @@ -127,7 +130,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 @@ -137,12 +147,59 @@ export async function exportSeed(db: Kysely<Database>, withContent?: string): Pr
.map((s) => s.trim())
.filter(Boolean);

seed.content = await exportContent(db, seed.collections || [], collections);
seed.content = await exportContent(db, seed.collections || [], collections, groupToSeedId);
}

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

/**
* Export site settings
*/
Expand Down Expand Up @@ -546,6 +603,7 @@ async function exportContent(
db: Kysely<Database>,
collections: SeedCollection[],
includeCollections: string[] | null,
bylineGroupToSeedId: Map<string, string>,
): Promise<Record<string, SeedContentEntry[]>> {
const content: Record<string, SeedContentEntry[]> = {};
const contentRepo = new ContentRepository(db);
Expand Down Expand Up @@ -642,6 +700,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 @@ -723,6 +791,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