feat(server): boot: migrate, load plugins, worker, scheduler, daily backup, web placeholder

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:16:15 +01:00
co-authored by Claude Opus 5.5
parent 6963e297b0
commit 02b7eed5ec
4 changed files with 107 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import { enqueue } from "@sttracker/core/jobs";
import type { Db } from "@sttracker/db/db";
/** The next 03:00 local time strictly after `now`. */
export function nextBackupAt(now: Date = new Date()): Date {
const d = new Date(now);
d.setHours(3, 0, 0, 0);
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
return d;
}
/**
* Queue the daily `backup` job for the next 03:00 unless one is already queued or running (lockKey "backup").
* Returns the job id, or null when one is pending.
*/
export function ensureBackupScheduled(db: Db, now: Date = new Date()): Promise<string | null> {
return enqueue(db, "backup", {}, { lockKey: "backup", runAt: nextBackupAt(now).toISOString() });
}
+66
View File
@@ -0,0 +1,66 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { backupHandler } from "@sttracker/core/backup";
import { syncHandler } from "@sttracker/core/connections";
import { startScheduler, startWorker } from "@sttracker/core/jobs";
import { loadPlugins } from "@sttracker/core/registry";
import { createDb } from "@sttracker/db/db";
import { migrateToLatest } from "@sttracker/db/migrate";
import index from "../../web/index.html";
import { createApp } from "./app.ts";
import { ensureBackupScheduled } from "./boot.ts";
import { loadConfig } from "./config.ts";
const BACKUP_CHECK_MS = 60 * 60 * 1000;
const config = loadConfig();
await mkdir(join(config.dataDir, "plugins"), { recursive: true });
const db = await createDb(config.databaseUrl);
await migrateToLatest(db);
const registry = await loadPlugins(config.pluginDirs);
const ensureBackup = () => ensureBackupScheduled(db).catch((e) => console.error("[backup] scheduling failed", e));
const runBackup = backupHandler({ db, dataDir: config.dataDir });
const worker = startWorker(db, {
sync: syncHandler({ db, secret: config.secret, registry }),
backup: async () => {
try {
await runBackup();
} finally {
// The finishing job still holds lockKey "backup" until the worker marks it done; queue the next one shortly after.
setTimeout(() => void ensureBackup(), 5_000);
}
},
});
const scheduler = startScheduler(db);
await ensureBackup();
// Safety net: re-queue the daily backup if a run's follow-up was missed (e.g. it failed for good).
const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS);
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
const server = Bun.serve({
port: config.port,
development: process.env.NODE_ENV !== "production",
routes: {
"/api/*": (req, srv) => app.fetch(req, srv),
"/ingest/*": (req, srv) => app.fetch(req, srv),
"/*": index,
},
fetch: (req, srv) => app.fetch(req, srv),
});
console.log(`STTracker listening on ${server.url} (${db.dialect}, ${registry.list().length} plugin(s))`);
let stopping = false;
async function shutdown(signal: string): Promise<void> {
if (stopping) return;
stopping = true;
console.log(`${signal}: shutting down`);
clearInterval(backupTimer);
scheduler.stop();
await server.stop();
await worker.stop();
await db.close();
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
import { ensureBackupScheduled, nextBackupAt } from "../src/boot.ts";
test("nextBackupAt is the next 03:00 local time", () => {
const before = new Date(2026, 9, 30, 2, 59);
expect(nextBackupAt(before).getTime()).toBe(new Date(2026, 9, 30, 3, 0).getTime());
const after = new Date(2026, 9, 30, 3, 0);
expect(nextBackupAt(after).getTime()).toBe(new Date(2026, 9, 31, 3, 0).getTime());
});
forEachDialect("boot", (getDb) => {
test("ensureBackupScheduled queues one backup job under lockKey backup", async () => {
const db = getDb();
expect(await ensureBackupScheduled(db)).not.toBeNull();
expect(await ensureBackupScheduled(db)).toBeNull();
const jobs = await db.k.selectFrom("jobs").selectAll().where("type", "=", "backup").execute();
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({ lock_key: "backup", status: "queued" });
expect(new Date(jobs[0]!.run_at).getHours()).toBe(3);
});
});