refactor(db): switch records layer to Prisma

- Replace hand-written node:sqlite queries with a Prisma client using
  the better-sqlite3 driver adapter
- Version the schema with migrations: baseline table, timestamp
  normalisation, and WAL journal mode
- Store timestamps with an explicit UTC offset so they stop being parsed
  as local time
- Show created/updated times on the edit page, formatted with dayjs
- Move the root redirect into next.config to avoid a prerender error
- Run migrations from the container entrypoint before starting the server

BREAKING CHANGE: DNSMASQ_ADMIN_DB_PATH is replaced by DATABASE_URL, which
must be a Prisma connection URL (file:./data/dnsmasq-admin.db). Existing
database files are migrated in place and need no conversion.
This commit is contained in:
jamo.siu@gmail.com committed 2026-10-12 14:27:03 +08:00
1 parent 97ad09ad8a
commit 1e4f028685
25 files changed
+1796 -141

No files matched your search

+46 -62
View File
@@ -1,84 +1,68 @@
import { mkdirSync } from "node:fs"
import path from "node:path"
import { DatabaseSync } from "node:sqlite"
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"
import { PrismaClient } from "./generated/prisma/client"
import type { DnsRecordModel } from "./generated/prisma/models"
import { env } from "./env"
export type RecordType = "A" | "CNAME"
export interface DnsRecord {
id: number
domain: string
/**
* The row Prisma hands back, with `type` narrowed to the union this app actually allows.
*
* SQLite has no enum support, so the generated field is a plain string even though every
* path that writes it goes through lib/validate.ts. Narrowing here keeps the rest of the app
* type-safe without pretending the database enforces it.
*/
export type DnsRecord = Omit<DnsRecordModel, "type" | "createdAt" | "updatedAt"> & {
type: RecordType
value: string
enabled: boolean
comment: string
createdAt: string
updatedAt: string
}
const CREATE_TABLE = `
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL COLLATE NOCASE UNIQUE,
type TEXT NOT NULL CHECK (type IN ('A','CNAME')),
value TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`
function openDatabase(): DatabaseSync {
mkdirSync(path.dirname(env.dbPath), { recursive: true })
const db = new DatabaseSync(env.dbPath)
// WAL keeps reads from blocking writes; busy_timeout lets concurrent connections wait
// instead of failing immediately with SQLITE_BUSY.
db.exec("PRAGMA journal_mode = WAL")
db.exec("PRAGMA busy_timeout = 5000")
db.exec(CREATE_TABLE)
return db
/** Narrow a database string into RecordType, defaulting to `A` like the old mapper did. */
export function toRecordType(value: string): RecordType {
return value === "CNAME" ? "CNAME" : "A"
}
// Cached on globalThis so Next.js dev HMR does not leak a new handle per reload.
const globalForDb = globalThis as typeof globalThis & {
__dnsmasqAdminDb?: DatabaseSync
/**
* Timestamps travel through the app as ISO strings rather than `Date`.
*
* They are stored UTC with an explicit `+00:00` offset (see prisma/migrations/0002), so an
* ISO string round-trips unambiguously, whereas a `Date` crossing the Server Component /
* Client Component boundary invites accidental local-time formatting in the wrong place.
* Use lib/datetime.ts to render one.
*/
export function toTimestamp(value: Date): string {
return value.toISOString()
}
export function getDb(): DatabaseSync {
globalForDb.__dnsmasqAdminDb ??= openDatabase()
return globalForDb.__dnsmasqAdminDb
}
type Row = Record<string, unknown>
/** Normalise whatever the driver hands back into a well-typed record. */
function rowsOf(result: unknown): Row[] {
return Array.isArray(result) ? (result as Row[]) : []
}
export function rowToRecord(row: Row): DnsRecord {
export function toDnsRecord(row: DnsRecordModel): DnsRecord {
return {
id: Number(row.id),
domain: String(row.domain),
type: row.type === "CNAME" ? "CNAME" : "A",
value: String(row.value),
enabled: Number(row.enabled) === 1,
comment: String(row.comment ?? ""),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
id: row.id,
domain: row.domain,
type: toRecordType(row.type),
value: row.value,
enabled: row.enabled,
comment: row.comment,
createdAt: toTimestamp(row.createdAt),
updatedAt: toTimestamp(row.updatedAt),
}
}
export function queryRows(sql: string, ...params: unknown[]): Row[] {
return rowsOf(getDb().prepare(sql).all(...params))
function createClient(): PrismaClient {
// The adapter strips the `file:` prefix before handing the path to better-sqlite3.
// `timeout` maps to busy_timeout and already defaults to 5000, which is what this app used
// before; listed explicitly so the behaviour survives a driver default change.
const adapter = new PrismaBetterSqlite3({ url: env.databaseUrl, timeout: 5000 })
return new PrismaClient({ adapter })
}
export function queryRow(sql: string, ...params: unknown[]): Row | null {
const row = getDb().prepare(sql).get(...params)
return row && typeof row === "object" ? (row as Row) : null
// Cached on globalThis so Next.js dev HMR does not open a new handle per reload; see the
// journal_mode migration note below for why one long-lived handle matters here.
const globalForDb = globalThis as typeof globalThis & {
__dnsmasqAdminPrisma?: PrismaClient
}
export function execute(sql: string, ...params: unknown[]): void {
getDb().prepare(sql).run(...params)
export function getDb(): PrismaClient {
globalForDb.__dnsmasqAdminPrisma ??= createClient()
return globalForDb.__dnsmasqAdminPrisma
}