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:
25 files changed
+1796
-141
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 } })
|
||||
}
|
||||
Reference in new issue
Block a user