diff --git a/apps/server/src/boot.ts b/apps/server/src/boot.ts new file mode 100644 index 0000000..b22bff4 --- /dev/null +++ b/apps/server/src/boot.ts @@ -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 { + return enqueue(db, "backup", {}, { lockKey: "backup", runAt: nextBackupAt(now).toISOString() }); +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 0000000..c4ebf9a --- /dev/null +++ b/apps/server/src/main.ts @@ -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 { + 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")); diff --git a/apps/server/test/boot.test.ts b/apps/server/test/boot.test.ts new file mode 100644 index 0000000..3632478 --- /dev/null +++ b/apps/server/test/boot.test.ts @@ -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); + }); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d0864e8 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1 @@ +STTracker
STTracker is starting…