Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/cold-start-caching.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/cloudflare/src/db/d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}

// =========================================================================
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
170 changes: 170 additions & 0 deletions packages/core/src/db/lazy-migrations.ts
Original file line number Diff line number Diff line change
@@ -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<any>): 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<void> {
return this.#inner.init();
}

async acquireConnection(): Promise<DatabaseConnection> {
const conn = await this.#inner.acquireConnection();
return new LazyMigrationConnection(conn, this.#dialect);
}

async beginTransaction(conn: DatabaseConnection): Promise<void> {
return this.#inner.beginTransaction(conn);
}

async commitTransaction(conn: DatabaseConnection): Promise<void> {
return this.#inner.commitTransaction(conn);
}

async rollbackTransaction(conn: DatabaseConnection): Promise<void> {
return this.#inner.rollbackTransaction(conn);
}

async releaseConnection(conn: DatabaseConnection): Promise<void> {
return this.#inner.releaseConnection(conn);
}

async destroy(): Promise<void> {
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<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
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<Database>({ 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<O>(
compiledQuery: CompiledQuery,
chunkSize?: number,
): AsyncIterableIterator<QueryResult<O>> {
yield* this.#inner.streamQuery(compiledQuery, chunkSize);
}
}
11 changes: 7 additions & 4 deletions packages/core/src/db/libsql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}),
);
}
6 changes: 4 additions & 2 deletions packages/core/src/db/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,5 +28,5 @@ export function createDialect(config: PostgresConfig): PostgresDialect {
max: config.pool?.max ?? 10,
});

return new PostgresDialect({ pool });
return wrapWithLazyMigrations(new PostgresDialect({ pool }));
}
3 changes: 2 additions & 1 deletion packages/core/src/db/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,5 +24,5 @@ export function createDialect(config: SqliteConfig): Dialect {

const database = new BetterSqlite3(filePath);

return new SqliteDialect({ database });
return wrapWithLazyMigrations(new SqliteDialect({ database }));
}
Loading
Loading