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:
5
.changeset/cute-news-follow.md
Normal file
5
.changeset/cute-news-follow.md
Normal 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.
|
||||
@@ -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
|
||||
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)
|
||||
`.execute(db);
|
||||
|
||||
// Composite index for count-by-status queries: WHERE deleted_at IS NULL AND status = ?
|
||||
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)
|
||||
`.execute(db);
|
||||
|
||||
// Composite index for created-at ordering: WHERE deleted_at IS NULL ORDER BY created_at DESC
|
||||
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)
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -48,25 +48,25 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
||||
// Add partial indexes for efficient comment status counting
|
||||
// Each index contains only rows for one status, enabling fast COUNT queries
|
||||
await sql`
|
||||
CREATE INDEX idx_comments_pending
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_pending
|
||||
ON _emdash_comments (id)
|
||||
WHERE status = 'pending'
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_comments_approved
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_approved
|
||||
ON _emdash_comments (id)
|
||||
WHERE status = 'approved'
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_comments_spam
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_spam
|
||||
ON _emdash_comments (id)
|
||||
WHERE status = 'spam'
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_comments_trash
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_trash
|
||||
ON _emdash_comments (id)
|
||||
WHERE status = 'trash'
|
||||
`.execute(db);
|
||||
|
||||
@@ -7,11 +7,10 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ensureBuilt } from "../server.js";
|
||||
|
||||
interface RuntimeSiteCase {
|
||||
interface SiteCase {
|
||||
name: string;
|
||||
dir: string;
|
||||
port: number;
|
||||
mode: "runtime";
|
||||
startupTimeoutMs: number;
|
||||
waitPath?: string;
|
||||
setupPath?: string | null;
|
||||
@@ -20,121 +19,61 @@ interface RuntimeSiteCase {
|
||||
requireDoctype?: boolean;
|
||||
}
|
||||
|
||||
interface TypecheckSiteCase {
|
||||
name: string;
|
||||
dir: string;
|
||||
mode: "typecheck";
|
||||
}
|
||||
|
||||
type SiteCase = RuntimeSiteCase | TypecheckSiteCase;
|
||||
|
||||
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../..");
|
||||
const execAsync = promisify(execFile);
|
||||
|
||||
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",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/playground"),
|
||||
port: 4603,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
waitPath: "/playground",
|
||||
frontendPath: "/playground",
|
||||
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
|
||||
{
|
||||
name: "templates/blank",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/blank"),
|
||||
port: 4611,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
name: "templates/blog",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/blog"),
|
||||
port: 4612,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
name: "templates/blog-cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/blog-cloudflare"),
|
||||
port: 4613,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: "templates/marketing",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/marketing"),
|
||||
port: 4614,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 90_000,
|
||||
},
|
||||
{
|
||||
name: "templates/marketing-cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/marketing-cloudflare"),
|
||||
port: 4615,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: "templates/portfolio",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/portfolio"),
|
||||
port: 4616,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 90_000,
|
||||
},
|
||||
{
|
||||
name: "templates/portfolio-cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/portfolio-cloudflare"),
|
||||
port: 4617,
|
||||
mode: "runtime",
|
||||
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
|
||||
// and templates in parallel, then verifies each site produced output.
|
||||
// Build verification — runs a single recursive `pnpm build` across templates
|
||||
// and the playground demo in parallel.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
await execAsync(
|
||||
"pnpm",
|
||||
["run", "--recursive", "--filter", "{./demos/*}", "--filter", "{./templates/*}", "build"],
|
||||
[
|
||||
"run",
|
||||
"--recursive",
|
||||
"--filter",
|
||||
"{./templates/*}",
|
||||
"--filter",
|
||||
"@emdash-cms/playground",
|
||||
"build",
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE_ROOT,
|
||||
timeout: 240_000,
|
||||
@@ -221,16 +168,6 @@ describe("Site build verification", () => {
|
||||
|
||||
describe.sequential("Site runtime verification", () => {
|
||||
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 setupPath = site.setupPath ?? "/_emdash/api/setup/dev-bypass?redirect=/";
|
||||
const frontendPath = site.frontendPath ?? "/";
|
||||
|
||||
15
templates/marketing/emdash-env.d.ts
vendored
15
templates/marketing/emdash-env.d.ts
vendored
@@ -17,23 +17,8 @@ export interface Page {
|
||||
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" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
15
templates/portfolio-cloudflare/emdash-env.d.ts
vendored
15
templates/portfolio-cloudflare/emdash-env.d.ts
vendored
@@ -17,20 +17,6 @@ export interface Page {
|
||||
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 {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
@@ -52,7 +38,6 @@ export interface Project {
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
projects: Project;
|
||||
}
|
||||
}
|
||||
15
templates/portfolio/emdash-env.d.ts
vendored
15
templates/portfolio/emdash-env.d.ts
vendored
@@ -17,20 +17,6 @@ export interface Page {
|
||||
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 {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
@@ -52,7 +38,6 @@ export interface Project {
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
projects: Project;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user