feat: initialize SQLite with versioned migrations and sample content

This commit is contained in:
2026-09-15 21:47:42 +03:30
parent 8bfe35f6b9
commit a9fe865d99
10 changed files with 765 additions and 0 deletions

34
src/server/db.ts Normal file
View File

@@ -0,0 +1,34 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import * as schema from './schema';
// Runtime storage must never be traced into the deployable application image.
export const dataDirectory = () => path.resolve(/* turbopackIgnore: true */ process.env.DATA_DIR || './data');
export function openDatabase(directory = dataDirectory()) {
mkdirSync(directory, { recursive: true, mode: 0o700 });
const sqlite = new Database(/* turbopackIgnore: true */ path.join(directory, 'portfolio.sqlite'));
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('busy_timeout = 5000');
const db = drizzle(sqlite, { schema });
return { db, sqlite };
}
export type DatabaseConnection = ReturnType<typeof openDatabase>;
let connection: DatabaseConnection | undefined;
export function getDatabase() { return connection ??= openDatabase(); }
export function migrateDatabase(connection: DatabaseConnection) {
// Only maintenance/startup invokes migrations. Importing this module has no disk side effects.
const config = { migrationsFolder: path.join(process.cwd(), 'migrations') };
const available = readMigrationFiles(config);
if (connection.sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'").get()) {
const applied = connection.sqlite.prepare('SELECT hash, created_at FROM __drizzle_migrations').all() as { hash: string; created_at: number }[];
if (applied.some(row => !available.some(migration => migration.hash === row.hash && migration.folderMillis === row.created_at))) {
throw new Error('Database migrations do not match this application version. Use the matching image or restore its pre-upgrade backup.');
}
}
migrate(connection.db, config);
}

16
src/server/initialize.ts Normal file
View File

@@ -0,0 +1,16 @@
import { existsSync, writeFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { dataDirectory, getDatabase, migrateDatabase } from './db';
import { seedDatabase } from './seed';
export function initialize() {
const connection = getDatabase();
migrateDatabase(connection);
seedDatabase(connection);
const secretPath = path.join(dataDirectory(), 'auth-secret');
if (!process.env.BETTER_AUTH_SECRET && !existsSync(/* turbopackIgnore: true */ secretPath)) {
writeFileSync(secretPath, randomBytes(48).toString('base64url'), { flag: 'wx', mode: 0o600 });
}
return connection;
}

39
src/server/schema.ts Normal file
View File

@@ -0,0 +1,39 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import type { Settings, Language, Section, Entry } from '../content/contracts';
export const siteSettings = sqliteTable('site_settings', { id: integer('id').primaryKey(), data: text('data', { mode: 'json' }).$type<Settings>().notNull() });
export const languages = sqliteTable('languages', { code: text('code').primaryKey(), data: text('data', { mode: 'json' }).$type<Language>().notNull() });
export const sections = sqliteTable('sections', { key: text('key').primaryKey(), data: text('data', { mode: 'json' }).$type<Section>().notNull() });
export const entries = sqliteTable('entries', {
id: text('id').primaryKey(), kind: text('kind').$type<Entry['kind']>().notNull(), position: integer('position').notNull(),
visible: integer('visible', { mode: 'boolean' }).notNull(),
shared: text('shared', { mode: 'json' }).$type<Entry['shared']>().notNull(),
translations: text('translations', { mode: 'json' }).$type<Entry['translations']>().notNull(),
});
export const media = sqliteTable('media', {
key: text('key').primaryKey(), originalName: text('original_name').notNull(), mime: text('mime').notNull(),
width: integer('width'), height: integer('height'), size: integer('size').notNull(), createdAt: integer('created_at').notNull(),
});
export const state = sqliteTable('app_state', { key: text('key').primaryKey(), value: text('value').notNull() });
export const user = sqliteTable('user', {
id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(), image: text('image'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const session = sqliteTable('session', {
id: text('id').primaryKey(), expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
ipAddress: text('ip_address'), userAgent: text('user_agent'), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
});
export const account = sqliteTable('account', {
id: text('id').primaryKey(), accountId: text('account_id').notNull(), providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }), refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
scope: text('scope'), password: text('password'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const verification = sqliteTable('verification', {
id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});

18
src/server/seed.ts Normal file
View File

@@ -0,0 +1,18 @@
import { eq } from 'drizzle-orm';
import type { DatabaseConnection } from './db';
import * as tables from './schema';
import { sampleSettings, sampleEntries, sampleSections } from '../content/samples';
export function seedDatabase({ db }: DatabaseConnection) {
db.transaction(tx => {
if (tx.select().from(tables.siteSettings).get()) return;
tx.insert(tables.siteSettings).values({ id: 1, data: sampleSettings }).run();
tx.insert(tables.languages).values({ code: 'en', data: { code: 'en', name: 'English', direction: 'ltr', enabled: true, messages: {} } }).run();
for (const section of sampleSections()) tx.insert(tables.sections).values({ key: section.key, data: section }).run();
for (const entry of sampleEntries()) tx.insert(tables.entries).values(entry).run();
tx.insert(tables.state).values({ key: 'content-status', value: 'sample' }).run();
});
}
export function markContentEdited({ db }: DatabaseConnection) {
db.update(tables.state).set({ value: 'edited' }).where(eq(tables.state.key, 'content-status')).run();
}