PDFを保存できるようになったら、次に必要なのは「何が保存されているかを一覧で見る」機能です。フォルダの中身を並べるだけでも一覧は作れますが、業務システムとして検索や権限管理まで見据えるなら、ファイルの情報はデータベース(DB)に持たせるのが基本です。
前回の第1回:PDFをアップロードして安全に保存するでは、アップロードされたファイルをサーバー側で検証し、UUIDのファイル名で保存しました。今回は、そのファイルの情報(元のファイル名・サイズ・ページ数・登録日時など)を SQLite に保存し、一覧画面を作ります。
「保存フォルダの中身をそのまま一覧に出せばいいのでは?なぜわざわざデータベースが必要なの?」
結論から言うと、PDF本体は「ストレージ」、ファイルの情報は「DB」と分けて持つのが、検索・権限・版管理を後から足せる作り方です。保存名はUUIDなので、フォルダを見ても元のファイル名は分かりません。この回では、Nuxt 4 のサーバーAPIから Drizzle ORM と SQLite を使ってファイル情報を登録・取得し、アップロード後に一覧へ表示されるところまでを実装します。
PDFのファイル情報をDBで管理する理由
本体とファイル情報を分けると何がうれしいか
ファイル管理の仕組みを「本棚(ストレージ)」と「貸出台帳(DB)」に例えると分かりやすくなります。
| 置き場所 | 持つもの | 得意なこと |
|---|---|---|
ストレージ(今は uploads/、本番はS3) | PDF本体(バイナリ) | 大きなデータを安く・確実に置く |
| DB(SQLite) | 元のファイル名、サイズ、ページ数、登録者、登録日時など | 並べ替え・絞り込み・件数の集計、権限の判定 |
一覧や検索のたびにPDFを開いてページ数を数えていたら、ファイルが増えるほど遅くなります。登録時に一度だけ調べてDBに書いておけば、一覧は表を読むだけで済みます。次回の検索・絞り込み、第4回以降のログイン・権限、第7回の「版」の管理も、すべてこのDBの上に積み上げます。
SQLite と Drizzle ORM を選んだ理由
- SQLite:DBサーバーを別に立てず、1つのファイル(今回は
data/app.db)にデータを保存できるDBです。社内の小〜中規模の利用なら十分に動き、開発環境の準備も簡単です。Node.js から使うドライバは better-sqlite3(執筆時点で 13 系)を使います。 - Drizzle ORM:ORM(=SQLを直接書かずに、プログラムの関数でDBを操作できる仕組み)の1つです。テーブルの定義を TypeScript で書くと、取得結果にも型が付くため、列名の打ち間違いなどをビルド前に気付けます。
Drizzle の公式ドキュメントでは、執筆時点で 1.0 の候補版(RC)のインストールが案内されていますが、npm の標準(latest)で配布されているのは 0 系の安定版です。この連載では安定版を固定して使います。
📰 出典:Drizzle ORM 公式ドキュメント Get Started with SQLite
ファイル情報のテーブルを定義する
インストールするパッケージ
npm install --save-exact drizzle-orm better-sqlite3 pdf-lib
npm install -D --save-exact drizzle-kit @types/better-sqlite3
pdf-lib は、アップロード時にページ数を数えるために使います(第7回以降の編集機能でも使う予定のライブラリです)。サンプルでは実際に入ったバージョンを package.json に固定しています。筆者の環境(Node.js 24、WSL2 の Linux)では、better-sqlite3 は配布済みのビルド済みバイナリで動き、C++ のコンパイル環境は不要でした。
server/db/schema.ts:files テーブル
server/db/schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
/** アップロードされたPDFのメタデータ(PDF本体はストレージ側に保存) */
export const files = sqliteTable('files', {
/** UUID。URLやAPIで使うID */
id: text('id').primaryKey(),
/** 利用者がアップロードしたときの元のファイル名(表示・ダウンロード名用) */
originalName: text('original_name').notNull(),
/** ストレージ上の保存キー(UUID.pdf)。利用者には見せない */
storageKey: text('storage_key').notNull().unique(),
/** バイト数 */
size: integer('size').notNull(),
/** ページ数 */
pageCount: integer('page_count').notNull(),
/** 暗号化(パスワード付き)PDFかどうか。編集機能の可否判定に使う */
isEncrypted: integer('is_encrypted', { mode: 'boolean' }).notNull().default(false),
/** 登録者。ログイン機能(第4回)までは null */
uploadedBy: text('uploaded_by'),
/** 登録日時 */
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
})
export type FileRow = typeof files.$inferSelect
export type NewFileRow = typeof files.$inferInsert
ポイントは次の3つです。
- 元のファイル名と保存キーを別の列にする:画面に出すのは
originalName、ストレージの読み書きに使うのはstorageKeyです。 - 暗号化の有無を記録する:pdf-lib はパスワード付きPDFの編集に対応していないため、後の編集機能で「このファイルは編集できません」と判定できるようにしておきます。
- 登録者の列を先に用意する:ログイン機能は第4回で追加するので、今は空(null)のままにしておきます。
SQLite には日付型がないため、createdAt は mode: 'timestamp' で「数値として保存し、読み出すと Date になる」形にしています。この形式は秒単位で保存されるため、ミリ秒は切り捨てられます。
drizzle.config.ts とマイグレーション
テーブルの定義から、実際にDBにテーブルを作るSQL(マイグレーション=DBの構造変更の手順書)を生成するのが drizzle-kit です。
drizzle.config.ts
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { defineConfig } from 'drizzle-kit'
// drizzle-kit(マイグレーションの生成・適用)用の設定。アプリ本体は runtimeConfig.databasePath を使う
const databasePath = process.env.NUXT_DATABASE_PATH ?? './data/app.db'
// better-sqlite3 はフォルダまでは作らないため、先に作っておく
mkdirSync(dirname(databasePath), { recursive: true })
export default defineConfig({
dialect: 'sqlite',
schema: './server/db/schema.ts',
out: './server/db/migrations',
dbCredentials: {
url: databasePath,
},
})
package.json に次の2つのスクリプトを追加しました。
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
npm run db:generate を実行すると server/db/migrations/0000_init.sql が作られ、npm run db:migrate でDBに適用されます。生成されたSQLは Git にコミットし、どの環境でも同じ手順でテーブルを作れるようにします。なお、data/ フォルダ(DBファイル)は .gitignore に追加しています。
サーバーからDBを使う
server/utils/db.ts:接続を1つだけ作る
server/utils/db.ts
import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import Database from 'better-sqlite3'
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import * as schema from '../db/schema'
let db: BetterSQLite3Database<typeof schema> | undefined
/** Drizzle のDBインスタンスを返す(プロセス内で1つだけ作る) */
export function useDb(): BetterSQLite3Database<typeof schema> {
if (!db) {
const path = resolve(useRuntimeConfig().databasePath)
mkdirSync(dirname(path), { recursive: true })
const sqlite = new Database(path)
// 読み書きの同時実行に強い WAL モードにする
sqlite.pragma('journal_mode = WAL')
db = drizzle({ client: sqlite, schema })
}
return db
}
DBファイルの場所は nuxt.config.ts の runtimeConfig.databasePath(既定 ./data/app.db、環境変数 NUXT_DATABASE_PATH で上書き)から読みます。server/utils/ に置いたので、APIのファイルからは import なしで useDb() を呼べます。
server/utils/pdf/inspect.ts:ページ数を数える
server/utils/pdf/inspect.ts
import { PDFDocument } from 'pdf-lib'
export interface PdfInfo {
pageCount: number
isEncrypted: boolean
}
export async function inspectPdf(data: Uint8Array): Promise<PdfInfo> {
try {
// 暗号化PDFは既定では読み込みエラーになるため、情報取得だけの目的で ignoreEncryption を付ける
// updateMetadata: false … 読み込み時に Producer 等のメタデータを書き換えない
const doc = await PDFDocument.load(data, { ignoreEncryption: true, updateMetadata: false })
return { pageCount: doc.getPageCount(), isEncrypted: doc.isEncrypted }
} catch {
throw createError({ statusCode: 400, statusMessage: 'PDFとして読み込めませんでした(ファイルが壊れている可能性があります)' })
}
}
第1回の「先頭が %PDF- か」のチェックに加えて、pdf-lib で実際に読み込めるかを確認することになるため、先頭だけ正しい壊れたファイルもここで弾けます。
📰 出典:pdf-lib 公式ドキュメント PDFDocument
アップロードAPIにDB登録を追加する
第1回の server/api/files.post.ts に、ページ数の確認とDB登録を追加します(前半の検証部分は第1回と同じなので省略)。
server/api/files.post.ts(抜粋)
import { randomUUID } from 'node:crypto'
import { files } from '../db/schema'
export default defineEventHandler(async (event) => {
// …(1〜2. Content-Length の確認と multipart の読み取りは第1回と同じ)
// 3. サイズ・拡張子・MIME・先頭バイトを検証し、pdf-lib で読めるか(ページ数)を確認
assertPdfFile(file, maxUploadBytes)
const info = await inspectPdf(file.data)
// 4. 保存名はUUID。ユーザーが送ったファイル名はパスに使わない
const id = randomUUID()
const storageKey = `${id}.pdf`
const storage = useFileStorage()
await storage.put(storageKey, file.data, 'application/pdf')
// 5. メタデータをDBに登録。失敗したら保存済みの本体を消して不整合を残さない
const row = {
id,
originalName: sanitizeFileName(file.filename ?? ''),
storageKey,
size: file.data.length,
pageCount: info.pageCount,
isEncrypted: info.isEncrypted,
uploadedBy: null,
createdAt: new Date(),
}
try {
useDb().insert(files).values(row).run()
} catch (err) {
await storage.delete(storageKey)
throw err
}
setResponseStatus(event, 201)
return {
id: row.id,
originalName: row.originalName,
size: row.size,
pageCount: row.pageCount,
}
})
「ストレージに保存したのにDB登録に失敗した」場合、どこからも参照されないファイルがストレージに残ってしまいます。そのため、DB登録に失敗したら保存したファイルを消すようにしています。
server/api/files.get.ts:一覧API
server/api/files.get.ts
import { desc } from 'drizzle-orm'
import { files } from '../db/schema'
/** ファイル一覧(新しい順)。検索・ページングは第3回で追加する */
export default defineEventHandler(() => {
return useDb()
.select({
id: files.id,
originalName: files.originalName,
size: files.size,
pageCount: files.pageCount,
isEncrypted: files.isEncrypted,
uploadedBy: files.uploadedBy,
createdAt: files.createdAt,
})
.from(files)
.orderBy(desc(files.createdAt))
.limit(100)
.all()
})
返す列を明示しているのがポイントです。storageKey(保存先の内部的な名前)は画面に不要なので返しません。「必要な情報だけを返す」を最初から習慣にしておくと、情報の出しすぎを防げます。件数は仮に100件までにしており、次回ページングに置き換えます。
一覧画面を作る
app/pages/files/index.vue(スタイルは省略)
<script setup lang="ts">
useHead({ title: 'ファイル一覧 | PDF管理システム' })
// サーバーAPIの戻り値の型は Nuxt が推論する(createdAt は JSON で文字列になる)
const { data: rows, status, error, refresh } = await useFetch('/api/files')
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
})
function formatDate(value: string): string {
return dateFormatter.format(new Date(value))
}
</script>
<template>
<section>
<h1>ファイル一覧</h1>
<p>
<NuxtLink to="/files/upload">PDFをアップロード</NuxtLink>
/ <button type="button" @click="() => refresh()">再読み込み</button>
</p>
<p v-if="status === 'pending'">読み込み中…</p>
<p v-else-if="error">一覧を取得できませんでした。</p>
<p v-else-if="!rows || rows.length === 0">まだファイルがありません。</p>
<table v-else class="files">
<thead>
<tr>
<th>ファイル名</th>
<th>サイズ</th>
<th>ページ数</th>
<th>登録者</th>
<th>登録日時</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
{{ row.originalName }}
<small v-if="row.isEncrypted">(パスワード付き)</small>
</td>
<td class="num">{{ formatSize(row.size) }}</td>
<td class="num">{{ row.pageCount }}</td>
<td>{{ row.uploadedBy ?? '—' }}</td>
<td>{{ formatDate(row.createdAt) }}</td>
</tr>
</tbody>
</table>
</section>
</template>
useFetch は、ページを表示するときにサーバー側でAPIを呼び、結果をHTMLに埋め込んでから返してくれる Nuxt の関数です。/api/files のように自分のAPIを指定すると、戻り値の型も自動で推論されます。ただし、サーバーで Date だった createdAt は JSON を通るので文字列になっている点に注意してください(型もそのように推論されます)。日時の表示は、サーバーとブラウザで結果がずれないよう、タイムゾーンを Asia/Tokyo に固定しています。
ほかに、アップロード画面の結果表示にページ数と「ファイル一覧へ」のリンクを、共通レイアウトのナビに「ファイル一覧」を追加しています。
動作確認の方法
npm install
npm run db:migrate # data/app.db に files テーブルを作る
npm run build
node .output/server/index.mjs
curl -F "file=@sample.pdf;filename=見積書_2026.pdf" http://localhost:3000/api/files
curl http://localhost:3000/api/files
筆者の環境では次のとおり確認しました。
| 確認内容 | 結果 |
|---|---|
| 3ページのPDFを日本語のファイル名でアップロード | 201。pageCount: 3 が返り、一覧APIにも同じ内容が出る |
/files をサーバーで表示 | 元のファイル名・サイズ・ページ数・登録日時の表が出る |
| サーバーを再起動してから一覧を取得 | 同じデータが残っている(SQLiteに保存されている) |
先頭だけ %PDF- で中身が壊れたファイル | 400(PDFとして読み込めない) |
| パスワード付きPDF | 201。ページ数が取得でき、isEncrypted: true になる |
開発サーバー(npm run dev)での一覧API | 本番ビルドと同じ結果 |
ブラウザでの画面操作(ファイルを選んでアップロードし、一覧に移動する流れ)は筆者の環境では実施していません。お手元で確認してください。
つまずきやすい点
npm run db:migrateを忘れる:テーブルがないまま起動すると、一覧APIがエラーになります。新しい環境では最初に実行してください。- DBファイルとアップロード先をバックアップの対象にする:SQLiteはファイル1つですが、WALモードでは
app.db-walなどの付属ファイルもできます。運用中のバックアップは、ファイルのコピーではなくSQLiteのバックアップ機能を使うのが安全です(第11回で扱います)。 - drizzle-kit の脆弱性警告:執筆時点では、
npm auditで drizzle-kit が依存するパッケージについて「moderate」の警告が出ます。drizzle-kit は開発時にマイグレーションを作るための道具で、本番のサーバーには含まれませんが、更新状況は定期的に確認してください。 - 複数台のサーバーで動かす場合:SQLite は1台のサーバーで使う前提のDBです。サーバーを複数台に増やす構成なら、PostgreSQL などへの移行を検討します。
発注者向けメモ:「一覧に何を出すか」は早めに決める
一覧画面は「とりあえず作って後で直す」がしやすく見えますが、表示する項目によっては、DBに保存する情報そのものを増やす必要があります。後から列を増やすと、DBの構造変更や過去データの扱い(空欄のままにするか、さかのぼって埋めるか)が発生します。
- 一覧に出したい項目を現場に聞く:ファイル名、登録者、登録日時のほかに、取引先名・案件番号・書類の種類など、業務で探すときの手がかりを洗い出します。
- ファイル名のルールがあるか確認する:「日付_取引先_書類名.pdf」のような社内ルールがあれば、それを項目として分けて持つかを検討します。
- 件数の見込み:数千件と数十万件では、一覧の表示方法や検索の作りが変わります。
- パスワード付きPDFの扱い:保存はできても編集できない、という仕様でよいかを確認します。
打ち合わせでは、次のように聞いてみてください。
- 「一覧に表示する項目を後から追加する場合、どのくらいの作業になりますか?過去に登録したデータはどうなりますか?」
- 「データベースとPDF本体のバックアップは、それぞれどのように取る予定ですか?」
- 「利用者が増えたり、サーバーを増やしたりする場合、今のデータベースのままで対応できますか?」
まとめと次回予告
第2回では、PDFのファイル情報をDBに保存し、一覧を表示できるようにしました。
- PDF本体はストレージ、ファイル情報はDB(SQLite)に分けて持つ
- Drizzle ORM でテーブルを TypeScript で定義し、drizzle-kit でマイグレーションを生成・適用する
- アップロード時に pdf-lib でページ数と暗号化の有無を調べ、DBに登録する
- 一覧APIは必要な列だけを返し、
useFetchで画面に表示する
次回は「検索・絞り込み・ページングをつける」です。ファイル名のキーワード、タグ、期間での絞り込みと、ページ送りを実装します。検索条件は URL に残し、zod で入力を検証します。
この連載の記事一覧
この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Nuxtで作るPDF管理システム 第0回】全体像とNuxt 4プロジェクトの土台づくり
- 【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する
- 【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する(この記事)
- 【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける
- 【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる
- 【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」
- 【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする
- 【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割
- 【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る
- 【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する
- 【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する
- 【Nuxtで作るPDF管理システム 第11回】本番運用へ:S3への切り替えとデプロイ










コメント
コメント一覧 (1件)
[…] 前回の第2回:ファイル情報をDBに持ち、一覧を表示するでは、PDFのファイル情報を SQLite に保存し、一覧画面を作りました。今回はその一覧に、ファイル名のキーワード検索・タグ・登録日の期間による絞り込みと、20件ずつのページングを追加します。 […]