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

+21
View File
@@ -0,0 +1,21 @@
import dayjs from "dayjs"
/** Single format used everywhere a timestamp is rendered, so the UI stays consistent. */
const DISPLAY_FORMAT = "YYYY-MM-DD HH:mm:ss"
/**
* Format a stored timestamp for display.
*
* Every timestamp carries an explicit `+00:00` offset (see prisma/migrations/0002), so dayjs
* always resolves the correct instant; it then renders it in the viewer's local zone. Before
* that migration the values looked like `2026-10-09 08:55:10`, which JavaScript parses as
* *local* time even though SQLite stored it UTC — an eight-hour error on a UTC+8 machine.
*
* Timestamps travel through the app as ISO strings (see lib/db.ts), so this helper takes a
* string rather than a `Date`.
*/
export function formatDateTime(value: string | null | undefined): string {
if (!value) return "—"
const parsed = dayjs(value)
return parsed.isValid() ? parsed.format(DISPLAY_FORMAT) : value
}
+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
}
+8 -2
View File
@@ -14,8 +14,14 @@ function toAbsolute(target: string): string {
* boot locally without any env file; see `.env.example` for the deploy-time values.
*/
export const env = {
/** SQLite database file — the single source of truth for records. */
dbPath: toAbsolute(withDefault("DNSMASQ_ADMIN_DB_PATH", "./data/dnsmasq-admin.db")),
/**
* Prisma connection URL for the SQLite database — the single source of truth for records.
*
* Left as-is rather than resolved with `toAbsolute()`, because Prisma and better-sqlite3
* resolve relative paths against the working directory, which is what `./data/...` in
* `.env` already assumes.
*/
databaseUrl: withDefault("DATABASE_URL", "file:./data/dnsmasq-admin.db"),
/** Managed dnsmasq config file that this app regenerates on every apply. */
confPath: toAbsolute(withDefault("DNSMASQ_ADMIN_CONF_PATH", "./data/dnsmasq-admin.conf")),
/** Container that gets restarted through the Docker socket after an apply. */
+69 -39
View File
@@ -1,9 +1,8 @@
import { io } from "next/cache"
import { execute, queryRow, queryRows, rowToRecord, type DnsRecord } from "./db"
import { Prisma } from "./generated/prisma/client"
import { getDb, toDnsRecord, type DnsRecord } from "./db"
import type { RecordInput } from "./validate"
const SELECT_ALL = "SELECT * FROM records"
export class DuplicateDomainError extends Error {
constructor(readonly domain: string) {
super(`domain already exists: ${domain}`)
@@ -12,6 +11,10 @@ export class DuplicateDomainError extends Error {
}
function isUniqueViolation(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return error.code === "P2002"
}
// Raw driver errors can still surface from statements outside the query builder.
const { code, message } = (error ?? {}) as { code?: string; message?: string }
return (
code === "SQLITE_CONSTRAINT_UNIQUE" ||
@@ -21,71 +24,98 @@ function isUniqueViolation(error: unknown): boolean {
}
/**
* Reads go through `await io()`.
* Case-insensitive domain ordering.
*
* `node:sqlite` is a synchronous driver, so under `cacheComponents` its results would
* otherwise be captured into the prerendered static shell at build time and the page would
* show stale rows forever. `io()` suspends during prerender so the read happens at request
* time. See node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md.
* `domain` no longer carries COLLATE NOCASE (see prisma/migrations/0001_baseline), so SQLite
* would order by binary collation and put "Zoo.local" before "apple.local". Sorting here also
* keeps results identical between databases created before that change and after it.
*/
const byDomain = (a: DnsRecord, b: DnsRecord): number =>
a.domain.localeCompare(b.domain, undefined, { sensitivity: "base" })
/**
* Reads must still call `await io()` even though Prisma is asynchronous.
*
* Next only bails out of prerendering for *sources it recognises* — `fetch`, the request-time
* APIs like `cookies()`, or an explicit `io()`. Prisma over better-sqlite3 is a bespoke async
* driver, so during a build its queries simply resolve and get baked into the static shell:
* verified by grepping the built `.next/server/app/records.html`, which contained real rows
* until this guard was restored. The symptom is a page that renders build-time data forever.
*
* Worth knowing before "tidying" this away simply because the driver is async: the docs'
* exemption covers an awaited *fetch*, not every awaited promise.
* See node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md.
*
* Callers wrap these in `<Suspense>`, so the shell ships the fallback and rows stream in per
* request.
*/
export async function listRecords(): Promise<DnsRecord[]> {
await io()
return queryRows(`${SELECT_ALL} ORDER BY domain COLLATE NOCASE`).map(rowToRecord)
const rows = await getDb().dnsRecord.findMany({ orderBy: { domain: "asc" } })
return rows.map(toDnsRecord).sort(byDomain)
}
export async function getRecord(id: number): Promise<DnsRecord | null> {
await io()
const row = queryRow(`${SELECT_ALL} WHERE id = ?`, id)
return row ? rowToRecord(row) : null
const row = await getDb().dnsRecord.findUnique({ where: { id } })
return row ? toDnsRecord(row) : null
}
export async function listEnabledRecords(): Promise<DnsRecord[]> {
await io()
return queryRows(`${SELECT_ALL} WHERE enabled = 1 ORDER BY domain COLLATE NOCASE`).map(rowToRecord)
const rows = await getDb().dnsRecord.findMany({
where: { enabled: true },
orderBy: { domain: "asc" },
})
return rows.map(toDnsRecord).sort(byDomain)
}
// Writes below only ever run inside Server Actions, which always execute at request time,
// so they do not need an `io()` guard.
// Writes below run inside Server Actions, which always execute at request time.
//
// They use `updateMany`/`deleteMany` rather than `update`/`delete` deliberately: those raise
// P2025 when the row is gone, which would turn "someone deleted this in another tab" into a
// crashed action. The pre-Prisma code was a plain UPDATE/DELETE that quietly matched zero
// rows, so this keeps that behaviour. @updatedAt is still applied by both.
export function createRecord(input: RecordInput): void {
export async function createRecord(input: RecordInput): Promise<void> {
try {
execute(
"INSERT INTO records (domain, type, value, enabled, comment) VALUES (?, ?, ?, ?, ?)",
input.domain,
input.type,
input.value,
input.enabled ? 1 : 0,
input.comment
)
await getDb().dnsRecord.create({
data: {
domain: input.domain,
type: input.type,
value: input.value,
enabled: input.enabled,
comment: input.comment,
},
})
} catch (error) {
if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
throw error
}
}
export function updateRecord(id: number, input: RecordInput): void {
export async function updateRecord(id: number, input: RecordInput): Promise<void> {
try {
execute(
`UPDATE records
SET domain = ?, type = ?, value = ?, enabled = ?, comment = ?, updated_at = datetime('now')
WHERE id = ?`,
input.domain,
input.type,
input.value,
input.enabled ? 1 : 0,
input.comment,
id
)
await getDb().dnsRecord.updateMany({
where: { id },
data: {
domain: input.domain,
type: input.type,
value: input.value,
enabled: input.enabled,
comment: input.comment,
},
})
} catch (error) {
if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
throw error
}
}
export function removeRecord(id: number): void {
execute("DELETE FROM records WHERE id = ?", id)
export async function removeRecord(id: number): Promise<void> {
await getDb().dnsRecord.deleteMany({ where: { id } })
}
export function setRecordEnabled(id: number, enabled: boolean): void {
execute("UPDATE records SET enabled = ?, updated_at = datetime('now') WHERE id = ?", enabled ? 1 : 0, id)
export async function setRecordEnabled(id: number, enabled: boolean): Promise<void> {
await getDb().dnsRecord.updateMany({ where: { id }, data: { enabled } })
}