refactor(node): migrate from better-sqlite3 to node:sqlite, require Node 24+
Some checks failed
CI (Node) / build-test (push) Failing after 8s

Native binding caused install pain on every new Node major (no prebuilts +
node-gyp needs VS+Windows SDK to fall back). For this project's workload
(a few ops/day, no advanced SQLite features) better-sqlite3's perf edge is
irrelevant — node:sqlite's bundled, ABI-stable sync API is the better fit.

- db.ts: DatabaseSync, db.exec("PRAGMA …"), explicit BEGIN/COMMIT helper to
  replace db.transaction(); row casts go through unknown because node:sqlite
  returns Record<string, SQLOutputValue>.
- package.json: drop better-sqlite3 + @types/better-sqlite3, bump
  engines.node to >=24, vitest 2 → 4 (2.x couldn't resolve `node:sqlite`).
- mailbox-doctor: add Step 1 that enforces Node ≥24 with a concrete fix
  message, renumbers downstream steps.

Node 1.2.0 → 1.3.0. 35 transitive packages removed from the lockfile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-05-19 16:07:21 +02:00
parent 8747d638fb
commit 8832eab6c7
5 changed files with 818 additions and 1324 deletions

View File

@@ -1,4 +1,4 @@
import Database from "better-sqlite3";
import { DatabaseSync, type StatementSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
@@ -57,28 +57,44 @@ function parseDate(s: string | null | undefined): Date | null {
return isNaN(d.getTime()) ? null : d;
}
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN");
try {
const result = fn();
db.exec("COMMIT");
return result;
} catch (err) {
try {
db.exec("ROLLBACK");
} catch {
// ignore: original error already on its way up
}
throw err;
}
}
export class MailboxStore {
private readonly db: Database.Database;
private readonly db: DatabaseSync;
private readonly stmts: {
findMailbox: Database.Statement;
insertMailbox: Database.Statement;
touchMailbox: Database.Statement;
listMailboxes: Database.Statement;
insertMessage: Database.Statement;
countPending: Database.Statement;
oldestPending: Database.Statement;
selectPending: Database.Statement;
markDelivered: Database.Statement;
pendingByRecipient: Database.Statement;
findMailbox: StatementSync;
insertMailbox: StatementSync;
touchMailbox: StatementSync;
listMailboxes: StatementSync;
insertMessage: StatementSync;
countPending: StatementSync;
oldestPending: StatementSync;
selectPending: StatementSync;
markDelivered: StatementSync;
pendingByRecipient: StatementSync;
};
constructor(public readonly dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma("journal_mode = WAL");
this.db.pragma("foreign_keys = ON");
for (const sql of DDL_STATEMENTS) this.db.prepare(sql).run();
this.db = new DatabaseSync(dbPath);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA foreign_keys = ON");
for (const sql of DDL_STATEMENTS) this.db.exec(sql);
this.stmts = {
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
@@ -114,7 +130,7 @@ export class MailboxStore {
upsertMailbox(name: string): void {
const now = nowIso();
const existing = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
const existing = this.stmts.findMailbox.get(name) as unknown as MailboxRow | undefined;
if (existing) {
this.stmts.touchMailbox.run(now, name);
} else {
@@ -123,14 +139,13 @@ export class MailboxStore {
}
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
const tx = this.db.transaction(() => {
return runInTransaction(this.db, () => {
this.upsertMailbox(from);
this.upsertMailbox(to);
const createdAt = nowIso();
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
});
return tx();
}
peek(name: string): InboxStatus {
@@ -141,19 +156,18 @@ export class MailboxStore {
}
checkInbox(name: string): MessageRow[] {
const tx = this.db.transaction(() => {
const pending = this.stmts.selectPending.all(name) as MessageRow[];
return runInTransaction(this.db, () => {
const pending = this.stmts.selectPending.all(name) as unknown as MessageRow[];
if (pending.length > 0) {
const ids = pending.map((m) => m.id);
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
}
return pending;
});
return tx();
}
listMailboxes(forName?: string): MailboxInfo[] {
const rows = this.stmts.listMailboxes.all() as MailboxRow[];
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
const pendingMap = new Map<string, number>();
if (forName) {
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];