chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)

This commit is contained in:
Git Agent
2026-08-23 09:56:53 +02:00
commit 8f1140127f
79 changed files with 6456 additions and 0 deletions

View File

@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DatabaseService } from "./database.service.js";
@Module({
providers: [DatabaseService],
exports: [DatabaseService]
})
export class DatabaseModule {}

View File

@ -0,0 +1,124 @@
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { AppConfig, loadConfig } from "../config/env.js";
import * as schema from "./schema.js";
@Injectable()
export class DatabaseService implements OnModuleDestroy {
readonly config: AppConfig;
readonly sqlite: Database.Database;
readonly db: BetterSQLite3Database<typeof schema>;
constructor() {
this.config = loadConfig();
this.sqlite = new Database(this.config.databasePath);
this.sqlite.pragma("journal_mode = WAL");
this.sqlite.pragma("foreign_keys = ON");
this.sqlite.pragma("busy_timeout = 5000");
this.db = drizzle(this.sqlite, { schema });
this.migrate();
}
onModuleDestroy(): void {
this.sqlite.close();
}
now(): string {
return new Date().toISOString();
}
private migrate(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')),
detail TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
title,
author,
description,
isbn,
content='books',
content_rowid='id'
);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
}

View File

@ -0,0 +1,77 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
name: text("name"),
passwordHash: text("password_hash").notNull(),
role: text("role", { enum: ["admin", "user"] }).notNull().default("user"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ emailIdx: uniqueIndex("users_email_unique").on(table.email) })
);
export const libraries = sqliteTable("libraries", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
path: text("path").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const books = sqliteTable(
"books",
{
id: integer("id").primaryKey({ autoIncrement: true }),
libraryId: integer("library_id")
.notNull()
.references(() => libraries.id, { onDelete: "cascade" }),
title: text("title").notNull(),
author: text("author"),
description: text("description"),
isbn: text("isbn"),
language: text("language"),
publisher: text("publisher"),
publishedDate: text("published_date"),
format: text("format", { enum: ["epub", "pdf"] }).notNull(),
filePath: text("file_path").notNull(),
coverPath: text("cover_path"),
fileSize: integer("file_size").notNull(),
fileMtime: text("file_mtime").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
);
export const progress = sqliteTable(
"progress",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
bookId: integer("book_id")
.notNull()
.references(() => books.id, { onDelete: "cascade" }),
locator: text("locator").notNull(),
percent: integer("percent").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ userBookIdx: uniqueIndex("progress_user_book_unique").on(table.userId, table.bookId) })
);
export const jobs = sqliteTable("jobs", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").notNull(),
status: text("status", { enum: ["queued", "running", "succeeded", "failed"] }).notNull(),
detail: text("detail"),
error: text("error"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});