35ed5223a0
- pnpm monorepo (apps/client + apps/server) - Server: Express + node:sqlite with numbered migration runner, REST API for all 9 features (members, events, chores, shopping, meals, messages, countdowns, photos, settings) - Client: React 18 + Vite + TypeScript + Tailwind + Framer Motion + Zustand - Theme system: dark/light + 5 accent colors, CSS custom properties, anti-FOUC script, ThemeToggle on every surface - AppShell: collapsible sidebar, animated route transitions, mobile drawer - Phase 2 features: Calendar (custom month grid, event chips, add/edit modal), Chores (card grid, complete/reset, member filter, streaks), Shopping (multi-list tabs, animated check-off, quick-add bar, member assign) - Family member CRUD with avatar, color picker - Settings page: theme/accent, photo folder, slideshow, weather, date/time - Docker: multi-stage Dockerfile, docker-compose.yml, entrypoint with PUID/PGID - Unraid: CA XML template, CLI install script, UNRAID.md guide - .gitignore covering node_modules, dist, db files, secrets, build artifacts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
124 lines
3.4 KiB
TypeScript
124 lines
3.4 KiB
TypeScript
/**
|
|
* Migration runner
|
|
*
|
|
* How it works:
|
|
* 1. Creates a `_migrations` table on first run.
|
|
* 2. Loads all migration modules from ./migrations/ in filename order.
|
|
* 3. Skips any migration whose `id` is already recorded in `_migrations`.
|
|
* 4. Executes pending migrations inside individual transactions.
|
|
* 5. Records each successful migration with a timestamp.
|
|
*
|
|
* Adding a new migration:
|
|
* - Create `apps/server/src/db/migrations/NNN_description.ts`
|
|
* - Export `id` (string, matches filename) and `up` (SQL string).
|
|
* - Optionally export `down` (SQL string) for rollback support.
|
|
* - The runner picks it up automatically on next startup.
|
|
*/
|
|
|
|
import db from './db';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
|
|
interface Migration {
|
|
id: string;
|
|
up: string;
|
|
down?: string;
|
|
}
|
|
|
|
function bootstrap() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS _migrations (
|
|
id TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
}
|
|
|
|
function loadMigrations(): Migration[] {
|
|
const dir = path.join(__dirname, 'migrations');
|
|
if (!fs.existsSync(dir)) return [];
|
|
|
|
return fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.endsWith('.ts') || f.endsWith('.js'))
|
|
.sort()
|
|
.map((file) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const mod = require(path.join(dir, file)) as Migration;
|
|
if (!mod.id || !mod.up) {
|
|
throw new Error(`Migration ${file} must export 'id' and 'up'`);
|
|
}
|
|
return mod;
|
|
});
|
|
}
|
|
|
|
function getApplied(): Set<string> {
|
|
const rows = db.prepare('SELECT id FROM _migrations').all() as { id: string }[];
|
|
return new Set(rows.map((r) => r.id));
|
|
}
|
|
|
|
export function runMigrations() {
|
|
bootstrap();
|
|
|
|
const migrations = loadMigrations();
|
|
const applied = getApplied();
|
|
const pending = migrations.filter((m) => !applied.has(m.id));
|
|
|
|
if (pending.length === 0) {
|
|
console.log('[db] All migrations up to date.');
|
|
return;
|
|
}
|
|
|
|
console.log(`[db] Running ${pending.length} pending migration(s)...`);
|
|
|
|
for (const migration of pending) {
|
|
const apply = db.transaction(() => {
|
|
db.exec(migration.up);
|
|
db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(migration.id);
|
|
});
|
|
|
|
try {
|
|
apply();
|
|
console.log(`[db] ✓ Applied: ${migration.id}`);
|
|
} catch (err) {
|
|
console.error(`[db] ✗ Failed: ${migration.id}`, err);
|
|
throw err; // Abort startup on migration failure
|
|
}
|
|
}
|
|
|
|
console.log('[db] Migrations complete.');
|
|
}
|
|
|
|
export function rollback(targetId: string) {
|
|
const migrations = loadMigrations();
|
|
const applied = getApplied();
|
|
|
|
// Find all applied migrations after targetId in reverse order
|
|
const toRollback = migrations
|
|
.filter((m) => applied.has(m.id) && m.id > targetId)
|
|
.reverse();
|
|
|
|
if (toRollback.length === 0) {
|
|
console.log('[db] Nothing to roll back.');
|
|
return;
|
|
}
|
|
|
|
for (const migration of toRollback) {
|
|
if (!migration.down) {
|
|
console.warn(`[db] ⚠ No down migration for: ${migration.id} — skipping`);
|
|
continue;
|
|
}
|
|
const revert = db.transaction(() => {
|
|
db.exec(migration.down!);
|
|
db.prepare('DELETE FROM _migrations WHERE id = ?').run(migration.id);
|
|
});
|
|
try {
|
|
revert();
|
|
console.log(`[db] ✓ Rolled back: ${migration.id}`);
|
|
} catch (err) {
|
|
console.error(`[db] ✗ Rollback failed: ${migration.id}`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|