Files
family-planner/apps/server/src/db/runner.ts
T
jason 35ed5223a0 Phase 1 & 2: full-stack family dashboard scaffold
- 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>
2026-03-29 21:56:30 -05:00

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;
}
}
}