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>
This commit is contained in:
@@ -6,6 +6,7 @@ import fsp from 'node:fs/promises'
|
||||
import { dataDir, foldersDir, jobsDir, tmpDir, publicDir, viewsDir } from './paths.js'
|
||||
import { publicRouter } from './routes/public.js'
|
||||
import { opRouter } from './routes/op.js'
|
||||
import { initDatabase } from './db.js'
|
||||
|
||||
async function ensureDirs(): Promise<void> {
|
||||
for (const dir of [dataDir, foldersDir, jobsDir, tmpDir]) {
|
||||
@@ -15,6 +16,8 @@ async function ensureDirs(): Promise<void> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensureDirs()
|
||||
// SQLite 스키마 보장 + 비어 있으면 기존 디스크 트리에서 마이그레이션.
|
||||
await initDatabase()
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000)
|
||||
const HOST = process.env.HOST ?? '127.0.0.1'
|
||||
|
||||
193
src/db.ts
Normal file
193
src/db.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const dataDir = path.join(projectRoot, 'data')
|
||||
export const foldersDir = path.join(dataDir, 'folders')
|
||||
export const jobsDir = path.join(dataDir, 'jobs')
|
||||
export const tmpDir = path.join(dataDir, 'tmp')
|
||||
export const dbPath = path.join(dataDir, 'app.db')
|
||||
|
||||
export const viewsDir = path.join(projectRoot, 'views')
|
||||
export const publicDir = path.join(projectRoot, 'public')
|
||||
|
||||
Reference in New Issue
Block a user