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/deployment-migration-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds a validated, secret-free deployment migration manifest during Astro build and sync so deployment tooling can run the exact migrations bundled with the site.
5 changes: 5 additions & 0 deletions .changeset/deployment-migration-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds public migration identity and exact-status APIs so deployment tooling can verify and run the same core migration set as the installed EmDash version.
5 changes: 5 additions & 0 deletions .changeset/direct-migration-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds deployment migration executors for the built-in SQLite, libSQL, and PostgreSQL database adapters with secret-free build metadata.
5 changes: 5 additions & 0 deletions .changeset/runtime-migration-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds `auto`, `check`, and `manual` runtime migration modes so deployments can verify or manage core database migrations before application traffic while existing sites remain on automatic migrations by default.
16 changes: 16 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,30 @@
"types": "./dist/db/sqlite.d.mts",
"default": "./dist/db/sqlite.mjs"
},
"./db/sqlite-migrations": {
"types": "./dist/db/sqlite-migrations.d.mts",
"default": "./dist/db/sqlite-migrations.mjs"
},
"./db/libsql": {
"types": "./dist/db/libsql.d.mts",
"default": "./dist/db/libsql.mjs"
},
"./db/libsql-migrations": {
"types": "./dist/db/libsql-migrations.d.mts",
"default": "./dist/db/libsql-migrations.mjs"
},
"./db/postgres": {
"types": "./dist/db/postgres.d.mts",
"default": "./dist/db/postgres.mjs"
},
"./db/postgres-migrations": {
"types": "./dist/db/postgres-migrations.d.mts",
"default": "./dist/db/postgres-migrations.mjs"
},
"./migrations": {
"types": "./dist/migrations/index.d.mts",
"default": "./dist/migrations/index.mjs"
},
"./database/instrumentation": {
"types": "./dist/database/instrumentation.d.mts",
"default": "./dist/database/instrumentation.mjs"
Expand Down
60 changes: 46 additions & 14 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@
*/

import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";

import type { AstroIntegration, AstroIntegrationLogger, AstroIntegrationMiddleware } from "astro";

import { validateAllowedOrigins, validateOriginShape } from "../../auth/allowed-origins.js";
import { normalizeMigrationConfig } from "../../database/migrations/policy.js";
import { normalizeAstroI18n } from "../../i18n/normalize.js";
import { INTERNAL_MEDIA_PREFIX } from "../../media/normalize.js";
import { getCoreMigrationIdentity } from "../../migrations/identity.js";
import {
createMigrationIntegrationMetadata,
MIGRATION_CONFIG_SYMBOL,
} from "../../migrations/integration-metadata.js";
import { buildMigrationManifest } from "../../migrations/manifest-builder.js";
import { writeMigrationManifest } from "../../migrations/manifest-writer.js";
import type { ResolvedPlugin } from "../../plugins/types.js";
import { VERSION } from "../../version.js";
import { local } from "../storage/adapters.js";
Expand Down Expand Up @@ -313,6 +323,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
const resolvedConfig: EmDashConfig = {
...config,
storage: config.storage ?? DEFAULT_STORAGE,
migrations: normalizeMigrationConfig(config.migrations),
};

// Validate marketplace URL
Expand Down Expand Up @@ -421,6 +432,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
// i18n is populated in astro:config:setup from astroConfig.i18n
const serializableConfig: Record<string, unknown> = {
database: resolvedConfig.database,
migrations: resolvedConfig.migrations,
storage: resolvedConfig.storage,
auth: resolvedConfig.auth,
authProviders: resolvedConfig.authProviders,
Expand All @@ -440,8 +452,10 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
// Captured in astro:config:setup so the astro:server:setup hook can tell
// whether we're running `astro dev` (where the dev-bypass shortcut applies).
let astroCommand: "dev" | "build" | "preview" | "sync" | undefined;
let normalizedI18n: ReturnType<typeof normalizeAstroI18n> = null;
const migrationMetadata = createMigrationIntegrationMetadata(resolvedConfig.database);

return {
const integration: AstroIntegration = {
name: "emdash",
hooks: {
"astro:config:setup": ({
Expand All @@ -467,18 +481,8 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
// Expose Astro's trailingSlash routing policy so plugins can build
// URLs (sitemap/canonical/hreflang) that match what the site serves.
serializableConfig.trailingSlash = astroConfig.trailingSlash;
// Extract i18n config from Astro config
// Astro locales can be strings OR { path, codes } objects — normalize to paths
if (astroConfig.i18n) {
const routing = astroConfig.i18n.routing;
serializableConfig.i18n = {
defaultLocale: astroConfig.i18n.defaultLocale,
locales: astroConfig.i18n.locales.map((l) => (typeof l === "string" ? l : l.path)),
fallback: astroConfig.i18n.fallback,
prefixDefaultLocale:
typeof routing === "object" ? (routing.prefixDefaultLocale ?? false) : false,
};
}
normalizedI18n = normalizeAstroI18n(astroConfig.i18n);
if (normalizedI18n) serializableConfig.i18n = normalizedI18n;

// Disable Astro's built-in checkOrigin -- EmDash's own CSRF
// layer (checkPublicCsrf in api/csrf.ts) handles origin
Expand Down Expand Up @@ -603,9 +607,31 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
// dev server is listening (see astro:server:setup), since the
// port isn't known yet here. Nothing useful to print for build.
},
"astro:config:done": ({ config: finalConfig, logger }) => {
"astro:config:done": async ({ config: finalConfig, logger }) => {
const warning = missingReactIntegrationWarning(finalConfig.integrations);
if (warning) logger.warn(warning);

if (astroCommand !== "build" && astroCommand !== "sync") return;
if (!migrationMetadata.database) {
logger.warn(
"EmDash migration manifest was not written because no database adapter is configured.",
);
return;
}
if (!migrationMetadata.database.migrations) {
logger.warn(
"EmDash migration manifest was not written because the configured database adapter does not support deployment migrations.",
);
return;
}

const identity = await getCoreMigrationIdentity();
const manifest = await buildMigrationManifest({
identity,
i18n: normalizedI18n,
database: migrationMetadata.database,
});
await writeMigrationManifest(fileURLToPath(finalConfig.root), manifest);
},
"astro:server:setup": ({ server, logger }) => {
// Print route info with absolute, clickable URLs once the server
Expand Down Expand Up @@ -683,6 +709,12 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
},
},
};

Object.defineProperty(integration, MIGRATION_CONFIG_SYMBOL, {
value: migrationMetadata,
enumerable: false,
});
return integration;
}

export default emdash;
3 changes: 3 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type { ManifestHookEntry, ManifestRouteEntry } from "@emdash-cms/plugin-types";

import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js";
import type { RuntimeMigrationConfig } from "../../database/migrations/policy.js";
import type { DatabaseDescriptor } from "../../db/adapters.js";
import type { MediaProviderDescriptor } from "../../media/types.js";
import type { ObjectCacheDescriptor } from "../../object-cache/types.js";
Expand Down Expand Up @@ -180,6 +181,8 @@ export interface EmDashConfig {
* ```
*/
database?: DatabaseDescriptor;
/** Core database migration behavior at runtime. Defaults to `auto`. */
migrations?: RuntimeMigrationConfig;
/**
* Storage configuration (for media)
*/
Expand Down
73 changes: 63 additions & 10 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ import {
flushRecorder,
isInstrumentationEnabled,
} from "../database/instrumentation.js";
import {
PendingMigrationsError,
resolveRuntimeMigrationMode,
type RuntimeMigrationMode,
} from "../database/migrations/policy.js";
import { createDeferredTaskTracker } from "../deferred-tasks.js";
import {
DB_INIT_DEADLINE_MS,
Expand Down Expand Up @@ -192,7 +197,10 @@ function getPlugins(): ResolvedPlugin[] {
/**
* Build runtime dependencies from virtual modules
*/
function buildDependencies(config: EmDashConfig): RuntimeDependencies {
function buildDependencies(
config: EmDashConfig,
migrationMode: RuntimeMigrationMode,
): RuntimeDependencies {
/* eslint-disable typescript-eslint/no-unsafe-type-assertion --
The virtual:emdash/* imports above use @ts-ignore because tsgo/IDE
resolution can't see virtual-modules.d.ts in every consumer setup,
Expand All @@ -202,6 +210,7 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies {
const sandboxModule = virtualSandboxRunnerModule as Record<string, unknown>;
return {
config,
migrationMode,
plugins: getPlugins(),
createDialect: virtualCreateDialect as (config: Record<string, unknown>) => unknown,
// Optional: only batching backends (D1, DO) export this; undefined otherwise.
Expand Down Expand Up @@ -229,6 +238,7 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies {
*/
async function getRuntime(
config: EmDashConfig,
migrationMode: RuntimeMigrationMode,
initTimings?: Array<{ name: string; dur: number; desc?: string }>,
): Promise<EmDashRuntime> {
// Waiters poll rather than awaiting the initializing request's promise —
Expand All @@ -243,7 +253,7 @@ async function getRuntime(
holder.lock,
() => holder.instance,
async (isCurrentClaim) => {
const deps = buildDependencies(config);
const deps = buildDependencies(config, migrationMode);
const runtime = await EmDashRuntime.create(deps, initTimings);
if (isCurrentClaim()) {
holder.instance = runtime;
Expand Down Expand Up @@ -360,8 +370,9 @@ async function runOutsideRequest<T>(
config: EmDashConfig,
fn: (runtime: EmDashRuntime) => Promise<T>,
): Promise<T> {
const migrationMode = resolveConfiguredMigrationMode(config);
if (getRequestContext()) {
const runtime = await getRuntime(config);
const runtime = await getRuntime(config, migrationMode);
return runOutsideRequestWithRuntime(config, runtime, fn);
}

Expand All @@ -374,7 +385,7 @@ async function runOutsideRequest<T>(
return runWithContext(context, async () => {
const runtime = await (async () => {
try {
return await getRuntime(config);
return await getRuntime(config, migrationMode);
} finally {
deferredTasks.settle();
await deferredTasks.settled;
Expand Down Expand Up @@ -452,6 +463,31 @@ const NOOP_COOKIE_JAR = {
*/
const CRON_EVENT_URL = new URL("https://cron.emdash.internal/");

function resolveConfiguredMigrationMode(config: EmDashConfig): RuntimeMigrationMode {
const processOverride =
typeof process !== "undefined" && process.env ? process.env.EMDASH_MIGRATIONS_MODE : undefined;
const importMetaOverride = import.meta.env.EMDASH_MIGRATIONS_MODE;
return resolveRuntimeMigrationMode(config.migrations, {
dev: import.meta.env.DEV,
override: processOverride ?? importMetaOverride,
});
}

function pendingMigrationsResponse(error: PendingMigrationsError): Response {
console.error("[emdash] database migrations are pending:", error.pending.join(", "));
return migrationRequiredResponse();
}

function migrationRequiredResponse(): Response {
return new Response(
"Database migrations are required. Apply the deployment migration manifest and retry.",
{
status: 503,
headers: { "Retry-After": "60" },
},
);
}

/**
* Baseline security headers applied to all responses.
* Admin routes get additional headers (strict CSP) from auth middleware.
Expand Down Expand Up @@ -601,6 +637,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
const metrics = createRequestMetrics(performance.now());

const run = async (): Promise<Response> => {
const config = getConfig();
const migrationMode = config ? resolveConfiguredMigrationMode(config) : "auto";
// Process /_emdash routes and public routes with an active session
// (logged-in editors need the runtime for toolbar/visual editing on public pages)
const isEmDashRoute = url.pathname.startsWith("/_emdash");
Expand Down Expand Up @@ -668,7 +706,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
// production. The build database is legitimately empty in CI and there
// is no live visitor to send to the wizard at build time (session reads
// are already skipped for prerender above for the same reason).
if (!isSetupVerified() && !context.isPrerendered) {
if (migrationMode === "auto" && !isSetupVerified() && !context.isPrerendered) {
const t0 = performance.now();
try {
const { getDb } = await import("../loader.js");
Expand All @@ -695,14 +733,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
// The runtime is a cached singleton — after the first request,
// getRuntime() is just a null-check. This enables SEO plugins to
// contribute meta tags for all visitors, not just logged-in editors.
const config = getConfig();
if (config) {
// Sub-phase timings are populated only on the cold init. Warm
// requests hit the cached runtime and leave this empty.
const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
const t0 = performance.now();
try {
const runtime = await getRuntime(config, initSubTimings);
const runtime = await getRuntime(config, migrationMode, initSubTimings);
markSetupVerified();
const handlePublicPluginApiRoute = createPublicPluginApiRouteHandler(runtime);
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- partial object; getPageRuntime() only checks for the page-contribution methods
Expand All @@ -717,6 +754,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
storage: runtime.storage,
} as EmDashHandlers;
} catch (error) {
if (error instanceof PendingMigrationsError) {
return pendingMigrationsResponse(error);
}
if (migrationMode === "manual" && isMissingTableError(error)) {
console.error(
"[emdash] database schema is unavailable in manual migration mode:",
error,
);
return migrationRequiredResponse();
}
// Non-fatal — EmDashHead falls back to base SEO contributions —
// but log it (throttled): a persistently failing init (e.g. a
// failing migration, #1744) is otherwise invisible on the
Expand Down Expand Up @@ -796,7 +843,6 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
}

const config = getConfig();
if (!config) {
console.error("EmDash: No configuration found");
return finalizeResponse(await next());
Expand All @@ -816,14 +862,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
// instance and `initSubTimings` stays empty.
const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
let t0 = performance.now();
const runtime = await getRuntime(config, initSubTimings);
const runtime = await getRuntime(config, migrationMode, initSubTimings);
timings.push({ name: "rt", dur: performance.now() - t0, desc: "Runtime init" });
// Forward any sub-phase samples so cold-start breakdown is visible
// in Server-Timing. Each phase appears prefixed "rt." to distinguish
// from the aggregate "rt" timing above.
for (const sub of initSubTimings) timings.push(sub);

// Runtime init runs migrations, so the DB is guaranteed set up
// Runtime initialization has satisfied the effective migration policy.
markSetupVerified();

// The manifest is no longer pre-loaded here. It's admin-only
Expand Down Expand Up @@ -942,6 +988,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
setPluginStatus: runtime.setPluginStatus.bind(runtime),
};
} catch (error) {
if (error instanceof PendingMigrationsError) {
return pendingMigrationsResponse(error);
}
if (migrationMode === "manual" && isMissingTableError(error)) {
console.error("[emdash] database schema is unavailable in manual migration mode:", error);
return migrationRequiredResponse();
}
console.error("EmDash middleware error:", error);
}

Expand Down
4 changes: 0 additions & 4 deletions packages/core/src/astro/routes/api/auth/dev-bypass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import { ulid } from "ulidx";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { escapeHtml } from "#api/escape.js";
import { isSafeRedirect } from "#api/redirect.js";
import { runMigrations } from "#db/migrations/runner.js";

const DEV_USER_EMAIL = "dev@emdash.local";
const DEV_USER_NAME = "Dev Admin";
Expand All @@ -46,9 +45,6 @@ async function handleDevBypass(context: Parameters<APIRoute>[0]): Promise<Respon
}

try {
// Ensure migrations are run
await runMigrations(emdash.db);

// Find or create dev user (direct DB access to avoid @emdash-cms/auth import issues in dev)
const existingUser = await emdash.db
.selectFrom("users")
Expand Down
Loading
Loading