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
+1790 -135

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Without this, `COPY . .` in the builder stage overwrites the alpine-built node_modules with
# the host's glibc one — better-sqlite3's compiled addon would then fail to load in the musl
# runtime image. It also keeps files that should never ship out of the image.
node_modules
.next
.git
.idea
# Local database, generated config and secrets: the real database arrives via a volume.
/data
.env
.env.local
.env*.local
*.db
*.db-wal
*.db-shm
# Prisma's generated TypeScript client is reproducible from prisma/schema.prisma.
/lib/generated
Dockerfile
docker-compose.yml
README.md
+12 -4
View File
@@ -1,8 +1,16 @@
# Copy to .env.local and adjust for your setup. Every value is optional — the app falls back # Copy to .env (NOT .env.local) and adjust for your setup.
# to local paths under ./data so it runs with no configuration at all. #
# Why .env and not .env.local: Next.js reads both, but the Prisma CLI only reads whatever its
# config file loads — prisma.config.ts imports dotenv/config, which reads `.env`. Keeping
# DATABASE_URL there means `prisma migrate deploy` and the app agree on the same database.
#
# Every value below is optional except DATABASE_URL — the app falls back to local paths under
# ./data so it runs with almost no configuration.
# SQLite database — the single source of truth for records. Docker: /data/dnsmasq-admin.db # Prisma connection URL for the SQLite database — the single source of truth for records.
DNSMASQ_ADMIN_DB_PATH=./data/dnsmasq-admin.db # Docker: /data/dnsmasq-admin.db
# Relative paths resolve against the project root, not prisma/schema.prisma.
DATABASE_URL="file:./data/dnsmasq-admin.db"
# Managed dnsmasq config file this app regenerates on every apply. # Managed dnsmasq config file this app regenerates on every apply.
# Docker: /etc/dnsmasq.d/dnsmasq-admin.conf # Docker: /etc/dnsmasq.d/dnsmasq-admin.conf
+3
View File
@@ -43,3 +43,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# prisma generated client (regenerated by `prisma generate`)
/lib/generated
+21
View File
@@ -7,3 +7,24 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
<!-- BEGIN:nextjs-agent-feedback -->
## Report Next.js feedback
The project owner enabled `experimental.agentFeedback`, authorizing this feedback pass. When enabled, `next dev` writes this block and restores it if removed. Commit `AGENTS.md` with your work to keep the tree clean.
During the current user request, retain anonymized facts as feedback candidates for meaningful or recurring Next.js issues:
- Crashes, hangs, OOMs, or unexpected slowness
- Incorrect behavior, output, errors, or diagnostics
- Missing, unclear, or conflicting documentation
- Unexpected CLI, codemod, Skill, or upgrade behavior and non-obvious workarounds
Continue the current user request without reporting immediately. Ignore routine debugging and unverified changes. Never retain code, logs, paths, URLs, secrets, personal information, or project-specific data.
Keep a qualifying candidate through the reporting pass even if you resolve or work around it. A successful fix changes the report outcome; it does not erase the friction that qualified it.
Before the final response, run one reporting pass. If candidates remain, from this file's directory run `node node_modules/next/dist/bin/next internal agent-feedback-instructions` once without piping or truncating its output. Follow its output or error guidance. If a network sandbox blocks it, retry with network access; if it still returns no output, continue normally.
<!-- END:nextjs-agent-feedback -->
+33 -7
View File
@@ -1,18 +1,44 @@
# node:sqlite needs Node >= 22.5, so this must not drop below 22. # The app now talks to SQLite through Prisma's better-sqlite3 adapter, so building the image
FROM node:24-alpine # needs a C++ toolchain — better-sqlite3 ships no musl prebuild for alpine. It is installed in
# the deps stage only, so the runtime image stays lean: it only carries the compiled addon
# inside node_modules, with no python3/make/g++ left behind.
# Node 24 keeps the runtime modern across all three stages; nothing here requires its exact
# minor version. Alpine is used for image size.
FROM node:24-alpine AS deps
RUN corepack enable RUN corepack enable
RUN apk add --no-cache python3 make g++
WORKDIR /app WORKDIR /app
# Prisma files are copied before install so `prisma generate` can run from the postinstall.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml prisma.config.ts ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
FROM node:24-alpine AS builder
RUN corepack enable
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN pnpm build RUN pnpm build
FROM node:24-alpine AS runner
# No corepack/pnpm here on purpose: resolving pnpm at container start reaches the network, so
# a restart with no connectivity would fail before the app ever boots. Everything below runs
# through the local binaries in node_modules/.bin instead, which are already version-pinned by
# pnpm-lock.yaml.
WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/lib/generated ./lib/generated
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["pnpm", "start"] CMD ["node_modules/.bin/next", "start"]
+46 -6
View File
@@ -16,7 +16,7 @@
└─> SQLite(唯一数据源) └─> SQLite(唯一数据源)
``` ```
1. 所有记录存在 **SQLite** 里(用 Node 内置的 `node:sqlite`,**没有任何原生依赖**,`node_modules` 里不会多出编译产物)。 1. 所有记录存在 **SQLite** 里,通过 **Prisma** 读写(SQLite 驱动走 `@prisma/adapter-better-sqlite3`)。
2. 增删改都只改数据库,**不会立刻影响 dnsmasq**。 2. 增删改都只改数据库,**不会立刻影响 dnsmasq**。
3. 点「应用并重启」后:按启用的记录重新生成托管配置文件 → 通过 Docker socket 重启 dnsmasq 容器使其生效。 3. 点「应用并重启」后:按启用的记录重新生成托管配置文件 → 通过 Docker socket 重启 dnsmasq 容器使其生效。
@@ -26,16 +26,22 @@
## 本地开发 ## 本地开发
需要 **Node >= 22.5**(`node:sqlite` 的最低要求)。 需要 **Node >= 22.5**。
```bash ```bash
pnpm install pnpm install
cp .env.example .env # 见下方说明
pnpm db:migrate # 建立 ./data/dnsmasq-admin.db 并建表
pnpm dev pnpm dev
``` ```
> 数据库从此由迁移管理:`db:migrate` 会创建 `./data/dnsmasq-admin.db` 并应用 `prisma/migrations/` 里的 SQL。首次 `install` 后不必单独 `db:generate` —— `dev` 和 `build` 都会先跑 `prisma generate`(生成物在 `lib/generated/`,不进 git)。
打开 <http://localhost:3000>,会被重定向到 `/records`。 打开 <http://localhost:3000>,会被重定向到 `/records`。
本地运行不需要配任何环境变量 —— 数据库默认落在 `./data/dnsmasq-admin.db`,生成的配置默认落在 `./data/dnsmasq-admin.conf`。需要调整时复制 `.env.example` 为 `.env.local`。 数据库连接串默认落在 `DATABASE_URL=file:./data/dnsmasq-admin.db`,生成的配置默认落在 `./data/dnsmasq-admin.conf`。需要调整时复制 `.env.example` 为 **`.env`**。
> ⚠️ 必须是 `.env` 而不是 `.env.local`:Next 两者都读,但 Prisma CLI 只读它自己的配置文件加载的东西,而 `prisma.config.ts` 里的 `dotenv/config` 指向 `.env`。放错地方会导致「应用连的是这个库、迁移跑的是另一个库」。
在 **Windows / macOS 桌面上开发**时没有 `/var/run/docker.sock`,点「应用并重启」会提示「重启已跳过」,但配置文件**照常生成**,可以用来自查内容是否正确。完整的重启链路需要在装了 Docker 的 Linux 上验证。 在 **Windows / macOS 桌面上开发**时没有 `/var/run/docker.sock`,点「应用并重启」会提示「重启已跳过」,但配置文件**照常生成**,可以用来自查内容是否正确。完整的重启链路需要在装了 Docker 的 Linux 上验证。
@@ -61,12 +67,44 @@ volumes:
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `DNSMASQ_ADMIN_DB_PATH` | `./data/dnsmasq-admin.db` | SQLite 数据库位置 | | `DATABASE_URL` | `file:./data/dnsmasq-admin.db` | Prisma 连接串;相对路径按**项目根目录**解析(不是 `prisma/`) |
| `DNSMASQ_ADMIN_CONF_PATH` | `./data/dnsmasq-admin.conf` | 生成的托管配置文件 | | `DNSMASQ_ADMIN_CONF_PATH` | `./data/dnsmasq-admin.conf` | 生成的托管配置文件 |
| `DNSMASQ_CONTAINER_NAME` | `dnsmasq` | 应用后要重启的容器名 | | `DNSMASQ_CONTAINER_NAME` | `dnsmasq` | 应用后要重启的容器名 |
| `DNSMASQ_ADMIN_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket 路径;不存在则跳过重启 | | `DNSMASQ_ADMIN_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket 路径;不存在则跳过重启 |
| `DNSMASQ_SERVER_IP` | `127.0.0.1` | `/resolve` 页查询用的 DNS 服务器 | | `DNSMASQ_SERVER_IP` | `127.0.0.1` | `/resolve` 页查询用的 DNS 服务器 |
> ⚠️ **破坏性变更**:旧版的 `DNSMASQ_ADMIN_DB_PATH` 已被 `DATABASE_URL` 取代(Prisma 要求 URL 形式,不再接受裸路径)。升级时把 Docker Compose / `.env` 里的这个变量改掉即可,数据库文件本身**不需要**动。
## 数据库迁移
改表结构的标准流程:
```bash
# 1. 改 prisma/schema.prisma
# 2. 生成一份迁移 SQL(会让你起名字),到 prisma/migrations/ 下
pnpm prisma migrate dev --create-only
# 3. 检查生成的 SQL,然后应用
pnpm db:migrate
```
Docker 部署下不需要手动跑第 3 步:容器启动时 `docker-entrypoint.sh` 会先执行 `prisma migrate deploy` 再起服务,所以**换镜像 = 自动升级表结构**。
可以用 `pnpm prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --script` 自检;输出只有 `-- This is an empty migration.` 才算干净。
`0001_baseline` 保持和 Prisma 自动生成的 SQL **逐字节一致**,这是有代价的:它放弃了 v0.1.0 手写表里的两条约束。
| 原本由数据库保证 | 现在由哪里保证 |
| --- | --- |
| `domain ... COLLATE NOCASE UNIQUE`(忽略大小写去重) | `lib/validate.ts` 的 `normalizeDomain()` 在入库前把域名转成小写 |
| `COLLATE NOCASE` 带来的大小写不敏感排序 | `lib/records.ts` 里的 `byDomain`,用 `localeCompare` 排序 |
| `CHECK (type IN ('A','CNAME'))` | `lib/validate.ts` 的表单校验 + `lib/db.ts` 里的 `RecordType` 联合类型 |
为什么甘愿降级:手写 SQL 加约束会让 Prisma 认为 schema 与迁移长期不一致(drift),之后任何人跑一次 `migrate dev`,它会提议一条「DROP TABLE + 重建」的迁移,并且在重建中悄悄丢掉那些约束 —— 这比失去约束本身更危险。现在 `migrate dev` 是安全的。你可以在用过 v0.1.0 的旧库上验证:它们的列级 `COLLATE NOCASE` 仍然存在。
`0003_enable_wal` 也是手写的:把 `journal_mode` 设成 WAL。它是数据库文件的持久属性,设一次就够 —— better-sqlite3 默认是 `delete` 模式,而 adapter 没有开放 PRAGMA 的入口。它不在 diff 的比对范围内,所以不会造成 drift。
`0002_normalise_timestamps` 修正了一个真实的时区 bug:旧表的时间戳默认值是 `datetime('now')`,存出来形如 `2026-10-09 08:55:10`,JavaScript 会把它当作**本地时间**解析,在 UTC+8 的机器上偏差 8 小时。这条迁移把历史行改成 Prisma 写入时的格式(`2026-10-09T08:55:10.000+00:00`),带明确时区偏移,两边都不会误解。
## ⚠️ 安全提示 ## ⚠️ 安全提示
**这个应用当前没有任何鉴权** —— 任何能访问到它的人都能增删 DNS 记录并重启你的 dnsmasq 容器。请务必只监听内网或本机,**不要暴露到公网**。 **这个应用当前没有任何鉴权** —— 任何能访问到它的人都能增删 DNS 记录并重启你的 dnsmasq 容器。请务必只监听内网或本机,**不要暴露到公网**。
@@ -77,6 +115,8 @@ volumes:
本项目启用了 `cacheComponents: true`,即 Next 16 的 Cache Components / PPR 模式。 本项目启用了 `cacheComponents: true`,即 Next 16 的 Cache Components / PPR 模式。
这里踩到一个关键点:`node:sqlite` 是**同步**驱动,如果不显式处理,它的查询结果会在**构建期**被烤进预渲染的静态 shell,上线后页面永远显示构建时的旧数据。官方 issue 文档明确提到了这一点(`node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md`),解法是在读库前 `await io()`,并把数据组件包在 `<Suspense>` 里 —— 参见 `lib/records.ts`。 这里踩到一个关键点:读库必须显式告诉 Next「这之后是 IO」,否则查询结果会在**构建期**被烤进预渲染的静态 shell,上线后页面永远显示构建时的旧数据。解法是在读库前 `await io()`,并把数据组件包在 `<Suspense>` 里 —— 参见 `lib/records.ts`。
同理,时间戳一律用 SQL 的 `datetime('now')` 而非 JS 的 `new Date()`,后者在预渲染阶段会直接导致构建失败。 注意这个 `await io()` **不能**因为「Prisma 是异步驱动」就删掉。官方文档里「awaited async database query 本身是 suspending point」的说法针对的是 `fetch` 这类 Next 认识的动态源;better-sqlite3 这种自建异步 driver 对 Next 是透明的,构建时它会照常解析、照常被烤进静态产物。实测办法:`grep proto.quanta.dev .next/server/app/records.html` —— 去掉守卫时能命中,加回来后为 0,`/records` 也从 `○ Static` 变回 `◐ Partial Prerender`。
同理,时间戳一律由数据库侧生成(`schema.prisma` 里的 `@default(now())` / `@updatedAt`),而不是 JS 的 `new Date()`,后者在预渲染阶段会直接导致构建失败。
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/records")
}
+16 -2
View File
@@ -1,8 +1,9 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { formatDateTime } from "@/lib/datetime"
import { getRecord } from "@/lib/records" import { getRecord } from "@/lib/records"
import { RecordForm } from "../../_components/RecordForm" import { RecordForm } from "../_components/RecordForm"
import { updateRecordAction } from "../../actions" import { updateRecordAction } from "../actions"
export const metadata = { title: "编辑记录" } export const metadata = { title: "编辑记录" }
@@ -31,11 +32,24 @@ async function EditRecordForm({ params }: Pick<PageProps<"/records/[id]">, "para
if (!record) notFound() if (!record) notFound()
return ( return (
<>
<dl className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-zinc-500 dark:text-zinc-400">
<div className="flex gap-1">
<dt>创建于</dt>
<dd className="text-zinc-700 dark:text-zinc-300">{formatDateTime(record.createdAt)}</dd>
</div>
<div className="flex gap-1">
<dt>更新于</dt>
<dd className="text-zinc-700 dark:text-zinc-300">{formatDateTime(record.updatedAt)}</dd>
</div>
</dl>
<RecordForm <RecordForm
action={updateRecordAction} action={updateRecordAction}
record={record} record={record}
submitLabel="保存修改" submitLabel="保存修改"
cancelHref="/records" cancelHref="/records"
/> />
</>
) )
} }
+4 -4
View File
@@ -38,7 +38,7 @@ export async function createRecordAction(
if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors } if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
try { try {
createRecord(parsed.input) await createRecord(parsed.input)
} catch (error) { } catch (error) {
const duplicate = asDuplicateState(error) const duplicate = asDuplicateState(error)
if (duplicate) return duplicate if (duplicate) return duplicate
@@ -60,7 +60,7 @@ export async function updateRecordAction(
if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors } if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
try { try {
updateRecord(id, parsed.input) await updateRecord(id, parsed.input)
} catch (error) { } catch (error) {
const duplicate = asDuplicateState(error) const duplicate = asDuplicateState(error)
if (duplicate) return duplicate if (duplicate) return duplicate
@@ -74,7 +74,7 @@ export async function updateRecordAction(
export async function deleteRecordAction(formData: FormData): Promise<void> { export async function deleteRecordAction(formData: FormData): Promise<void> {
const id = readId(formData) const id = readId(formData)
if (id === null) return if (id === null) return
removeRecord(id) await removeRecord(id)
revalidatePath(RECORDS_PATH) revalidatePath(RECORDS_PATH)
} }
@@ -82,7 +82,7 @@ export async function toggleRecordAction(formData: FormData): Promise<void> {
const id = readId(formData) const id = readId(formData)
if (id === null) return if (id === null) return
// The hidden field carries the state this record should move *to*. // The hidden field carries the state this record should move *to*.
setRecordEnabled(id, formData.get("enabled") === "1") await setRecordEnabled(id, formData.get("enabled") === "1")
revalidatePath(RECORDS_PATH) revalidatePath(RECORDS_PATH)
} }
+4 -1
View File
@@ -17,7 +17,10 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
DNSMASQ_ADMIN_DB_PATH: /data/dnsmasq-admin.db # Must be an absolute path inside the container now: Prisma resolves relative sqlite
# paths against the working directory, and the Prisma CLI does not inherit Next's
# path handling.
DATABASE_URL: file:/data/dnsmasq-admin.db
# Same mounted config directory dnsmasq reads from. # Same mounted config directory dnsmasq reads from.
DNSMASQ_ADMIN_CONF_PATH: /etc/dnsmasq.d/dnsmasq-admin.conf DNSMASQ_ADMIN_CONF_PATH: /etc/dnsmasq.d/dnsmasq-admin.conf
DNSMASQ_CONTAINER_NAME: dnsmasq DNSMASQ_CONTAINER_NAME: dnsmasq
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Apply any pending schema migrations before serving traffic.
#
# The database lives in a volume that outlives the container, so an image upgrade ships new
# migrations without them having been applied yet. Running them here makes deploying a new
# version a plain restart. `migrate deploy` is repeatable and never resets data.
set -e
# Reaches for the local binary rather than corepack/pnpm: this must succeed even when the
# container has no outbound network.
echo "applying database migrations…"
node_modules/.bin/prisma migrate deploy
exec "$@"
+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 { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"
import path from "node:path" import { PrismaClient } from "./generated/prisma/client"
import { DatabaseSync } from "node:sqlite" import type { DnsRecordModel } from "./generated/prisma/models"
import { env } from "./env" import { env } from "./env"
export type RecordType = "A" | "CNAME" export type RecordType = "A" | "CNAME"
export interface DnsRecord { /**
id: number * The row Prisma hands back, with `type` narrowed to the union this app actually allows.
domain: string *
* 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 type: RecordType
value: string
enabled: boolean
comment: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
const CREATE_TABLE = ` /** Narrow a database string into RecordType, defaulting to `A` like the old mapper did. */
CREATE TABLE IF NOT EXISTS records ( export function toRecordType(value: string): RecordType {
id INTEGER PRIMARY KEY AUTOINCREMENT, return value === "CNAME" ? "CNAME" : "A"
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
} }
// Cached on globalThis so Next.js dev HMR does not leak a new handle per reload. /**
const globalForDb = globalThis as typeof globalThis & { * Timestamps travel through the app as ISO strings rather than `Date`.
__dnsmasqAdminDb?: DatabaseSync *
* 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 { export function toDnsRecord(row: DnsRecordModel): DnsRecord {
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 {
return { return {
id: Number(row.id), id: row.id,
domain: String(row.domain), domain: row.domain,
type: row.type === "CNAME" ? "CNAME" : "A", type: toRecordType(row.type),
value: String(row.value), value: row.value,
enabled: Number(row.enabled) === 1, enabled: row.enabled,
comment: String(row.comment ?? ""), comment: row.comment,
createdAt: String(row.created_at), createdAt: toTimestamp(row.createdAt),
updatedAt: String(row.updated_at), updatedAt: toTimestamp(row.updatedAt),
} }
} }
export function queryRows(sql: string, ...params: unknown[]): Row[] { function createClient(): PrismaClient {
return rowsOf(getDb().prepare(sql).all(...params)) // 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 { // Cached on globalThis so Next.js dev HMR does not open a new handle per reload; see the
const row = getDb().prepare(sql).get(...params) // journal_mode migration note below for why one long-lived handle matters here.
return row && typeof row === "object" ? (row as Row) : null const globalForDb = globalThis as typeof globalThis & {
__dnsmasqAdminPrisma?: PrismaClient
} }
export function execute(sql: string, ...params: unknown[]): void { export function getDb(): PrismaClient {
getDb().prepare(sql).run(...params) 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. * boot locally without any env file; see `.env.example` for the deploy-time values.
*/ */
export const env = { 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. */ /** Managed dnsmasq config file that this app regenerates on every apply. */
confPath: toAbsolute(withDefault("DNSMASQ_ADMIN_CONF_PATH", "./data/dnsmasq-admin.conf")), confPath: toAbsolute(withDefault("DNSMASQ_ADMIN_CONF_PATH", "./data/dnsmasq-admin.conf")),
/** Container that gets restarted through the Docker socket after an apply. */ /** 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 { 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" import type { RecordInput } from "./validate"
const SELECT_ALL = "SELECT * FROM records"
export class DuplicateDomainError extends Error { export class DuplicateDomainError extends Error {
constructor(readonly domain: string) { constructor(readonly domain: string) {
super(`domain already exists: ${domain}`) super(`domain already exists: ${domain}`)
@@ -12,6 +11,10 @@ export class DuplicateDomainError extends Error {
} }
function isUniqueViolation(error: unknown): boolean { 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 } const { code, message } = (error ?? {}) as { code?: string; message?: string }
return ( return (
code === "SQLITE_CONSTRAINT_UNIQUE" || 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 * `domain` no longer carries COLLATE NOCASE (see prisma/migrations/0001_baseline), so SQLite
* otherwise be captured into the prerendered static shell at build time and the page would * would order by binary collation and put "Zoo.local" before "apple.local". Sorting here also
* show stale rows forever. `io()` suspends during prerender so the read happens at request * keeps results identical between databases created before that change and after it.
* time. See node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md. */
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[]> { export async function listRecords(): Promise<DnsRecord[]> {
await io() 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> { export async function getRecord(id: number): Promise<DnsRecord | null> {
await io() await io()
const row = queryRow(`${SELECT_ALL} WHERE id = ?`, id) const row = await getDb().dnsRecord.findUnique({ where: { id } })
return row ? rowToRecord(row) : null return row ? toDnsRecord(row) : null
} }
export async function listEnabledRecords(): Promise<DnsRecord[]> { export async function listEnabledRecords(): Promise<DnsRecord[]> {
await io() 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, // Writes below run inside Server Actions, which always execute at request time.
// so they do not need an `io()` guard. //
// 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 { try {
execute( await getDb().dnsRecord.create({
"INSERT INTO records (domain, type, value, enabled, comment) VALUES (?, ?, ?, ?, ?)", data: {
input.domain, domain: input.domain,
input.type, type: input.type,
input.value, value: input.value,
input.enabled ? 1 : 0, enabled: input.enabled,
input.comment comment: input.comment,
) },
})
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain) if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
throw error throw error
} }
} }
export function updateRecord(id: number, input: RecordInput): void { export async function updateRecord(id: number, input: RecordInput): Promise<void> {
try { try {
execute( await getDb().dnsRecord.updateMany({
`UPDATE records where: { id },
SET domain = ?, type = ?, value = ?, enabled = ?, comment = ?, updated_at = datetime('now') data: {
WHERE id = ?`, domain: input.domain,
input.domain, type: input.type,
input.type, value: input.value,
input.value, enabled: input.enabled,
input.enabled ? 1 : 0, comment: input.comment,
input.comment, },
id })
)
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain) if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
throw error throw error
} }
} }
export function removeRecord(id: number): void { export async function removeRecord(id: number): Promise<void> {
execute("DELETE FROM records WHERE id = ?", id) await getDb().dnsRecord.deleteMany({ where: { id } })
} }
export function setRecordEnabled(id: number, enabled: boolean): void { export async function setRecordEnabled(id: number, enabled: boolean): Promise<void> {
execute("UPDATE records SET enabled = ?, updated_at = datetime('now') WHERE id = ?", enabled ? 1 : 0, id) await getDb().dnsRecord.updateMany({ where: { id }, data: { enabled } })
} }
+7
View File
@@ -2,6 +2,13 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
// The root route only ever forwards to /records, so it is handled at the routing layer
// instead of by a page component that calls redirect() during render. Rendering first used
// to make Next try to validate the redirect target during prerender and report a stray
// NEXT_REDIRECT error, even though the 307 itself was served correctly.
async redirects() {
return [{ source: "/", destination: "/records", permanent: false }]
},
experimental: { experimental: {
agentFeedback: true, agentFeedback: true,
}, },
+10 -3
View File
@@ -3,12 +3,17 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "prisma generate && next dev",
"build": "next build", "build": "prisma generate && next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "7.10.0",
"@prisma/client": "^7.10.0",
"dayjs": "^1.11.23",
"next": "16.4.0", "next": "16.4.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0" "react-dom": "19.3.0"
@@ -18,9 +23,11 @@
"@types/node": "24", "@types/node": "24",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^18.0.6",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.4.0", "eslint-config-next": "16.4.0",
"prettier": "^3.9.9", "prettier": "^3.9.9",
"prisma": "7.10.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
}, },
+1327
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -1,3 +1,14 @@
allowBuilds: allowBuilds:
# Prisma's postinstall downloads the query engine binaries and generates the client.
# Without these two entries pnpm refuses to add packages (ERR_PNPM_IGNORED_BUILDS).
'@prisma/engines': true
prisma: true
# SQLite driver: prisma ships its own binaries, but better-sqlite3 resolved to v12 requires postinstall(prebuild-install)
# to fetch platform binary.
better-sqlite3: true
# esbuild/workerd ship their platform binaries as optionalDependencies, so there is no
# install script that needs running. Declared explicitly so pnpm stops asking.
esbuild: false
workerd: false
sharp: false sharp: false
unrs-resolver: false unrs-resolver: false
+15
View File
@@ -0,0 +1,15 @@
// Prisma 7 moved the connection URL out of schema.prisma into this file, and no longer
// auto-loads .env — hence the explicit dotenv import below. See prisma/schema.prisma for the
// datamodel and prisma/migrations/ for the SQL actually applied to the database.
import "dotenv/config"
import { defineConfig } from "prisma/config"
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
})
@@ -0,0 +1,31 @@
-- Baseline: the `records` table as Prisma itself generates it from prisma/schema.prisma.
--
-- This SQL is kept byte-identical to the output of
-- `prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script`
-- on purpose. The alternative — adding `COLLATE NOCASE` on `domain` and a
-- `CHECK (type IN ('A','CNAME'))` by hand — makes Prisma see permanent drift and propose a
-- DROP TABLE + rebuild migration the next time anyone runs `migrate dev`, which would silently
-- drop those very constraints. A clean diff is worth more here than the extra constraints,
-- because both properties are enforced elsewhere:
--
-- - case-insensitive uniqueness: lib/validate.ts lowercases every domain before it reaches
-- the database, so `FOO.com` and `foo.com` can never both be inserted;
-- - case-insensitive ordering: lib/records.ts sorts with localeCompare after fetching,
-- since SQLite would otherwise order by binary collation ('Zoo' before 'apple');
-- - the type whitelist: again lib/validate.ts, plus the generated union in lib/db.ts.
--
-- Existing databases installed before this migration already ran the earlier hand-written
-- version and keep their column-level COLLATE NOCASE; nothing about them changes.
CREATE TABLE "records" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"domain" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"comment" TEXT NOT NULL DEFAULT '',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "records_domain_key" ON "records"("domain");
@@ -0,0 +1,18 @@
-- Rewrite timestamps written by the v0.1.0 schema into the format Prisma expects.
--
-- Background: the old column default was `datetime('now')`, producing values like
-- `2026-10-09 08:55:10`. SQLite stores that UTC, but JavaScript parses the space-separated
-- form as *local* time — on a UTC+8 machine `new Date('2026-10-09 08:55:10')` yields
-- 00:55:10Z, silently off by eight hours. Prisma 7's better-sqlite3 adapter writes
-- `2026-10-09T08:55:10.000+00:00` instead, which is unambiguous.
--
-- Leaving old rows in the legacy format would give one column two different parse rules
-- depending on when the row was created, so every existing row is normalised here.
--
-- Idempotent: the WHERE clause skips anything already in ISO shape, so this is a no-op on
-- freshly migrated databases and safe to re-run.
UPDATE "records"
SET
"created_at" = strftime('%Y-%m-%dT%H:%M:%S.000+00:00', "created_at"),
"updated_at" = strftime('%Y-%m-%dT%H:%M:%S.000+00:00', "updated_at")
WHERE "created_at" NOT LIKE '____-__-__T%';
@@ -0,0 +1,11 @@
-- Set journal_mode to WAL, exactly as the pre-Prisma code did on every connection open.
--
-- WAL lets readers proceed while a write is in flight, which matters here: the SQLite file is
-- frequently opened by something else at the same time — the sqlite3 CLI, or a database
-- console in an IDE — and the UI answers from it on every page load.
--
-- Unlike busy_timeout, this cannot be set from the app: the better-sqlite3 adapter takes only
-- a connection URL and driver options, with no hook for PRAGMAs. It is safe to do here
-- because `PRAGMA journal_mode` is a persistent property of the database file, so it only has
-- to be set once rather than on every connection. A no-op on databases already in WAL.
PRAGMA journal_mode = WAL;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+37
View File
@@ -0,0 +1,37 @@
// dnsmasq-admin — single-table schema mapping the `records` table that shipped with v0.1.0,
// so existing databases keep working untouched.
//
// Keep this file as the single source of truth for the table shape, and keep migrations
// byte-identical to what `prisma migrate diff` produces from it. Hand-editing a migration to
// add something this file cannot express — such as column-level COLLATE NOCASE on `domain`, or
// a CHECK constraint on `type` — leaves Prisma reporting permanent drift, and the next
// `migrate dev` then proposes a DROP TABLE + rebuild migration that silently drops it. Those
// two safeguards therefore live in application code; see
// prisma/migrations/0001_baseline/migration.sql for the details.
//
// The `type` field is a plain String because SQLite has no enum support; the union is enforced
// by lib/validate.ts and by the `RecordType` type in lib/db.ts.
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "sqlite"
}
/// Named `DnsRecord` (table `records`) rather than `Record` to avoid colliding with
/// TypeScript's built-in `Record<K, V>` utility type.
model DnsRecord {
id Int @id @default(autoincrement())
domain String @unique
type String
value String
enabled Boolean @default(true)
comment String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("records")
}