db 마이그레이션 기능 (#17)

db 마이그레이션 기능 추가

Reviewed-on: #17
This commit is contained in:
monoid 2025-06-26 21:21:45 +09:00
parent 25343a22c5
commit b57246f56d
15 changed files with 664 additions and 479 deletions

View file

@ -53,6 +53,12 @@ export const UserSchema = z.object({
export type User = z.infer<typeof UserSchema>; export type User = z.infer<typeof UserSchema>;
export const UserSettingSchema = z.object({
fileDeepLinkRegex: z.string().optional(),
});
export type UserSetting = z.infer<typeof UserSettingSchema>;
export const SchemaMigrationSchema = z.object({ export const SchemaMigrationSchema = z.object({
version: z.string().nullable(), version: z.string().nullable(),
dirty: z.boolean(), dirty: z.boolean(),

View file

@ -45,6 +45,11 @@ export interface Users {
username: string | null; username: string | null;
} }
export interface UserSettings {
username: string;
settings: string | null;
}
export interface DB { export interface DB {
doc_tag_relation: DocTagRelation; doc_tag_relation: DocTagRelation;
document: Document; document: Document;
@ -52,4 +57,5 @@ export interface DB {
schema_migration: SchemaMigration; schema_migration: SchemaMigration;
tags: Tags; tags: Tags;
users: Users; users: Users;
user_settings: UserSettings;
} }

View file

@ -1,50 +0,0 @@
// import { promises } from "fs";
// const { readdir, writeFile } = promises;
// import { dirname, join } from "path";
// import { createGenerator } from "ts-json-schema-generator";
// async function genSchema(path: string, typename: string) {
// const gen = createGenerator({
// path: path,
// type: typename,
// tsconfig: "tsconfig.json",
// });
// const schema = gen.createSchema(typename);
// if (schema.definitions != undefined) {
// const definitions = schema.definitions;
// const definition = definitions[typename];
// if (typeof definition == "object") {
// let property = definition.properties;
// if (property) {
// property["$schema"] = {
// type: "string",
// };
// }
// }
// }
// const text = JSON.stringify(schema);
// await writeFile(join(dirname(path), `${typename}.schema.json`), text);
// }
// function capitalize(s: string) {
// return s.charAt(0).toUpperCase() + s.slice(1);
// }
// async function setToALL(path: string) {
// console.log(`scan ${path}`);
// const direntry = await readdir(path, { withFileTypes: true });
// const works = direntry
// .filter((x) => x.isFile() && x.name.endsWith("Config.ts"))
// .map((x) => {
// const name = x.name;
// const m = /(.+)\.ts/.exec(name);
// if (m !== null) {
// const typename = m[1];
// return genSchema(join(path, typename), capitalize(typename));
// }
// });
// await Promise.all(works);
// const subdir = direntry.filter((x) => x.isDirectory()).map((x) => x.name);
// for (const x of subdir) {
// await setToALL(join(path, x));
// }
// }
// setToALL("src");

View file

@ -65,8 +65,8 @@ export async function up(db: Kysely<any>) {
await db await db
.insertInto('schema_migration') .insertInto('schema_migration')
.values({ .values({
version: '0.0.1', version: '2024-12-27',
dirty: false, dirty: 0,
}) })
.execute(); .execute();

View file

@ -0,0 +1,18 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>) {
await db.schema
.createTable("user_settings")
.addColumn("username", "varchar(256)", col => col.notNull().primaryKey())
.addColumn("settings", "json", col => col.notNull())
.addForeignKeyConstraint("user_settings_username_fk", ["username"], "users", ["username"])
.execute();
await db.updateTable("schema_migration")
.set({ version: "2025-06-26", dirty: 0 })
.execute();
}
export async function down(db: Kysely<any>) {
throw new Error('Downward migrations are not supported. Restore from backup.');
}

View file

@ -6,37 +6,37 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
"start": "tsx src/app.ts" "start": "tsx src/app.ts",
"migrate": "tsx tools/migration.ts"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@std/async": "npm:@jsr/std__async@^1.0.12", "@std/async": "npm:@jsr/std__async@^1.0.13",
"@zip.js/zip.js": "^2.7.60", "@zip.js/zip.js": "^2.7.62",
"better-sqlite3": "^9.6.0", "better-sqlite3": "^9.6.0",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"dbtype": "workspace:dbtype", "dbtype": "workspace:dbtype",
"dotenv": "^16.4.5", "dotenv": "^16.5.0",
"jose": "^5.9.3", "jose": "^5.10.0",
"koa": "^2.15.3", "koa": "^2.16.1",
"koa-bodyparser": "^4.4.1", "koa-bodyparser": "^4.4.1",
"koa-compose": "^4.1.0", "koa-compose": "^4.1.0",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",
"kysely": "^0.27.4", "kysely": "^0.27.6",
"natural-orderby": "^2.0.3", "natural-orderby": "^2.0.3",
"tiny-async-pool": "^1.3.0" "tiny-async-pool": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^8.5.9", "@types/jsonwebtoken": "^8.5.9",
"@types/koa": "^2.15.0", "@types/koa": "^2.15.0",
"@types/koa-bodyparser": "^4.3.12", "@types/koa-bodyparser": "^4.3.12",
"@types/koa-compose": "^3.2.8", "@types/koa-compose": "^3.2.8",
"@types/koa-router": "^7.4.8", "@types/koa-router": "^7.4.8",
"@types/node": "^22.15.3", "@types/node": "^22.15.33",
"@types/tiny-async-pool": "^1.0.5", "@types/tiny-async-pool": "^1.0.5",
"nodemon": "^3.1.7", "tsx": "^4.20.3",
"tsx": "^4.19.1", "typescript": "^5.8.3"
"typescript": "^5.6.2"
} }
} }

View file

@ -1,7 +0,0 @@
// import { contextBridge, ipcRenderer } from "electron";
// contextBridge.exposeInMainWorld("electron", {
// passwordReset: async (username: string, toPw: string) => {
// return await ipcRenderer.invoke("reset_password", username, toPw);
// },
// });

View file

@ -1,14 +1,16 @@
import { Kysely } from "kysely"; import { Kysely } from "kysely";
import { getKysely } from "./db/kysely.ts"; import { getKysely } from "./db/kysely.ts";
import fs from "node:fs/promises";
import path from "path";
export async function connectDB() { export async function connectDB() {
const kysely = getKysely(); const kysely = getKysely();
let tries = 0; let tries = 0;
for (;;) { for (; ;) {
try { try {
console.log("try to connect db"); console.log("try to connect db");
await kysely.selectNoFrom(eb=> eb.val(1).as("dummy")).execute(); await kysely.selectNoFrom(eb => eb.val(1).as("dummy")).execute();
console.log("connect success"); console.log("connect success");
} catch (err) { } catch (err) {
if (tries < 3) { if (tries < 3) {
@ -25,20 +27,72 @@ export async function connectDB() {
} }
async function checkTableExists(kysely: Kysely<any>, table: string) { async function checkTableExists(kysely: Kysely<any>, table: string) {
const result = await kysely.selectFrom("sqlite_master").where("type", "=", "table").where("name", "=", table).executeTakeFirst(); const result = await kysely.selectFrom("sqlite_master")
.selectAll()
.where("type", "=", "table")
.where("name", "=", table)
.executeTakeFirst();
return result !== undefined; return result !== undefined;
} }
export async function migrateDB() { export async function migrateDB() {
const kysely = getKysely(); const kysely = getKysely();
let version_number = 0;
// is schema_migration exists? // is schema_migration exists?
const hasTable = await checkTableExists(kysely, "schema_migration"); const hasTable = await checkTableExists(kysely, "schema_migration");
if (!hasTable) { if (!hasTable) {
// migrate from 0 // 2. 마이그레이션 실행 (최초 마이그레이션)
// create schema_migration const migration = await import("../migrations/2024-12-27.ts");
await migration.up(kysely);
console.log("최초 마이그레이션 완료");
return;
} }
const version = await kysely.selectFrom("schema_migration").executeTakeFirst(); // 현재 버전 확인
const row = await kysely.selectFrom("schema_migration").selectAll().executeTakeFirst();
const currentVersion = row?.version ?? "0001-01-01"; // 기본값 설정
// 마이그레이션 목록 정의 (버전순 정렬 필수)
const migrations = await readMigrations();
// 현재 버전보다 높은 migration만 실행
let lastestVersion = currentVersion;
console.log(`현재 DB 버전: ${currentVersion}`);
for (const m of migrations) {
if (compareVersion(m.version, currentVersion) > 0) {
console.log(`마이그레이션 실행: ${m.version}`);
const migration = await import(m.file);
await migration.up(kysely);
await kysely.updateTable("schema_migration")
.set({ version: m.version, dirty: 0 })
.execute();
lastestVersion = m.version;
console.log(`마이그레이션 완료: ${m.version}`);
}
}
if (lastestVersion !== currentVersion) {
console.log(`마이그레이션 완료. ${currentVersion} -> ${lastestVersion}`);
} else {
console.log("마이그레이션 필요 없음");
}
return;
}
async function readMigrations(): Promise<{ version: string; file: string }[]> {
const migrationsDir = path.join(import.meta.dirname, "../migrations");
const files = (await fs.readdir(migrationsDir))
.filter(file => file.endsWith(".ts"))
.map(file => {
const version = file.match(/(\d{4}-\d{2}-\d{2})/)?.[0] || "0001-01-01";
return { version, file: `../migrations/${file}` };
});
return files.sort((a, b) => compareVersion(a.version, b.version));
}
// Date 기반 버전 비교 함수.
function compareVersion(a: string, b: string): number {
const dateA = new Date(a);
const dateB = new Date(b);
if (dateA < dateB) return -1;
if (dateA > dateB) return 1;
return 0; // 같을 경우
} }

View file

@ -1,5 +1,5 @@
import { getKysely } from "./kysely.ts"; import { getKysely } from "./kysely.ts";
import { type IUser, Password, type UserAccessor, type UserCreateInput } from "../model/user.ts"; import { type IUser, IUserSettings, Password, type UserAccessor, type UserCreateInput } from "../model/user.ts";
class SqliteUser implements IUser { class SqliteUser implements IUser {
readonly username: string; readonly username: string;
@ -41,6 +41,24 @@ class SqliteUser implements IUser {
.executeTakeFirst(); .executeTakeFirst();
return (result.numDeletedRows ?? 0n) > 0; return (result.numDeletedRows ?? 0n) > 0;
} }
async get_settings(): Promise<IUserSettings | undefined> {
const settings = await this.kysely
.selectFrom("user_settings")
.select("settings")
.where("username", "=", this.username)
.executeTakeFirst();
if (!settings) return undefined;
return settings.settings ? JSON.parse(settings.settings) as IUserSettings : undefined;
}
async set_settings(settings: IUserSettings) {
const settingsJson = JSON.stringify(settings);
const result = await this.kysely
.insertInto("user_settings")
.values({ username: this.username, settings: settingsJson })
.onConflict((oc) => oc.doUpdateSet({ settings: settingsJson }))
.executeTakeFirst();
return (result.numInsertedOrUpdatedRows ?? 0n) > 0;
}
} }
export const createSqliteUserController = (kysely = getKysely()): UserAccessor => { export const createSqliteUserController = (kysely = getKysely()): UserAccessor => {

View file

@ -76,7 +76,7 @@ function setToken(ctx: Koa.Context, token_name: string, token_payload: string |
}); });
} }
export const createLoginMiddleware = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => { export const createLoginHandler = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
const setting = get_setting(); const setting = get_setting();
const secretKey = setting.jwt_secretkey; const secretKey = setting.jwt_secretkey;
const body = ctx.request.body; const body = ctx.request.body;
@ -115,7 +115,7 @@ export const createLoginMiddleware = (userController: UserAccessor) => async (ct
return; return;
}; };
export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => { export const LogoutHandler = (ctx: Koa.Context, _next: Koa.Next) => {
const setting = get_setting(); const setting = get_setting();
ctx.cookies.set(accessTokenName, null); ctx.cookies.set(accessTokenName, null);
ctx.cookies.set(refreshTokenName, null); ctx.cookies.set(refreshTokenName, null);
@ -127,9 +127,9 @@ export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => {
return; return;
}; };
export const createUserMiddleWare = export const createUserHandler =
(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => { (userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const refreshToken = refreshTokenHandler(userController); const refreshToken = makeRefreshToken(userController);
const setting = get_setting(); const setting = get_setting();
const setGuest = async () => { const setGuest = async () => {
setToken(ctx, accessTokenName, null, 0); setToken(ctx, accessTokenName, null, 0);
@ -140,7 +140,7 @@ export const createUserMiddleWare =
return await refreshToken(ctx, setGuest, next); return await refreshToken(ctx, setGuest, next);
}; };
const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => { const makeRefreshToken = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
const accessPayload = ctx.cookies.get(accessTokenName); const accessPayload = ctx.cookies.get(accessTokenName);
const setting = get_setting(); const setting = get_setting();
const secretKey = setting.jwt_secretkey; const secretKey = setting.jwt_secretkey;
@ -200,7 +200,7 @@ const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fai
} }
}; };
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => { export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
const handler = refreshTokenHandler(cntr); const handler = makeRefreshToken(cntr);
await handler(ctx, fail, success); await handler(ctx, fail, success);
async function fail() { async function fail() {
const user = ctx.state.user as PayloadInfo; const user = ctx.state.user as PayloadInfo;
@ -242,12 +242,54 @@ export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.C
ctx.type = "json"; ctx.type = "json";
}; };
export function getUserSettingHandler(userController: UserAccessor) {
return async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const username = ctx.state.user.username;
if (!username) {
return sendError(403, "not authorized");
}
const user = await userController.findUser(username);
if (user === undefined) {
return sendError(403, "not authorized");
}
const settings = await user.get_settings();
if (settings === undefined) {
ctx.body = {};
ctx.type = "json";
return;
}
ctx.body = settings;
ctx.type = "json";
await next();
};
}
export function setUserSettingHandler(userController: UserAccessor) {
return async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const username = ctx.state.user.username;
if (!username) {
return sendError(403, "not authorized");
}
const user = await userController.findUser(username);
if (user === undefined) {
return sendError(403, "not authorized");
}
const body = ctx.request.body;
const settings = body as Record<string, unknown>;
await user.set_settings(settings);
ctx.body = { ok: true };
ctx.type = "json";
await next();
};
}
export function createLoginRouter(userController: UserAccessor) { export function createLoginRouter(userController: UserAccessor) {
const router = new Router(); const router = new Router();
router.post("/login", createLoginMiddleware(userController)); router.post("/login", createLoginHandler(userController));
router.post("/logout", LogoutMiddleware); router.post("/logout", LogoutHandler);
router.post("/refresh", createRefreshTokenMiddleware(userController)); router.post("/refresh", createRefreshTokenMiddleware(userController));
router.post("/reset", resetPasswordMiddleware(userController)); router.post("/reset", resetPasswordMiddleware(userController));
router.get("/settings", getUserSettingHandler(userController));
router.post("/settings", setUserSettingHandler(userController));
return router; return router;
} }

View file

@ -1,3 +1,4 @@
import { UserSetting } from "dbtype";
import { createHmac, randomBytes } from "node:crypto"; import { createHmac, randomBytes } from "node:crypto";
function hashForPassword(salt: string, password: string) { function hashForPassword(salt: string, password: string) {
@ -41,6 +42,8 @@ export interface UserCreateInput {
password: string; password: string;
} }
export type IUserSettings = UserSetting;
export interface IUser { export interface IUser {
readonly username: string; readonly username: string;
readonly password: Password; readonly password: Password;
@ -65,6 +68,18 @@ export interface IUser {
* @param password password to set * @param password password to set
*/ */
reset_password(password: string): Promise<void>; reset_password(password: string): Promise<void>;
/**
* get user settings
* @returns user settings, or undefined if not set
*/
get_settings(): Promise<IUserSettings | undefined>;
/**
* set user settings
* @param settings user settings to set
* @returns if settings updated, return true
*/
set_settings(settings: IUserSettings): Promise<boolean>;
} }
export interface UserAccessor { export interface UserAccessor {

View file

@ -36,20 +36,21 @@ export enum Permission {
export const createPermissionCheckMiddleware = export const createPermissionCheckMiddleware =
(...permissions: string[]) => (...permissions: string[]) =>
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => { async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state.user; const user = ctx.state.user;
if (user.username === "admin") { if (user.username === "admin") {
return await next(); return await next();
} }
const user_permission = user.permission; const user_permission = user.permission;
// if permissions is not subset of user permission // if permissions is not subset of user permission
if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) { if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
if (user.username === "") { if (user.username === "") {
return sendError(401, "you are guest. login needed."); return sendError(401, "you are guest. login needed.");
}return sendError(403, "do not have permission"); } return sendError(403, "do not have permission");
} }
await next(); await next();
}; };
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => { export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state.user; const user = ctx.state.user;
if (user.username !== "admin") { if (user.username !== "admin") {

View file

@ -8,7 +8,7 @@ import { get_setting, SettingConfig } from "./SettingConfig.ts";
import { createReadStream, readFileSync } from "node:fs"; import { createReadStream, readFileSync } from "node:fs";
import bodyparser from "koa-bodyparser"; import bodyparser from "koa-bodyparser";
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts"; import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
import { createLoginRouter, createUserMiddleWare, getAdmin, isAdminFirst } from "./login.ts"; import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
import getContentRouter from "./route/contents.ts"; import getContentRouter from "./route/contents.ts";
import { error_handler } from "./route/error_handler.ts"; import { error_handler } from "./route/error_handler.ts";
@ -63,7 +63,7 @@ class ServerApplication {
} }
app.use(bodyparser()); app.use(bodyparser());
app.use(error_handler); app.use(error_handler);
app.use(createUserMiddleWare(this.userController)); app.use(createUserHandler(this.userController));
const diff_router = createDiffRouter(this.diffManger); const diff_router = createDiffRouter(this.diffManger);
this.diffManger.register("comic", createComicWatcher()); this.diffManger.register("comic", createComicWatcher());
@ -238,8 +238,6 @@ class ServerApplication {
static async createServer() { static async createServer() {
const db = await connectDB(); const db = await connectDB();
// todo : db migration
const app = new ServerApplication({ const app = new ServerApplication({
userController: createSqliteUserController(db), userController: createSqliteUserController(db),
documentController: createSqliteDocumentAccessor(db), documentController: createSqliteDocumentAccessor(db),

View file

@ -0,0 +1,16 @@
import { migrateDB } from "../src/database.ts";
import { config } from "dotenv";
config(); // Load environment variables from .env file
export async function runMigration() {
try {
await migrateDB();
console.log("Database migration completed successfully.");
} catch (error) {
console.error("Database migration failed:", error);
process.exit(1);
}
}
await runMigration();

814
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff