feat: initialize SQLite with versioned migrations and sample content
This commit is contained in:
34
src/server/db.ts
Normal file
34
src/server/db.ts
Normal 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
16
src/server/initialize.ts
Normal 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
39
src/server/schema.ts
Normal 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
18
src/server/seed.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user