diff --git a/.changeset/restore-content-bylines-table.md b/.changeset/restore-content-bylines-table.md new file mode 100644 index 0000000000..ed8d42235c --- /dev/null +++ b/.changeset/restore-content-bylines-table.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes SQLite and D1 sites where an interrupted upgrade left the byline credits table staged as `_emdash_content_bylines_new`, so pages, feeds and the admin reported no entries although the content was intact. The next migration run restores the table and the stored credits. diff --git a/packages/core/src/database/migrations/071_restore_content_bylines_table.ts b/packages/core/src/database/migrations/071_restore_content_bylines_table.ts new file mode 100644 index 0000000000..200982122f --- /dev/null +++ b/packages/core/src/database/migrations/071_restore_content_bylines_table.ts @@ -0,0 +1,43 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +import { isSqlite, tableExists } from "../dialect-helpers.js"; + +/** + * Restore `_emdash_content_bylines` where migration 040's SQLite rebuild + * stopped between dropping the old table and renaming the staged copy. + * + * 040 guards its rebuild on `PRAGMA foreign_key_list`, which returns no rows + * for a missing table, so a retry after that partial run skips the rename and + * leaves `_emdash_content_bylines_new` behind with the credits inside. The + * indexes went with the dropped table. + * + * The Postgres path of 040 alters the table in place, so this is a no-op there. + */ +export async function up(db: Kysely): Promise { + if (!isSqlite(db)) return; + + if (!(await tableExists(db, "_emdash_content_bylines"))) { + if (!(await tableExists(db, "_emdash_content_bylines_new"))) return; + await sql`ALTER TABLE _emdash_content_bylines_new RENAME TO _emdash_content_bylines`.execute( + db, + ); + } + + await db.schema + .createIndex("idx_content_bylines_content") + .ifNotExists() + .on("_emdash_content_bylines") + .columns(["collection_slug", "content_id", "sort_order"]) + .execute(); + await db.schema + .createIndex("idx_content_bylines_byline") + .ifNotExists() + .on("_emdash_content_bylines") + .column("byline_id") + .execute(); +} + +export async function down(_db: Kysely): Promise { + // no-op +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 84d1cda67e..0ab69b1820 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -73,6 +73,7 @@ import * as m067 from "./067_indexed_content_fields.js"; import * as m068 from "./068_content_taxonomy_entry_groups.js"; import * as m069 from "./069_collection_title_date_fields.js"; import * as m070 from "./070_collection_routable.js"; +import * as m071 from "./071_restore_content_bylines_table.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -144,6 +145,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "068_content_taxonomy_entry_groups": m068, "069_collection_title_date_fields": m069, "070_collection_routable": m070, + "071_restore_content_bylines_table": m071, }); /** Ordered names from the statically registered migration set. */ diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 88987f0e0d..d043f8b0d7 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -184,6 +184,7 @@ describe("Database Migrations (Integration)", () => { "068_content_taxonomy_entry_groups", "069_collection_title_date_fields", "070_collection_routable", + "071_restore_content_bylines_table", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts b/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts new file mode 100644 index 0000000000..a5649caf93 --- /dev/null +++ b/packages/core/tests/integration/database/restore-content-bylines-table-migration.test.ts @@ -0,0 +1,121 @@ +import { sql } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { indexExists, tableExists } from "../../../src/database/dialect-helpers.js"; +import * as migration071 from "../../../src/database/migrations/071_restore_content_bylines_table.js"; +import { BylineRepository } from "../../../src/database/repositories/byline.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { + describeEachDialect, + runMigrationsForDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("restore content bylines table migration", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function createCreditedPost() { + const content = new ContentRepository(ctx.db); + const bylines = new BylineRepository(ctx.db); + const post = await content.create({ + type: "post", + slug: "hello", + data: { title: "Hello" }, + }); + const author = await bylines.create({ slug: "ada", displayName: "Ada" }); + await bylines.setContentBylines("post", post.id, [{ bylineId: author.id }]); + return { post, author }; + } + + async function leaveStagedCopyBehind() { + await sql`DROP INDEX IF EXISTS idx_content_bylines_content`.execute(ctx.db); + await sql`DROP INDEX IF EXISTS idx_content_bylines_byline`.execute(ctx.db); + await sql`ALTER TABLE _emdash_content_bylines RENAME TO _emdash_content_bylines_new`.execute( + ctx.db, + ); + } + + it("keeps a healthy table and its credits untouched", async () => { + const { post, author } = await createCreditedPost(); + + await migration071.up(ctx.db); + + expect(await tableExists(ctx.db, "_emdash_content_bylines")).toBe(true); + expect(await tableExists(ctx.db, "_emdash_content_bylines_new")).toBe(false); + const credits = await new BylineRepository(ctx.db).getContentBylines("post", post.id); + expect(credits.map((c) => c.byline.id)).toEqual([author.id]); + }); + + if (dialect === "sqlite") { + it("renames the staged copy back and restores its indexes", async () => { + const { post, author } = await createCreditedPost(); + await leaveStagedCopyBehind(); + + await migration071.up(ctx.db); + + expect(await tableExists(ctx.db, "_emdash_content_bylines")).toBe(true); + expect(await tableExists(ctx.db, "_emdash_content_bylines_new")).toBe(false); + expect(await indexExists(ctx.db, "idx_content_bylines_content")).toBe(true); + expect(await indexExists(ctx.db, "idx_content_bylines_byline")).toBe(true); + const credits = await new BylineRepository(ctx.db).getContentBylines("post", post.id); + expect(credits.map((c) => c.byline.id)).toEqual([author.id]); + }); + + it("completes when re-run after the rename already landed", async () => { + const { post, author } = await createCreditedPost(); + await leaveStagedCopyBehind(); + await sql`ALTER TABLE _emdash_content_bylines_new RENAME TO _emdash_content_bylines`.execute( + ctx.db, + ); + + await migration071.up(ctx.db); + await migration071.up(ctx.db); + + expect(await indexExists(ctx.db, "idx_content_bylines_content")).toBe(true); + expect(await indexExists(ctx.db, "idx_content_bylines_byline")).toBe(true); + const credits = await new BylineRepository(ctx.db).getContentBylines("post", post.id); + expect(credits.map((c) => c.byline.id)).toEqual([author.id]); + }); + + it("recovers through the runner when the rebuild is retried after the drop", async () => { + const { post, author } = await createCreditedPost(); + await leaveStagedCopyBehind(); + await ctx.db + .deleteFrom("_emdash_migrations") + .where("name", ">=", "040_byline_i18n") + .execute(); + + const { applied } = await runMigrationsForDialect(ctx); + + expect(applied).toContain("040_byline_i18n"); + expect(applied).toContain("071_restore_content_bylines_table"); + expect(await tableExists(ctx.db, "_emdash_content_bylines")).toBe(true); + expect(await tableExists(ctx.db, "_emdash_content_bylines_new")).toBe(false); + const credits = await new BylineRepository(ctx.db).getContentBylines("post", post.id); + expect(credits.map((c) => c.byline.id)).toEqual([author.id]); + }); + + it("leaves a stale staged copy alone while the live table exists", async () => { + const { post, author } = await createCreditedPost(); + await sql`CREATE TABLE _emdash_content_bylines_new AS SELECT * FROM _emdash_content_bylines WHERE 0`.execute( + ctx.db, + ); + + await migration071.up(ctx.db); + + expect(await tableExists(ctx.db, "_emdash_content_bylines_new")).toBe(true); + const credits = await new BylineRepository(ctx.db).getContentBylines("post", post.id); + expect(credits.map((c) => c.byline.id)).toEqual([author.id]); + }); + } +});