Files
make_video_site/src/db.ts
Claude 3560dcb802 feat(db): introduce SQLite (better-sqlite3) backing store
First step of the folder/video refactor. Adds an app-local SQLite DB at
data/app.db with schema for nested folders (max 2 levels enforced in
code, not SQL) and user-editable globally-unique video IDs.

- src/db.ts: open + WAL + schema (folders, videos) + partial unique
  index for top-level folder names (NULL parent_id case)
- One-shot disk-tree migration: on first start scans data/folders/*
  and seeds folder rows; reads each {videoId}/meta.json to seed video
  rows. Skips invalid/missing meta gracefully.
- Wired into app.ts startup before routes mount.
- gitignore: data/app.db* (DB + WAL/journal sidecars).

Schema only — no routes or store.ts changes yet. Next step swaps the
file-based store calls to DB-backed equivalents.

Smoke-tested on the current data dir: 3 folders migrated, 0 videos
(folders were empty). Build (npx tsc) passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:18:51 +09:00

194 lines
7.0 KiB
TypeScript

/**
* SQLite (better-sqlite3) 기반 메타데이터 저장소.
*
* 디스크 레이아웃은 기존 그대로 유지한다:
* data/folders/{topName}/[{subName}/]{videoId}/{original|edited.*}
*
* DB 는 그 트리의 source of truth 인덱스 역할. 동기화 규칙:
* - 폴더/영상 생성 시 mkdir 과 INSERT 를 같이 한다.
* - 폴더/영상 rename 시 fs.rename 과 UPDATE 를 같이 한다.
* - 폴더/영상 삭제 시 rm -rf 와 DELETE 를 같이 한다.
* - 디스크와 DB 가 어긋난 경우 (수동 손상 등) initDatabase() 마이그레이션이
* 디스크 트리를 스캔해 DB 를 다시 채운다.
*
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
*
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다.
*/
import Database from 'better-sqlite3'
import { promises as fsp } from 'node:fs'
import path from 'node:path'
import { dataDir, dbPath, foldersDir } from './paths.js'
let _db: Database.Database | null = null
export function getDb(): Database.Database {
if (!_db) throw new Error('DB 가 아직 초기화되지 않았습니다. initDatabase() 를 먼저 호출하세요.')
return _db
}
/**
* DB 파일을 열고 스키마를 보장한 뒤, 비어 있으면 기존 디스크 구조를 스캔해 채워넣는다.
* 앱 시작 시 1회 호출.
*/
export async function initDatabase(): Promise<void> {
await fsp.mkdir(dataDir, { recursive: true })
await fsp.mkdir(foldersDir, { recursive: true })
const db = new Database(dbPath)
// 동시 쓰기는 거의 없고 안정성/성능 균형용.
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.pragma('synchronous = NORMAL')
createSchema(db)
_db = db
// 비어 있으면 디스크 스캔 → 마이그레이션.
const folderCount = db.prepare('SELECT COUNT(*) as c FROM folders').get() as { c: number }
if (folderCount.c === 0) {
await migrateFromDisk(db)
}
}
function createSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NULL REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(parent_id, name)
);
CREATE INDEX IF NOT EXISTS folders_parent_idx ON folders(parent_id);
-- SQLite 의 UNIQUE 는 NULL 끼리 distinct 로 본다. 최상위(parent_id IS NULL) 동명 폴더를
-- 막으려면 partial unique index 가 별도로 필요.
CREATE UNIQUE INDEX IF NOT EXISTS folders_unique_top
ON folders(name) WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
title TEXT NOT NULL,
original_file TEXT NOT NULL,
edited_file TEXT,
duration_sec REAL,
source_type TEXT NOT NULL CHECK (source_type IN ('upload', 'youtube')),
source_url TEXT,
trim_start_sec REAL,
trim_end_sec REAL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
`)
}
/**
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.
*/
async function migrateFromDisk(db: Database.Database): Promise<void> {
let topEntries: string[]
try {
topEntries = await fsp.readdir(foldersDir)
} catch {
return
}
const now = new Date().toISOString()
const insertFolder = db.prepare(
`INSERT INTO folders (parent_id, name, position, created_at, updated_at)
VALUES (NULL, ?, ?, ?, ?)`
)
const insertVideo = db.prepare(
`INSERT INTO videos
(id, folder_id, title, original_file, edited_file, duration_sec,
source_type, source_url, trim_start_sec, trim_end_sec,
position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
let folderPos = 0
let imported = { folders: 0, videos: 0 }
for (const topName of topEntries.sort()) {
const topPath = path.join(foldersDir, topName)
const stat = await fsp.stat(topPath).catch(() => null)
if (!stat || !stat.isDirectory()) continue
if (!isSafeFolderName(topName)) continue
const info = insertFolder.run(topName, folderPos++, now, now)
const folderId = Number(info.lastInsertRowid)
imported.folders++
const videoEntries = await fsp.readdir(topPath).catch(() => [])
let videoPos = 0
for (const videoId of videoEntries.sort()) {
const vPath = path.join(topPath, videoId)
const vStat = await fsp.stat(vPath).catch(() => null)
if (!vStat || !vStat.isDirectory()) continue
if (!isSafeVideoId(videoId)) continue
const metaPath = path.join(vPath, 'meta.json')
const meta = await readJsonSafe(metaPath)
if (!meta) continue
const sourceType =
meta.sourceType === 'upload' || meta.sourceType === 'youtube'
? meta.sourceType
: 'upload'
try {
insertVideo.run(
videoId,
folderId,
typeof meta.title === 'string' ? meta.title : '제목 없음',
typeof meta.originalFile === 'string' ? meta.originalFile : 'original',
typeof meta.editedFile === 'string' ? meta.editedFile : null,
typeof meta.durationSec === 'number' ? meta.durationSec : null,
sourceType,
typeof meta.sourceUrl === 'string' ? meta.sourceUrl : null,
meta.trim && typeof meta.trim.startSec === 'number' ? meta.trim.startSec : null,
meta.trim && typeof meta.trim.endSec === 'number' ? meta.trim.endSec : null,
videoPos++,
typeof meta.createdAt === 'string' ? meta.createdAt : now,
typeof meta.updatedAt === 'string' ? meta.updatedAt : now
)
imported.videos++
} catch (err) {
console.warn(`[db] migrate video skip (${topName}/${videoId}):`, (err as Error).message)
}
}
}
if (imported.folders > 0 || imported.videos > 0) {
console.log(`[db] 마이그레이션 완료: 폴더 ${imported.folders}, 영상 ${imported.videos}`)
}
}
// ─── 공통 검증 / 유틸 (store.ts 와 중복되지만 의존 방향을 끊기 위해 여기 다시 둠) ──
const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
function isSafeFolderName(name: string): boolean {
if (!name || name.length > 80) return false
if (name === '.' || name === '..') return false
return SAFE_NAME.test(name)
}
function isSafeVideoId(id: string): boolean {
return /^[a-zA-Z0-9_-]{1,64}$/.test(id)
}
async function readJsonSafe(p: string): Promise<Record<string, any> | null> {
try {
const raw = await fsp.readFile(p, 'utf8')
const v = JSON.parse(raw)
return v && typeof v === 'object' ? v : null
} catch {
return null
}
}