fix: migration 033 idempotency & smoke test cleanup (#365)

* fix(core): add IF NOT EXISTS to migration 033 index creation

The schema registry's createContentTable() creates composite indexes
on content tables at creation time. When migration 033 runs on a
database where tables were created after the registry added these
indexes, it fails with "index already exists". Adding IF NOT EXISTS
makes the migration idempotent.

* refactor(smoke): trim site matrix to templates + playground, remove typechecks

The smoke test matrix included every demo and template, plus typecheck
cases. This was slow and redundant — typechecks are already covered by
pnpm typecheck, and demos are dev targets not release artifacts.

Trim to templates + playground only, and replace the sequential per-site
astro build with a single recursive pnpm build. Remove the TypecheckSiteCase
type and handling since smoke tests should only verify runtime behavior.

* Update env

* chore: add changeset for migration 033 fix
This commit is contained in:
Matt Kane
2026-04-07 23:00:54 +01:00
committed by GitHub
parent f112ac4819
commit d6cfc437f2
6 changed files with 25 additions and 128 deletions

View File

@@ -0,0 +1,5 @@
---
"emdash": patch
---
Fixes migration 033 failing with "index already exists" on databases where the schema registry had already created composite indexes on content tables.

View File

@@ -28,19 +28,19 @@ export async function up(db: Kysely<unknown>): Promise<void> {
// Composite index for listing queries: WHERE deleted_at IS NULL ORDER BY updated_at DESC // Composite index for listing queries: WHERE deleted_at IS NULL ORDER BY updated_at DESC
await sql` await sql`
CREATE INDEX ${sql.ref(`idx_${table.name}_deleted_updated_id`)} CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_updated_id`)}
ON ${sql.ref(table.name)} (deleted_at, updated_at DESC, id DESC) ON ${sql.ref(table.name)} (deleted_at, updated_at DESC, id DESC)
`.execute(db); `.execute(db);
// Composite index for count-by-status queries: WHERE deleted_at IS NULL AND status = ? // Composite index for count-by-status queries: WHERE deleted_at IS NULL AND status = ?
await sql` await sql`
CREATE INDEX ${sql.ref(`idx_${table.name}_deleted_status`)} CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_status`)}
ON ${sql.ref(table.name)} (deleted_at, status) ON ${sql.ref(table.name)} (deleted_at, status)
`.execute(db); `.execute(db);
// Composite index for created-at ordering: WHERE deleted_at IS NULL ORDER BY created_at DESC // Composite index for created-at ordering: WHERE deleted_at IS NULL ORDER BY created_at DESC
await sql` await sql`
CREATE INDEX ${sql.ref(`idx_${table.name}_deleted_created_id`)} CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_created_id`)}
ON ${sql.ref(table.name)} (deleted_at, created_at DESC, id DESC) ON ${sql.ref(table.name)} (deleted_at, created_at DESC, id DESC)
`.execute(db); `.execute(db);
} }
@@ -48,25 +48,25 @@ export async function up(db: Kysely<unknown>): Promise<void> {
// Add partial indexes for efficient comment status counting // Add partial indexes for efficient comment status counting
// Each index contains only rows for one status, enabling fast COUNT queries // Each index contains only rows for one status, enabling fast COUNT queries
await sql` await sql`
CREATE INDEX idx_comments_pending CREATE INDEX IF NOT EXISTS idx_comments_pending
ON _emdash_comments (id) ON _emdash_comments (id)
WHERE status = 'pending' WHERE status = 'pending'
`.execute(db); `.execute(db);
await sql` await sql`
CREATE INDEX idx_comments_approved CREATE INDEX IF NOT EXISTS idx_comments_approved
ON _emdash_comments (id) ON _emdash_comments (id)
WHERE status = 'approved' WHERE status = 'approved'
`.execute(db); `.execute(db);
await sql` await sql`
CREATE INDEX idx_comments_spam CREATE INDEX IF NOT EXISTS idx_comments_spam
ON _emdash_comments (id) ON _emdash_comments (id)
WHERE status = 'spam' WHERE status = 'spam'
`.execute(db); `.execute(db);
await sql` await sql`
CREATE INDEX idx_comments_trash CREATE INDEX IF NOT EXISTS idx_comments_trash
ON _emdash_comments (id) ON _emdash_comments (id)
WHERE status = 'trash' WHERE status = 'trash'
`.execute(db); `.execute(db);

View File

@@ -7,11 +7,10 @@ import { describe, expect, it } from "vitest";
import { ensureBuilt } from "../server.js"; import { ensureBuilt } from "../server.js";
interface RuntimeSiteCase { interface SiteCase {
name: string; name: string;
dir: string; dir: string;
port: number; port: number;
mode: "runtime";
startupTimeoutMs: number; startupTimeoutMs: number;
waitPath?: string; waitPath?: string;
setupPath?: string | null; setupPath?: string | null;
@@ -20,121 +19,61 @@ interface RuntimeSiteCase {
requireDoctype?: boolean; requireDoctype?: boolean;
} }
interface TypecheckSiteCase {
name: string;
dir: string;
mode: "typecheck";
}
type SiteCase = RuntimeSiteCase | TypecheckSiteCase;
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../.."); const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../..");
const execAsync = promisify(execFile); const execAsync = promisify(execFile);
const SITE_MATRIX: SiteCase[] = [ const SITE_MATRIX: SiteCase[] = [
// Demos
{
name: "demos/simple",
dir: resolve(WORKSPACE_ROOT, "demos/simple"),
port: 4601,
mode: "runtime",
startupTimeoutMs: 60_000,
},
{
name: "demos/cloudflare",
dir: resolve(WORKSPACE_ROOT, "demos/cloudflare"),
port: 4602,
mode: "runtime",
startupTimeoutMs: 120_000,
},
{ {
name: "demos/playground", name: "demos/playground",
dir: resolve(WORKSPACE_ROOT, "demos/playground"), dir: resolve(WORKSPACE_ROOT, "demos/playground"),
port: 4603, port: 4603,
mode: "runtime",
startupTimeoutMs: 120_000, startupTimeoutMs: 120_000,
waitPath: "/playground", waitPath: "/playground",
frontendPath: "/playground", frontendPath: "/playground",
requireDoctype: false, requireDoctype: false,
}, },
{
name: "demos/preview",
dir: resolve(WORKSPACE_ROOT, "demos/preview"),
port: 4604,
mode: "runtime",
startupTimeoutMs: 120_000,
setupPath: null,
frontendStatuses: [400],
requireDoctype: false,
},
// Postgres demo requires DATABASE_URL — skip when not available
...(process.env.DATABASE_URL
? [
{
name: "demos/postgres",
dir: resolve(WORKSPACE_ROOT, "demos/postgres"),
port: 4605,
mode: "runtime" as const,
startupTimeoutMs: 90_000,
},
]
: []),
{
name: "demos/plugins-demo",
dir: resolve(WORKSPACE_ROOT, "demos/plugins-demo"),
port: 4606,
mode: "runtime",
startupTimeoutMs: 90_000,
},
// Templates // Templates
{ {
name: "templates/blank", name: "templates/blank",
dir: resolve(WORKSPACE_ROOT, "templates/blank"), dir: resolve(WORKSPACE_ROOT, "templates/blank"),
port: 4611, port: 4611,
mode: "runtime",
startupTimeoutMs: 60_000, startupTimeoutMs: 60_000,
}, },
{ {
name: "templates/blog", name: "templates/blog",
dir: resolve(WORKSPACE_ROOT, "templates/blog"), dir: resolve(WORKSPACE_ROOT, "templates/blog"),
port: 4612, port: 4612,
mode: "runtime",
startupTimeoutMs: 60_000, startupTimeoutMs: 60_000,
}, },
{ {
name: "templates/blog-cloudflare", name: "templates/blog-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/blog-cloudflare"), dir: resolve(WORKSPACE_ROOT, "templates/blog-cloudflare"),
port: 4613, port: 4613,
mode: "runtime",
startupTimeoutMs: 120_000, startupTimeoutMs: 120_000,
}, },
{ {
name: "templates/marketing", name: "templates/marketing",
dir: resolve(WORKSPACE_ROOT, "templates/marketing"), dir: resolve(WORKSPACE_ROOT, "templates/marketing"),
port: 4614, port: 4614,
mode: "runtime",
startupTimeoutMs: 90_000, startupTimeoutMs: 90_000,
}, },
{ {
name: "templates/marketing-cloudflare", name: "templates/marketing-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/marketing-cloudflare"), dir: resolve(WORKSPACE_ROOT, "templates/marketing-cloudflare"),
port: 4615, port: 4615,
mode: "runtime",
startupTimeoutMs: 120_000, startupTimeoutMs: 120_000,
}, },
{ {
name: "templates/portfolio", name: "templates/portfolio",
dir: resolve(WORKSPACE_ROOT, "templates/portfolio"), dir: resolve(WORKSPACE_ROOT, "templates/portfolio"),
port: 4616, port: 4616,
mode: "runtime",
startupTimeoutMs: 90_000, startupTimeoutMs: 90_000,
}, },
{ {
name: "templates/portfolio-cloudflare", name: "templates/portfolio-cloudflare",
dir: resolve(WORKSPACE_ROOT, "templates/portfolio-cloudflare"), dir: resolve(WORKSPACE_ROOT, "templates/portfolio-cloudflare"),
port: 4617, port: 4617,
mode: "runtime",
startupTimeoutMs: 120_000, startupTimeoutMs: 120_000,
}, },
]; ];
@@ -181,18 +120,26 @@ async function fetchWithRetry(url: string, retries = 10, delayMs = 1500): Promis
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Build verification — runs a single recursive `pnpm build` across all demos // Build verification — runs a single recursive `pnpm build` across templates
// and templates in parallel, then verifies each site produced output. // and the playground demo in parallel.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("Site build verification", () => { describe("Site build verification", () => {
it("all demos and templates build successfully", { timeout: 300_000 }, async () => { it("all templates and playground build successfully", { timeout: 300_000 }, async () => {
await ensureBuilt(); await ensureBuilt();
try { try {
await execAsync( await execAsync(
"pnpm", "pnpm",
["run", "--recursive", "--filter", "{./demos/*}", "--filter", "{./templates/*}", "build"], [
"run",
"--recursive",
"--filter",
"{./templates/*}",
"--filter",
"@emdash-cms/playground",
"build",
],
{ {
cwd: WORKSPACE_ROOT, cwd: WORKSPACE_ROOT,
timeout: 240_000, timeout: 240_000,
@@ -221,16 +168,6 @@ describe("Site build verification", () => {
describe.sequential("Site runtime verification", () => { describe.sequential("Site runtime verification", () => {
for (const site of SITE_MATRIX) { for (const site of SITE_MATRIX) {
if (site.mode === "typecheck") {
it(`${site.name} typechecks`, { timeout: 120_000 }, async () => {
await execAsync("pnpm", ["run", "typecheck"], {
cwd: site.dir,
timeout: 120_000,
});
});
continue;
}
const waitPath = site.waitPath ?? "/_emdash/admin/"; const waitPath = site.waitPath ?? "/_emdash/admin/";
const setupPath = site.setupPath ?? "/_emdash/api/setup/dev-bypass?redirect=/"; const setupPath = site.setupPath ?? "/_emdash/api/setup/dev-bypass?redirect=/";
const frontendPath = site.frontendPath ?? "/"; const frontendPath = site.frontendPath ?? "/";

View File

@@ -17,23 +17,8 @@ export interface Page {
bylines?: ContentBylineCredit[]; bylines?: ContentBylineCredit[];
} }
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
}
declare module "emdash" { declare module "emdash" {
interface EmDashCollections { interface EmDashCollections {
pages: Page; pages: Page;
posts: Post;
} }
} }

View File

@@ -17,20 +17,6 @@ export interface Page {
bylines?: ContentBylineCredit[]; bylines?: ContentBylineCredit[];
} }
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
}
export interface Project { export interface Project {
id: string; id: string;
slug: string | null; slug: string | null;
@@ -52,7 +38,6 @@ export interface Project {
declare module "emdash" { declare module "emdash" {
interface EmDashCollections { interface EmDashCollections {
pages: Page; pages: Page;
posts: Post;
projects: Project; projects: Project;
} }
} }

View File

@@ -17,20 +17,6 @@ export interface Page {
bylines?: ContentBylineCredit[]; bylines?: ContentBylineCredit[];
} }
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
}
export interface Project { export interface Project {
id: string; id: string;
slug: string | null; slug: string | null;
@@ -52,7 +38,6 @@ export interface Project {
declare module "emdash" { declare module "emdash" {
interface EmDashCollections { interface EmDashCollections {
pages: Page; pages: Page;
posts: Post;
projects: Project; projects: Project;
} }
} }