Merge PR #421: feat: add Turso (libSQL) storage support for EdgeOne deployment
This commit is contained in:
@@ -42,6 +42,8 @@ const runtimeEnvKeys = [
|
||||
'NEXT_PUBLIC_STORAGE_TYPE',
|
||||
'UPSTASH_URL',
|
||||
'UPSTASH_TOKEN',
|
||||
'TURSO_URL',
|
||||
'TURSO_TOKEN',
|
||||
'TMDB_API_KEY',
|
||||
'TMDB_IMAGE_DOMAIN',
|
||||
'NEXT_PUBLIC_SITE_NAME',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Turso (libSQL) 数据库初始化脚本
|
||||
*
|
||||
* 创建数据库表结构并初始化默认管理员用户
|
||||
* 复用 SQLite 迁移文件(Turso 基于 libSQL,完全兼容 SQLite 语法)
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 安装依赖:pnpm install
|
||||
* 2. 设置环境变量:
|
||||
* export TURSO_URL=libsql://your-database.turso.io
|
||||
* export TURSO_TOKEN=your-auth-token
|
||||
* export USERNAME=admin
|
||||
* export PASSWORD=your-password
|
||||
* 3. 运行:node scripts/init-turso.js
|
||||
*/
|
||||
|
||||
const { createClient } = require('@libsql/client/http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
|
||||
const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql';
|
||||
|
||||
function hashPassword(password) {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
function splitSqlStatements(sql) {
|
||||
const withoutLineComments = sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
return withoutLineComments
|
||||
.split(';')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function getMigrationFiles() {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function isIgnorableMigrationError(error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error || '');
|
||||
return (
|
||||
(message.includes('table') && message.includes('already exists')) ||
|
||||
(message.includes('index') && message.includes('already exists')) ||
|
||||
message.includes('duplicate column name')
|
||||
);
|
||||
}
|
||||
|
||||
async function tableExists(client, tableName) {
|
||||
const result = await client.execute({
|
||||
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
args: [tableName],
|
||||
});
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
async function ensureMigrationTable(client) {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function getAppliedMigrations(client) {
|
||||
const result = await client.execute('SELECT filename FROM schema_migrations');
|
||||
return new Set(result.rows.map((row) => row.filename));
|
||||
}
|
||||
|
||||
async function markMigrationApplied(client, filename) {
|
||||
await client.execute({
|
||||
sql: 'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)',
|
||||
args: [filename, Date.now()],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedExistingMigrationBaseline(
|
||||
client,
|
||||
migrationFiles,
|
||||
hadExistingSchema
|
||||
) {
|
||||
const applied = await getAppliedMigrations(client);
|
||||
if (!hadExistingSchema || applied.size > 0) return;
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) {
|
||||
await markMigrationApplied(client, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runMigrations(client) {
|
||||
const migrationFiles = getMigrationFiles();
|
||||
const hadExistingSchema = await tableExists(client, 'users');
|
||||
await ensureMigrationTable(client);
|
||||
await seedExistingMigrationBaseline(client, migrationFiles, hadExistingSchema);
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
const applied = await getAppliedMigrations(client);
|
||||
if (applied.has(file)) {
|
||||
console.log(`⏭️ Migration already applied: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const migrationPath = path.join(MIGRATIONS_DIR, file);
|
||||
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||
const statements = splitSqlStatements(sql);
|
||||
|
||||
console.log(`▶️ Applying migration: ${file}`);
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
await client.execute(statement);
|
||||
} catch (error) {
|
||||
if (isIgnorableMigrationError(error)) {
|
||||
console.log(`⏭️ Statement skipped in ${file}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await markMigrationApplied(client, file);
|
||||
console.log(`✅ Migration applied: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDefaultAdmin(client) {
|
||||
const username = process.env.USERNAME || 'admin';
|
||||
const password = process.env.PASSWORD || '123456789';
|
||||
const passwordHash = hashPassword(password);
|
||||
|
||||
const existingUser = await client.execute({
|
||||
sql: 'SELECT username FROM users WHERE username = ? LIMIT 1',
|
||||
args: [username],
|
||||
});
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
console.log(`ℹ️ Admin user already exists: ${username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO users (
|
||||
username, password_hash, role, created_at,
|
||||
playrecord_migrated, favorite_migrated, skip_migrated
|
||||
) VALUES (?, ?, 'owner', ?, 1, 1, 1)`,
|
||||
args: [username, passwordHash, Date.now()],
|
||||
});
|
||||
|
||||
console.log(`✅ Default admin user created: ${username}`);
|
||||
}
|
||||
|
||||
async function initTursoDatabase() {
|
||||
const tursoUrl = process.env.TURSO_URL;
|
||||
const tursoToken = process.env.TURSO_TOKEN;
|
||||
|
||||
if (!tursoUrl || !tursoToken) {
|
||||
console.error(
|
||||
'❌ TURSO_URL and TURSO_TOKEN environment variables must be set'
|
||||
);
|
||||
console.error('');
|
||||
console.error('Example:');
|
||||
console.error(' export TURSO_URL=libsql://your-database.turso.io');
|
||||
console.error(' export TURSO_TOKEN=your-auth-token');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
url: tursoUrl,
|
||||
authToken: tursoToken,
|
||||
});
|
||||
|
||||
console.log('📦 Initializing Turso (libSQL) database...');
|
||||
console.log('🔗 Database URL:', tursoUrl);
|
||||
|
||||
try {
|
||||
await runMigrations(client);
|
||||
await ensureDefaultAdmin(client);
|
||||
console.log('');
|
||||
console.log('🎉 Turso database is ready!');
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=turso in your environment');
|
||||
console.log('2. Set TURSO_URL and TURSO_TOKEN environment variables');
|
||||
console.log('3. Deploy your application');
|
||||
} catch (err) {
|
||||
console.error('❌ Turso initialization failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initTursoDatabase();
|
||||
Reference in New Issue
Block a user