This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,57 +1,168 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { Pool, type QueryResult, type QueryResultRow } from 'pg';
import type { AppConfig } from './config.js';
import { databaseMigrations } from './db/migrations.js';
const schemaSql = `
CREATE TABLE IF NOT EXISTS users (
const migrationTableSql = `
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
token_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS save_snapshots (
user_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
saved_at TEXT NOT NULL,
bottom_tab TEXT NOT NULL,
game_state_json TEXT NOT NULL,
current_story_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS runtime_settings (
user_id TEXT PRIMARY KEY,
music_volume REAL NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS custom_world_profiles (
user_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)
`;
export type AppDatabase = Database.Database;
type MigrationRow = QueryResultRow & {
id: string;
name: string;
appliedAt: string;
};
export function createDatabase(config: AppConfig) {
const sqliteDir = path.dirname(config.sqlitePath);
fs.mkdirSync(sqliteDir, { recursive: true });
export type AppDatabase = {
query<TResult extends QueryResultRow = QueryResultRow>(
text: string,
params?: readonly unknown[],
): Promise<QueryResult<TResult>>;
close(): Promise<void>;
};
const db = new Database(config.sqlitePath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(schemaSql);
type QueryablePool = Pick<Pool, 'query' | 'end'>;
function wrapPool(pool: QueryablePool): AppDatabase {
return {
query<TResult extends QueryResultRow = QueryResultRow>(
text: string,
params: readonly unknown[] = [],
) {
return pool.query<TResult>(text, [...params]);
},
async close() {
await pool.end();
},
};
}
function validateDatabaseUrl(databaseUrl: string) {
const trimmed = databaseUrl.trim();
if (!trimmed) {
throw new Error('DATABASE_URL 不能为空');
}
if (trimmed.startsWith('pg-mem://')) {
return;
}
let protocol = '';
try {
protocol = new URL(trimmed).protocol;
} catch {
throw new Error(
'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接',
);
}
if (protocol !== 'postgresql:' && protocol !== 'postgres:') {
throw new Error(
'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接',
);
}
}
export function summarizeDatabaseTarget(databaseUrl: string) {
const trimmed = databaseUrl.trim();
if (!trimmed) {
return '[missing]';
}
if (trimmed.startsWith('pg-mem://')) {
return trimmed;
}
try {
const url = new URL(trimmed);
const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres';
const portSuffix = url.port ? `:${url.port}` : '';
return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`;
} catch {
return '[configured]';
}
}
async function ensureMigrationTable(db: AppDatabase) {
await db.query(migrationTableSql);
}
export async function listAppliedMigrations(db: AppDatabase) {
await ensureMigrationTable(db);
const result = await db.query<MigrationRow>(
`SELECT id, name, applied_at AS "appliedAt"
FROM schema_migrations
ORDER BY id`,
);
return result.rows.map((row) => ({
id: row.id,
name: row.name,
appliedAt: row.appliedAt,
}));
}
async function runMigrations(db: AppDatabase) {
await ensureMigrationTable(db);
const appliedMigrations = new Set(
(await listAppliedMigrations(db)).map((migration) => migration.id),
);
for (const migration of databaseMigrations) {
if (appliedMigrations.has(migration.id)) {
continue;
}
await db.query('BEGIN');
try {
for (const statement of migration.statements) {
await db.query(statement);
}
await db.query(
`INSERT INTO schema_migrations (id, name, applied_at)
VALUES ($1, $2, $3)`,
[migration.id, migration.name, new Date().toISOString()],
);
await db.query('COMMIT');
} catch (error) {
await db.query('ROLLBACK');
throw new Error(
`failed to apply database migration ${migration.id}: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
}
async function createInMemoryDatabase() {
const { newDb } = await import('pg-mem');
const memoryDb = newDb({
autoCreateForeignKeyIndices: true,
noAstCoverageCheck: true,
});
const adapter = memoryDb.adapters.createPg();
const pool = new adapter.Pool() as unknown as QueryablePool;
const db = wrapPool(pool);
await runMigrations(db);
return db;
}
export async function createDatabase(config: AppConfig) {
validateDatabaseUrl(config.databaseUrl);
if (config.databaseUrl.startsWith('pg-mem://')) {
return createInMemoryDatabase();
}
const pool = new Pool({
connectionString: config.databaseUrl,
});
const db = wrapPool(pool);
await db.query('SELECT 1');
await runMigrations(db);
return db;
}