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
11 changes: 11 additions & 0 deletions .changeset/node-sqlite-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": minor
---

Updates the `sqlite()` adapter and CLI to use Node's built-in `node:sqlite` driver instead of better-sqlite3. SQLite sites no longer depend on a natively compiled binary, which removes `NODE_MODULE_VERSION` rebuild errors after Node upgrades and glibc incompatibilities on shared hosting.

Requires Node.js 22.15 or later. If you are on an older Node 22 release, upgrade Node before updating.

Connection pragmas are now applied wherever the package opens a SQLite database, so sites using the runtime `sqlite()` adapter get the same settings the CLI already applied: `journal_mode = WAL` (readers no longer block on the writer, and FTS5 shadow tables survive a mid-write process kill), `busy_timeout = 5000` (a competing writer waits instead of failing with `SQLITE_BUSY`), and `foreign_keys = ON`.

Query parameters are normalized so binding behaviour matches the old driver: `undefined` binds as `NULL` (accepted by better-sqlite3, rejected by `node:sqlite`), and a `Date` raises an actionable error instead of silently binding `NULL`. Booleans now bind as `0`/`1` — neither driver accepted them before, so boolean filters that previously threw at bind time (for example the plugin storage query API, whose `WhereValue` type has always advertised `boolean`) now work.
1 change: 0 additions & 1 deletion demos/plugins-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
Expand Down
1 change: 0 additions & 1 deletion demos/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"@emdash-cms/plugin-color": "workspace:*",
"@emdash-cms/plugin-cli": "workspace:*",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
Expand Down
1 change: 0 additions & 1 deletion e2e/fixture/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"@emdash-cms/auth": "workspace:*",
"@emdash-cms/plugin-color": "workspace:*",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
Expand Down
1 change: 0 additions & 1 deletion fixtures/perf-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"@astrojs/react": "catalog:",
"@emdash-cms/cloudflare": "workspace:*",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"kysely": "^0.29.0",
"react": "catalog:",
Expand Down
7 changes: 5 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@
"@unpic/placeholder": "^0.1.2",
"arctic": "^3.7.0",
"astro-portabletext": "^0.11.0",
"better-sqlite3": "catalog:",
"blurhash": "^2.0.5",
"citty": "^0.1.6",
"consola": "^3.4.2",
Expand Down Expand Up @@ -272,6 +271,7 @@
"@arethetypeswrong/cli": "catalog:",
"@emdash-cms/blocks": "workspace:*",
"@types/better-sqlite3": "^7.6.12",
"better-sqlite3": "catalog:",
"@types/pg": "^8.16.0",
"@types/react": "catalog:",
"@types/sanitize-html": "^2.16.0",
Expand All @@ -297,5 +297,8 @@
"wordpress"
],
"author": "Matt Kane",
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=22.15"
}
}
8 changes: 1 addition & 7 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,13 +336,7 @@ export function createVirtualModulesPlugin(
// `?url`), so both forms resolve to dist rather than the source alias.
const ADMIN_STYLES_ALIAS = /^@emdash-cms\/admin\/styles\.css/;

const NODE_NATIVE_EXTERNALS = [
"better-sqlite3",
"bindings",
"file-uri-to-path",
"@libsql/kysely-libsql",
"pg",
];
const NODE_NATIVE_EXTERNALS = ["@libsql/kysely-libsql", "pg"];

/**
* Detect whether the Cloudflare adapter is being used.
Expand Down
35 changes: 4 additions & 31 deletions packages/core/src/database/connection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import BetterSqlite3 from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";

import { openNodeSqliteDatabase } from "../db/node-sqlite-compat.js";
import { EmDashDatabaseError } from "./errors.js";
import { kyselyLogOption } from "./instrumentation.js";
import type { Database } from "./types.js";
Expand All @@ -12,23 +12,6 @@ export interface DatabaseConfig {
authToken?: string;
}

/**
* Returns a helpful, actionable message when better-sqlite3's native binary
* was compiled against a different Node.js version than the one running. This
* happens after upgrading Node without rebuilding native deps.
*
* Returns null if the error is not a NODE_MODULE_VERSION mismatch.
*/
export function formatNativeModuleVersionError(error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("NODE_MODULE_VERSION")) return null;
return (
"better-sqlite3's native binary was compiled against a different Node.js version. " +
"Rebuild it with `pnpm rebuild better-sqlite3` (or `npm rebuild better-sqlite3`), " +
"or reinstall dependencies with your current Node.js version."
);
}

/**
* Creates a Kysely database instance
* Supports:
Expand All @@ -42,15 +25,9 @@ export function createDatabase(config: DatabaseConfig): Kysely<Database> {
if (config.url.startsWith("file:") || config.url === ":memory:") {
const dbPath = config.url === ":memory:" ? ":memory:" : config.url.replace("file:", "");

const sqlite = new BetterSqlite3(dbPath);

// Enable WAL mode for crash safety — writes go to a write-ahead log
// before being applied, preventing FTS5 shadow table corruption on
// process kill during content writes. No-op for :memory: databases.
sqlite.pragma("journal_mode = WAL");

// Enable foreign key constraints
sqlite.pragma("foreign_keys = ON");
// Connection pragmas (WAL, busy_timeout, foreign_keys) are applied by
// openNodeSqliteDatabase so every SQLite entry point gets them.
const sqlite = openNodeSqliteDatabase(dbPath);

const dialect = new SqliteDialect({
database: sqlite,
Expand All @@ -73,10 +50,6 @@ export function createDatabase(config: DatabaseConfig): Kysely<Database> {
if (error instanceof EmDashDatabaseError) {
throw error;
}
const nativeVersionHint = formatNativeModuleVersionError(error);
if (nativeVersionHint) {
throw new EmDashDatabaseError(nativeVersionHint, error);
}
throw new EmDashDatabaseError("Failed to create database", error);
}
}
3 changes: 2 additions & 1 deletion packages/core/src/database/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// `createDatabase` is intentionally not re-exported here: it lives in
// `connection.ts`, which statically imports `better-sqlite3`. See #947.
// `connection.ts`, which statically imports `node:sqlite` — a Node-only
// builtin that must not load in non-Node runtimes (e.g. workerd). See #947.
export { EmDashDatabaseError } from "./errors.js";
export type { DatabaseConfig } from "./connection.js";
export { runMigrations, getMigrationStatus, rollbackMigration } from "./migrations/runner.js";
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/db/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ export interface LibsqlConfig {
}

/**
* SQLite database adapter (better-sqlite3)
* SQLite database adapter (node:sqlite)
*
* For local development and Node.js deployments.
* For local development and Node.js deployments. Uses the Node.js built-in
* SQLite driver — no native compiled dependency. Requires Node >= 22.15.
*
* @example
* ```ts
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/db/node-sqlite-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* better-sqlite3-compatible wrapper around node:sqlite.
*
* Kysely's built-in SqliteDialect drives the database through better-sqlite3's
* surface: `prepare()`, statement `.reader` / `.all(params)` / `.run(params)` /
* `.iterate(params)`, and `close()`. node:sqlite's DatabaseSync supports the
* same operations but binds parameters as spread arguments and has no
* `.reader` flag. This wrapper bridges the two so the dialect needs no native
* compiled dependency.
*
* `.reader` is derived from `StatementSync.columns()` (column count > 0),
* which — like better-sqlite3's flag — is true for any statement that returns
* rows, including `INSERT ... RETURNING`.
*
* Requires Node >= 22.15 (node:sqlite unflagged + StatementSync.columns()).
*/

import { DatabaseSync } from "node:sqlite";

/**
* The subset of better-sqlite3's Database API that emdash consumes: what
* Kysely's SqliteDialect calls, plus `exec`/`pragma` used at connection setup.
*/
export interface NodeSqliteCompatDatabase {
close(): void;
prepare(sql: string): NodeSqliteCompatStatement;
exec(sql: string): void;
pragma(pragma: string): void;
}

export interface NodeSqliteCompatStatement {
readonly reader: boolean;
all(parameters: ReadonlyArray<unknown>): unknown[];
run(parameters: ReadonlyArray<unknown>): {
changes: number | bigint;
lastInsertRowid: number | bigint;
};
iterate(parameters: ReadonlyArray<unknown>): IterableIterator<unknown>;
}

/**
* Open a SQLite database via node:sqlite, exposed through the
* better-sqlite3-compatible surface above. Pass the result directly to
* Kysely's `new SqliteDialect({ database })`.
*/
export function openNodeSqliteDatabase(path: string): NodeSqliteCompatDatabase {
const db = new DatabaseSync(path);

// Connection defaults, applied here because this is now the single place the
// package opens a SQLite database — previously only the CLI path
// (database/connection.ts) set them, so sites running through the runtime
// adapter (db/sqlite.ts) silently got neither.
//
// WAL: readers don't block on the writer, and writes land in a log before
// being applied — this is what prevents FTS5 shadow-table corruption if the
// process is killed mid-write. No-op for `:memory:`.
db.exec("PRAGMA journal_mode = WAL");
// Wait for a competing writer instead of failing the query outright; without
// it a concurrent write (backup, second process) surfaces as SQLITE_BUSY.
db.exec("PRAGMA busy_timeout = 5000");
// Referential integrity is off by default in SQLite; the schema declares
// foreign keys, so enforce them.
db.exec("PRAGMA foreign_keys = ON");

return {
close: () => db.close(),
exec: (sql) => db.exec(sql),
pragma: (pragma) => db.exec(`PRAGMA ${pragma}`),
prepare(sql) {
const stmt = db.prepare(sql);
return {
reader: stmt.columns().length > 0,
all: (parameters) => stmt.all(...toBindings(parameters)),
run: (parameters) => stmt.run(...toBindings(parameters)),
iterate: (parameters) => stmt.iterate(...toBindings(parameters)),
};
},
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] toBindings casts Kysely's parameters to SQLInputValue[] without any value mapping, but SQLInputValue excludes boolean. better-sqlite3 accepts JavaScript booleans and stores them as 0/1; node:sqlite rejects booleans at bind time, so any query that inserts, updates, or selects on a boolean value will throw a runtime TypeError. This is a real compatibility gap for any code that relied on the old driver's coercion.

Map booleans to integers before spreading them into the statement:

Suggested change
function toBindings(parameters: ReadonlyArray<unknown>): SQLInputValue[] {
return parameters.map((value) => {
if (typeof value === "boolean") return value ? 1 : 0;
return value as SQLInputValue;
});
}

/** Parameter type accepted by node:sqlite statement bindings. */
type SQLInputValue = null | number | bigint | string | Uint8Array;

/**
* Normalize Kysely's compiled parameters to what node:sqlite accepts.
*
* node:sqlite binds a narrower set of JS types than better-sqlite3 did, and
* differs in both directions, so the values are mapped rather than cast:
*
* - `boolean` — rejected by BOTH drivers (better-sqlite3: "SQLite3 can only
* bind numbers, strings, bigints, buffers, and null"). SQLite has no boolean
* type and stores them as 0/1, and `json_extract` yields 0/1 for JSON
* booleans, so mapping here makes boolean filters work rather than throw —
* e.g. the plugin storage query API, whose `WhereValue` advertises `boolean`
* (see plugins/types.ts) but which threw at bind time on either driver.
* - `undefined` — better-sqlite3 bound it as NULL; node:sqlite throws. Kysely
* passes it straight through (e.g. `.where(col, "=", undefined)` from an
* unset optional filter), so mapping to null preserves the old behaviour
* instead of turning a previously-working query into a runtime TypeError.
* - `Date` — better-sqlite3 threw; node:sqlite silently binds NULL, which
* would turn a loud programming error into silent data loss. Rethrow with
* an actionable message: emdash stores timestamps as ISO strings.
*
* Everything else (number, bigint, string, Uint8Array/Buffer, null) binds
* identically on both drivers and is passed through untouched; any remaining
* unsupported type is still rejected by node:sqlite at bind time.
*/
function toBindings(parameters: ReadonlyArray<unknown>): SQLInputValue[] {
return parameters.map((value) => {
if (typeof value === "boolean") return value ? 1 : 0;
if (value === undefined) return null;
if (value instanceof Date) {
throw new TypeError(
"Cannot bind a Date to a SQLite parameter; convert it to an ISO string first (e.g. date.toISOString()).",
);
}
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Kysely hands through the values it compiled into the query; node:sqlite rejects any remaining unsupported type at bind time, same as better-sqlite3 did
return value as SQLInputValue;
});
}
7 changes: 4 additions & 3 deletions packages/core/src/db/sqlite.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* SQLite runtime adapter
*
* Creates a Kysely dialect for better-sqlite3.
* Creates a Kysely dialect for node:sqlite (via a better-sqlite3-compatible
* wrapper — see node-sqlite-compat.ts). No native compiled dependency.
* Loaded at runtime via virtual module.
*/

import BetterSqlite3 from "better-sqlite3";
import { type Dialect, SqliteDialect } from "kysely";

import type { SqliteConfig } from "./adapters.js";
import { openNodeSqliteDatabase } from "./node-sqlite-compat.js";

/**
* Create a SQLite dialect from config
Expand All @@ -18,7 +19,7 @@ export function createDialect(config: SqliteConfig): Dialect {
const url = config.url;
const filePath = url.startsWith("file:") ? url.slice(5) : url;

const database = new BetterSqlite3(filePath);
const database = openNodeSqliteDatabase(filePath);

return new SqliteDialect({ database });
}
23 changes: 1 addition & 22 deletions packages/core/tests/database/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import { unlinkSync } from "node:fs";
import type { Kysely } from "kysely";
import { describe, it, expect, afterEach } from "vitest";

import {
createDatabase,
EmDashDatabaseError,
formatNativeModuleVersionError,
} from "../../src/database/connection.js";
import { createDatabase, EmDashDatabaseError } from "../../src/database/connection.js";
import type { Database } from "../../src/database/types.js";

describe("createDatabase", () => {
Expand Down Expand Up @@ -142,23 +138,6 @@ describe("createDatabase", () => {
});
});

describe("formatNativeModuleVersionError", () => {
it("returns an actionable message for NODE_MODULE_VERSION mismatch", () => {
const err = new Error(
"The module '/path/better_sqlite3.node' was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.",
);
const message = formatNativeModuleVersionError(err);
expect(message).not.toBeNull();
expect(message).toContain("better-sqlite3");
expect(message).toMatch(/rebuild/i);
});

it("returns null for unrelated errors", () => {
expect(formatNativeModuleVersionError(new Error("disk full"))).toBeNull();
expect(formatNativeModuleVersionError("some string")).toBeNull();
});
});

describe("connection lifecycle", () => {
it("should allow closing connection with destroy()", async () => {
db = createDatabase({ url: ":memory:" });
Expand Down
1 change: 0 additions & 1 deletion packages/core/tests/integration/fixture/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"@emdash-cms/auth": "workspace:*",
"@emdash-cms/plugin-color": "workspace:*",
"astro": "catalog:",
"better-sqlite3": "^11.10.0",
"emdash": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
Expand Down
Loading
Loading