diff --git a/.changeset/cold-start-caching.md b/.changeset/cold-start-caching.md new file mode 100644 index 000000000..0cbc3b46e --- /dev/null +++ b/.changeset/cold-start-caching.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": minor +--- + +Adds cold-start caching to reduce database queries from ~20 to ~2 on established sites. Manifest and init data (plugin states, site info) are persisted to the options table and reused across cold starts. A new `wrapWithLazyMigrations()` dialect wrapper defers migration checks until the first schema error, applied to all built-in adapters. FTS verification is skipped on init. diff --git a/packages/cloudflare/src/db/d1.ts b/packages/cloudflare/src/db/d1.ts index 4ef0e8962..9437d7a4e 100644 --- a/packages/cloudflare/src/db/d1.ts +++ b/packages/cloudflare/src/db/d1.ts @@ -9,6 +9,7 @@ */ import { env } from "cloudflare:workers"; +import { wrapWithLazyMigrations } from "emdash/db/lazy-migrations"; import type { DatabaseIntrospector, Dialect, Kysely } from "kysely"; import { D1Dialect } from "kysely-d1"; @@ -58,7 +59,11 @@ export function createDialect(config: D1Config): Dialect { // Use our custom dialect with D1-compatible introspector // db is unknown from env access; D1Dialect expects D1Database // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1Database binding from untyped env object - return new EmDashD1Dialect({ database: db as D1Database }); + const dialect = new EmDashD1Dialect({ database: db as D1Database }); + + // Wrap with lazy migration retry — migrations only run on first schema + // error instead of checking 30+ migrations on every cold start + return wrapWithLazyMigrations(dialect); } // ========================================================================= diff --git a/packages/core/package.json b/packages/core/package.json index f319b6fce..6e3711601 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,6 +61,10 @@ "types": "./dist/db/postgres.d.mts", "default": "./dist/db/postgres.mjs" }, + "./db/lazy-migrations": { + "types": "./dist/db/lazy-migrations.d.mts", + "default": "./dist/db/lazy-migrations.mjs" + }, "./storage/local": { "types": "./dist/storage/local.d.mts", "default": "./dist/storage/local.mjs" diff --git a/packages/core/src/db/lazy-migrations.ts b/packages/core/src/db/lazy-migrations.ts new file mode 100644 index 000000000..8897654f4 --- /dev/null +++ b/packages/core/src/db/lazy-migrations.ts @@ -0,0 +1,170 @@ +/** + * Lazy migration wrapper for Kysely dialects + * + * Wraps a dialect so that migrations are deferred until the first schema + * error ("no such table", "no such column"). On established sites the + * schema is stable, so checking 30+ migrations on every cold start is + * pure overhead. Fresh installs and upgrades are handled automatically + * when the first query fails. + * + * Usage (in a database adapter's runtime entry): + * + * ```ts + * import { wrapWithLazyMigrations } from "emdash/db/lazy-migrations"; + * + * export function createDialect(config) { + * const base = new D1Dialect({ database: d1 }); + * return wrapWithLazyMigrations(base); + * } + * ``` + */ + +import type { + CompiledQuery, + DatabaseConnection, + DatabaseIntrospector, + Dialect, + Driver, + Kysely, + QueryCompiler, + QueryResult, +} from "kysely"; + +import type { Database } from "../database/types.js"; + +let migrationsRun = false; + +function isSchemaError(e: unknown): boolean { + if (!(e instanceof Error)) return false; + const msg = e.message.toLowerCase(); + return ( + // SQLite / D1 + msg.includes("no such table") || + msg.includes("no such column") || + // PostgreSQL + (msg.includes("relation") && msg.includes("does not exist")) || + (msg.includes("column") && msg.includes("does not exist")) + ); +} + +/** + * Wrap a Kysely dialect with lazy migration retry. + * + * When a query fails with a schema error and migrations haven't been run + * yet in this worker lifetime, runs all pending migrations then retries + * the query once. Subsequent schema errors are thrown normally. + */ +export function wrapWithLazyMigrations(dialect: Dialect): Dialect { + return new LazyMigrationDialect(dialect); +} + +/** + * Check if a dialect is already wrapped with lazy migration retry. + * Used by the runtime to decide whether to run migrations eagerly. + */ +export function isLazyMigrationDialect(dialect: Dialect): boolean { + return dialect instanceof LazyMigrationDialect; +} + +class LazyMigrationDialect implements Dialect { + readonly #inner: Dialect; + + constructor(inner: Dialect) { + this.#inner = inner; + } + + createAdapter() { + return this.#inner.createAdapter(); + } + + createDriver(): Driver { + return new LazyMigrationDriver(this.#inner.createDriver(), this.#inner); + } + + createQueryCompiler(): QueryCompiler { + return this.#inner.createQueryCompiler(); + } + + createIntrospector(db: Kysely): DatabaseIntrospector { + return this.#inner.createIntrospector(db); + } +} + +class LazyMigrationDriver implements Driver { + readonly #inner: Driver; + readonly #dialect: Dialect; + + constructor(inner: Driver, dialect: Dialect) { + this.#inner = inner; + this.#dialect = dialect; + } + + async init(): Promise { + return this.#inner.init(); + } + + async acquireConnection(): Promise { + const conn = await this.#inner.acquireConnection(); + return new LazyMigrationConnection(conn, this.#dialect); + } + + async beginTransaction(conn: DatabaseConnection): Promise { + return this.#inner.beginTransaction(conn); + } + + async commitTransaction(conn: DatabaseConnection): Promise { + return this.#inner.commitTransaction(conn); + } + + async rollbackTransaction(conn: DatabaseConnection): Promise { + return this.#inner.rollbackTransaction(conn); + } + + async releaseConnection(conn: DatabaseConnection): Promise { + return this.#inner.releaseConnection(conn); + } + + async destroy(): Promise { + return this.#inner.destroy(); + } +} + +class LazyMigrationConnection implements DatabaseConnection { + readonly #inner: DatabaseConnection; + readonly #dialect: Dialect; + + constructor(inner: DatabaseConnection, dialect: Dialect) { + this.#inner = inner; + this.#dialect = dialect; + } + + async executeQuery(compiledQuery: CompiledQuery): Promise> { + try { + return await this.#inner.executeQuery(compiledQuery); + } catch (e) { + if (!migrationsRun && isSchemaError(e)) { + migrationsRun = true; + // Create a fresh Kysely instance for migration using the inner + // dialect (unwrapped) to avoid infinite retry loops. + const { Kysely } = await import("kysely"); + const db = new Kysely({ dialect: this.#dialect }); + try { + const { runMigrations } = await import("../database/migrations/runner.js"); + await runMigrations(db); + } finally { + await db.destroy(); + } + // Retry the original query + return this.#inner.executeQuery(compiledQuery); + } + throw e; + } + } + + async *streamQuery( + compiledQuery: CompiledQuery, + chunkSize?: number, + ): AsyncIterableIterator> { + yield* this.#inner.streamQuery(compiledQuery, chunkSize); + } +} diff --git a/packages/core/src/db/libsql.ts b/packages/core/src/db/libsql.ts index 5366f923e..e2ed7bc0f 100644 --- a/packages/core/src/db/libsql.ts +++ b/packages/core/src/db/libsql.ts @@ -8,6 +8,7 @@ import type { Dialect } from "kysely"; import type { LibsqlConfig } from "./adapters.js"; +import { wrapWithLazyMigrations } from "./lazy-migrations.js"; /** * Create a libSQL dialect from config @@ -16,8 +17,10 @@ export function createDialect(config: LibsqlConfig): Dialect { // Dynamic import to avoid loading @libsql/kysely-libsql at config time const { LibsqlDialect } = require("@libsql/kysely-libsql"); - return new LibsqlDialect({ - url: config.url, - authToken: config.authToken, - }); + return wrapWithLazyMigrations( + new LibsqlDialect({ + url: config.url, + authToken: config.authToken, + }), + ); } diff --git a/packages/core/src/db/postgres.ts b/packages/core/src/db/postgres.ts index 421b4b07b..9dd598ae9 100644 --- a/packages/core/src/db/postgres.ts +++ b/packages/core/src/db/postgres.ts @@ -5,15 +5,17 @@ * Loaded at runtime via virtual module. */ +import type { Dialect } from "kysely"; import { PostgresDialect } from "kysely"; import { Pool } from "pg"; import type { PostgresConfig } from "./adapters.js"; +import { wrapWithLazyMigrations } from "./lazy-migrations.js"; /** * Create a PostgreSQL dialect from config */ -export function createDialect(config: PostgresConfig): PostgresDialect { +export function createDialect(config: PostgresConfig): Dialect { const pool = new Pool({ connectionString: config.connectionString, host: config.host, @@ -26,5 +28,5 @@ export function createDialect(config: PostgresConfig): PostgresDialect { max: config.pool?.max ?? 10, }); - return new PostgresDialect({ pool }); + return wrapWithLazyMigrations(new PostgresDialect({ pool })); } diff --git a/packages/core/src/db/sqlite.ts b/packages/core/src/db/sqlite.ts index ea01d89d3..1a3fbd8f8 100644 --- a/packages/core/src/db/sqlite.ts +++ b/packages/core/src/db/sqlite.ts @@ -8,6 +8,7 @@ import type { Dialect } from "kysely"; import type { SqliteConfig } from "./adapters.js"; +import { wrapWithLazyMigrations } from "./lazy-migrations.js"; /** * Create a SQLite dialect from config @@ -23,5 +24,5 @@ export function createDialect(config: SqliteConfig): Dialect { const database = new BetterSqlite3(filePath); - return new SqliteDialect({ database }); + return wrapWithLazyMigrations(new SqliteDialect({ database })); } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c20d6d312..f04ea3b37 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -19,10 +19,10 @@ import type { } from "./astro/integration/runtime.js"; import type { EmDashManifest, ManifestCollection } from "./astro/types.js"; import { getAuthMode } from "./auth/mode.js"; -import { isSqlite } from "./database/dialect-helpers.js"; import { runMigrations } from "./database/migrations/runner.js"; import { RevisionRepository } from "./database/repositories/revision.js"; import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js"; +import { isLazyMigrationDialect } from "./db/lazy-migrations.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js"; @@ -141,7 +141,6 @@ import { PiggybackScheduler } from "./plugins/scheduler/piggyback.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.js"; import { getRequestContext } from "./request-context.js"; -import { FTSManager } from "./search/fts-manager.js"; /** * Map schema field types to editor field kinds @@ -282,6 +281,9 @@ export class EmDashRuntime { private enabledPlugins: Set; private pluginStates: Map; + /** In-memory manifest cache — cleared by invalidateManifest() */ + private _cachedManifest: EmDashManifest | null = null; + /** Current hook pipeline. Use the `hooks` getter for external access. */ get hooks(): HookPipeline { return this._hooks; @@ -562,31 +564,76 @@ export class EmDashRuntime { // Initialize database const db = await EmDashRuntime.getDatabase(deps); - // Verify and repair FTS indexes (auto-heal crash corruption) - // FTS5 is SQLite-only; on other dialects, search is a no-op until - // the pluggable SearchProvider work lands. - if (isSqlite(db)) { - try { - const ftsManager = new FTSManager(db); - const repaired = await ftsManager.verifyAndRepairAll(); - if (repaired > 0) { - console.log(`Repaired ${repaired} corrupted FTS index(es) at startup`); - } - } catch { - // FTS tables may not exist yet (pre-setup). Non-fatal. - } - } + // Skip FTS verification on init — run on FTS query errors instead. + // FTS corruption is rare; checking every cold start adds latency + // without benefit on stable sites. // Initialize storage const storage = EmDashRuntime.getStorage(deps); - // Fetch plugin states from database + // Try loading plugin states + site info from init cache (1 query + // instead of 2-4). The cache is invalidated whenever the manifest + // is invalidated (schema changes, plugin toggle, etc.). let pluginStates: Map = new Map(); + let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | undefined; + let initCacheHit = false; + try { - const states = await db.selectFrom("_plugin_state").select(["plugin_id", "status"]).execute(); - pluginStates = new Map(states.map((s) => [s.plugin_id, s.status])); + const options = new OptionsRepository(db); + const initCache = await options.get<{ + pluginStates: [string, string][]; + siteInfo: typeof siteInfo; + }>("emdash:init_cache"); + if (initCache && typeof initCache === "object") { + if (initCache.pluginStates) { + pluginStates = new Map(initCache.pluginStates); + } + siteInfo = initCache.siteInfo; + initCacheHit = true; + } } catch { - // Plugin state table may not exist yet + // Options table may not exist yet (pre-setup) + } + + if (!initCacheHit) { + // Fetch plugin states from database + try { + const states = await db + .selectFrom("_plugin_state") + .select(["plugin_id", "status"]) + .execute(); + pluginStates = new Map(states.map((s) => [s.plugin_id, s.status])); + } catch { + // Plugin state table may not exist yet + } + + // Load site info with a single batch query instead of 3 sequential gets + try { + const options = new OptionsRepository(db); + const siteOpts = await options.getMany([ + "emdash:site_title", + "emdash:site_url", + "emdash:locale", + ]); + siteInfo = { + siteName: siteOpts.get("emdash:site_title") ?? undefined, + siteUrl: siteOpts.get("emdash:site_url") ?? undefined, + locale: siteOpts.get("emdash:locale") ?? undefined, + }; + } catch { + // Options table may not exist yet (pre-setup) + } + + // Persist for next cold start + try { + const options = new OptionsRepository(db); + await options.set("emdash:init_cache", { + pluginStates: [...pluginStates.entries()], + siteInfo, + }); + } catch { + // Non-fatal — will just re-fetch next time + } } // Build set of enabled plugins @@ -598,22 +645,6 @@ export class EmDashRuntime { } } - // Load site info for plugin context extensions - let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | undefined; - try { - const optionsRepo = new OptionsRepository(db); - const siteName = await optionsRepo.get("emdash:site_title"); - const siteUrl = await optionsRepo.get("emdash:site_url"); - const locale = await optionsRepo.get("emdash:locale"); - siteInfo = { - siteName: siteName ?? undefined, - siteUrl: siteUrl ?? undefined, - locale: locale ?? undefined, - }; - } catch { - // Options table may not exist yet (pre-setup) - } - // Build the full list of pipeline-eligible plugins: all configured // plugins (regardless of current enabled status) plus built-in plugins. // rebuildHookPipeline() filters this to only enabled plugins. @@ -873,11 +904,23 @@ export class EmDashRuntime { const dialect = deps.createDialect(dbConfig.config); const db = new Kysely({ dialect }); - await runMigrations(db); + // Migrations are handled lazily by the dialect wrapper + // (wrapWithLazyMigrations) — on first schema error, migrations + // run and the query retries. This avoids checking 30+ migrations + // on every cold start for established sites. + // + // Adapters that don't use the lazy wrapper (e.g. third-party + // dialects) will hit schema errors on fresh installs, so we run + // migrations eagerly as a fallback when the dialect isn't wrapped. + if (!isLazyMigrationDialect(dialect)) { + await runMigrations(db); + } // Auto-seed schema if no collections exist and setup hasn't run. // This covers first-load on sites that skip the setup wizard. // Dev-bypass and the wizard apply seeds explicitly. + // On lazy-migration dialects, the queries below will trigger + // migration if needed (e.g. fresh install). try { const [collectionCount, setupOption] = await Promise.all([ db @@ -1135,9 +1178,46 @@ export class EmDashRuntime { // ========================================================================= /** - * Build the manifest (rebuilt on each request for freshness) + * Get the manifest, using in-memory and DB caches to avoid N+1 + * schema registry queries on every request. + * + * Cache is invalidated by invalidateManifest() which is called + * from the MCP server on schema changes, plugin toggles, etc. */ async getManifest(): Promise { + // 1. In-memory cache (instant — same worker lifetime) + if (this._cachedManifest) return this._cachedManifest; + + // 2. DB-persisted cache (1 query instead of N+1) + try { + const options = new OptionsRepository(this.db); + const cached = await options.get("emdash:manifest_cache"); + if (cached && typeof cached === "object" && cached.version) { + this._cachedManifest = cached; + return this._cachedManifest; + } + } catch { + // Options table may not exist yet + } + + // 3. Full rebuild, then persist + const manifest = await this._buildManifest(); + this._cachedManifest = manifest; + + try { + const options = new OptionsRepository(this.db); + await options.set("emdash:manifest_cache", manifest); + } catch { + // Non-fatal — will just rebuild next time + } + + return manifest; + } + + /** + * Build the manifest from database (expensive — N+1 collection queries). + */ + private async _buildManifest(): Promise { // Build collections from database. // Use this.db (ALS-aware getter) so playground mode picks up the // per-session DO database instead of the hardcoded singleton. @@ -1324,11 +1404,20 @@ export class EmDashRuntime { } /** - * Invalidate the cached manifest (no-op now that we don't cache). - * Kept for API compatibility. + * Invalidate the cached manifest — clears both in-memory and DB caches. + * + * Called from the MCP server when schema changes, plugins are toggled, + * etc. The next getManifest() call will do a full rebuild. */ invalidateManifest(): void { - // No-op - manifest is rebuilt on each request + this._cachedManifest = null; + try { + const options = new OptionsRepository(this.db); + void options.set("emdash:manifest_cache", null); + void options.set("emdash:init_cache", null); + } catch { + // Non-fatal + } } // ========================================================================= diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 7e755a902..6527298d1 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ "src/db/sqlite.ts", "src/db/libsql.ts", "src/db/postgres.ts", + "src/db/lazy-migrations.ts", // Storage adapters (runtime - loaded via virtual:emdash/storage) "src/storage/local.ts", "src/storage/s3.ts",