1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user