feat(server): boot: migrate, load plugins, worker, scheduler, daily backup, web placeholder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6963e297b0
commit
02b7eed5ec
4 files changed
+107
No files matched your search
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
|
||||||
Reference in new issue
Block a user