mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
Replace custom setInterval scheduler with croner
Swap the hand-rolled Scheduler class (setInterval-based) for croner, a battle-tested cron library with overlap protection and proper cron expressions. Consolidate lib/jobs/ into a single lib/cron.ts and remove the unused admin jobs API route. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ bun db:studio # Open Drizzle Studio (visual DB browser)
|
||||
- `lib/services/` — Business logic layer (metadata, tracking, discovery, availability)
|
||||
- `lib/tmdb/` — TMDB API client and TypeScript types
|
||||
- `lib/auth/` — Better Auth server config (`server.ts`) and client hooks (`client.ts`)
|
||||
- `lib/jobs/` — In-process background job scheduler (setInterval-based, `globalThis` singleton)
|
||||
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
|
||||
- `app/api/` — Next.js route handlers
|
||||
- `app/(pages)/` — User-facing pages (dashboard, search, title detail, login, register)
|
||||
- `components/` — App components + `components/ui/` for shadcn primitives
|
||||
@@ -86,7 +86,7 @@ Core files: `lib/services/image-cache.ts` (caching logic), `lib/tmdb/image.ts` (
|
||||
|
||||
### Background jobs
|
||||
|
||||
Registered in `lib/jobs/registry.ts`, started via Next.js instrumentation hook (`instrumentation.ts`) in production. Jobs: nightly library refresh (24h), availability refresh (6h), recommendations refresh (12h), TV episodes refresh (12h), image caching (12h). All batch jobs use 300ms delay between TMDB calls.
|
||||
Defined in `lib/cron.ts` using croner cron expressions, started via Next.js instrumentation hook (`instrumentation.ts`) in production. Jobs: nightly library refresh (`0 3 * * *`), availability refresh (`0 */6 * * *`), recommendations refresh (`0 */12 * * *`), TV episodes refresh (`30 */12 * * *`), image caching (`0 1,13 * * *`). All jobs use `protect: true` to prevent overlapping runs. All batch jobs use 300ms delay between TMDB calls.
|
||||
|
||||
### Environment variables
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { registerJobs } from "@/lib/jobs/registry";
|
||||
import { scheduler } from "@/lib/jobs/scheduler";
|
||||
|
||||
// Ensure jobs are registered for manual triggering
|
||||
registerJobs();
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
const jobNames = scheduler.getJobNames();
|
||||
|
||||
if (!jobNames.includes(name)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown job: ${name}. Available: ${jobNames.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await scheduler.runNow(name);
|
||||
return NextResponse.json({ ok: true, job: name });
|
||||
}
|
||||
@@ -5,44 +5,45 @@
|
||||
"": {
|
||||
"name": "sofa",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@tabler/icons-react": "^3.38.0",
|
||||
"@tanstack/react-hotkeys": "^0.3.1",
|
||||
"better-auth": "^1.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"@base-ui/react": "1.2.0",
|
||||
"@tabler/icons-react": "3.38.0",
|
||||
"@tanstack/react-hotkeys": "0.3.1",
|
||||
"better-auth": "1.5.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"croner": "10.0.2-dev.2",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"jotai": "^2.18.0",
|
||||
"motion": "^12.34.5",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"jotai": "2.18.0",
|
||||
"motion": "12.34.5",
|
||||
"next": "16.1.6",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"node-vibrant": "4.0.4",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-resizable-panels": "^4.7.0",
|
||||
"react-resizable-panels": "4.7.0",
|
||||
"recharts": "2.15.4",
|
||||
"shadcn": "^3.8.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6",
|
||||
"shadcn": "3.8.5",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"uuid": "13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.5",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@types/bun": "1.3.10",
|
||||
"@types/node": "25.3.3",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -705,6 +706,8 @@
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
|
||||
|
||||
"croner": ["croner@10.0.2-dev.2", "", {}, "sha512-/xijJtHje2b9SeU1T6ysrs1IGASOH/5YQ54oTEO/48sveZp1ig+MU9bO0N4bo2uXjJETuaDBRN7bJs+4kEEN1A=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
@@ -939,7 +942,7 @@
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
@@ -1407,7 +1410,7 @@
|
||||
|
||||
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
|
||||
|
||||
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="],
|
||||
"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
|
||||
|
||||
"timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="],
|
||||
|
||||
@@ -1569,6 +1572,8 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
@@ -1597,7 +1602,7 @@
|
||||
|
||||
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
|
||||
"mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
@@ -1615,6 +1620,8 @@
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
@@ -1649,8 +1656,6 @@
|
||||
|
||||
"isomorphic-fetch/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
+3
-4
@@ -17,16 +17,15 @@ export async function register() {
|
||||
runMigrations();
|
||||
|
||||
// Initialize job scheduler
|
||||
const { initJobs } = await import("@/lib/jobs/init");
|
||||
initJobs();
|
||||
const { startJobs, stopJobs } = await import("@/lib/cron");
|
||||
startJobs();
|
||||
|
||||
// Graceful shutdown — stop jobs and close DB on container stop
|
||||
const { scheduler } = await import("@/lib/jobs/scheduler");
|
||||
const { closeDatabase } = await import("@/lib/db/client");
|
||||
|
||||
const shutdown = () => {
|
||||
console.log("[shutdown] Stopping scheduler...");
|
||||
scheduler.stop();
|
||||
stopJobs();
|
||||
console.log("[shutdown] Closing database...");
|
||||
closeDatabase();
|
||||
console.log("[shutdown] Clean shutdown complete");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Cron } from "croner";
|
||||
import { and, eq, isNotNull, lt, or } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import {
|
||||
@@ -19,12 +20,40 @@ import {
|
||||
refreshTvChildren,
|
||||
} from "@/lib/services/metadata";
|
||||
import { getTvDetails } from "@/lib/tmdb/client";
|
||||
import { scheduler } from "./scheduler";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const RATE_LIMIT_MS = 300;
|
||||
|
||||
const globalForJobs = globalThis as unknown as {
|
||||
_jobs: Map<string, Cron> | undefined;
|
||||
};
|
||||
|
||||
if (!globalForJobs._jobs) {
|
||||
globalForJobs._jobs = new Map<string, Cron>();
|
||||
}
|
||||
const jobs = globalForJobs._jobs;
|
||||
|
||||
function schedule(name: string, cron: string, handler: () => Promise<void>) {
|
||||
jobs.set(
|
||||
name,
|
||||
new Cron(
|
||||
cron,
|
||||
{
|
||||
name,
|
||||
protect: true,
|
||||
catch: (err: unknown) => {
|
||||
console.error(`[scheduler] Job ${name} failed:`, err);
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
console.log(`[scheduler] Running job: ${name}`);
|
||||
await handler();
|
||||
console.log(`[scheduler] Completed job: ${name}`);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
@@ -175,14 +204,20 @@ async function cacheImagesJob() {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerJobs() {
|
||||
scheduler.register("nightlyRefreshLibrary", nightlyRefreshLibrary, 24 * HOUR);
|
||||
scheduler.register("refreshAvailability", refreshAvailabilityJob, 6 * HOUR);
|
||||
scheduler.register(
|
||||
"refreshRecommendations",
|
||||
refreshRecommendationsJob,
|
||||
12 * HOUR,
|
||||
);
|
||||
scheduler.register("refreshTvChildren", refreshTvChildrenJob, 12 * HOUR);
|
||||
scheduler.register("cacheImages", cacheImagesJob, 12 * HOUR);
|
||||
export function startJobs() {
|
||||
if (jobs.size > 0) return;
|
||||
|
||||
schedule("nightlyRefreshLibrary", "0 3 * * *", nightlyRefreshLibrary);
|
||||
schedule("refreshAvailability", "0 */6 * * *", refreshAvailabilityJob);
|
||||
schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob);
|
||||
schedule("refreshTvChildren", "30 */12 * * *", refreshTvChildrenJob);
|
||||
schedule("cacheImages", "0 1,13 * * *", cacheImagesJob);
|
||||
|
||||
console.log(`[scheduler] Started ${jobs.size} jobs`);
|
||||
}
|
||||
|
||||
export function stopJobs() {
|
||||
for (const job of jobs.values()) {
|
||||
job.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { registerJobs } from "./registry";
|
||||
import { scheduler } from "./scheduler";
|
||||
|
||||
export function initJobs() {
|
||||
registerJobs();
|
||||
scheduler.start();
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
interface Job {
|
||||
name: string;
|
||||
handler: () => Promise<void>;
|
||||
intervalMs: number;
|
||||
timer?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
|
||||
const globalForScheduler = globalThis as unknown as {
|
||||
_scheduler: Scheduler | undefined;
|
||||
};
|
||||
|
||||
class Scheduler {
|
||||
private jobs = new Map<string, Job>();
|
||||
private running = false;
|
||||
|
||||
register(name: string, handler: () => Promise<void>, intervalMs: number) {
|
||||
this.jobs.set(name, { name, handler, intervalMs });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
for (const job of this.jobs.values()) {
|
||||
job.timer = setInterval(async () => {
|
||||
try {
|
||||
console.log(`[scheduler] Running job: ${job.name}`);
|
||||
await job.handler();
|
||||
console.log(`[scheduler] Completed job: ${job.name}`);
|
||||
} catch (err) {
|
||||
console.error(`[scheduler] Job ${job.name} failed:`, err);
|
||||
}
|
||||
}, job.intervalMs);
|
||||
}
|
||||
|
||||
console.log(`[scheduler] Started ${this.jobs.size} jobs`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const job of this.jobs.values()) {
|
||||
if (job.timer) clearInterval(job.timer);
|
||||
}
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async runNow(name: string) {
|
||||
const job = this.jobs.get(name);
|
||||
if (!job) throw new Error(`Job not found: ${name}`);
|
||||
await job.handler();
|
||||
}
|
||||
|
||||
getJobNames() {
|
||||
return [...this.jobs.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalForScheduler._scheduler) {
|
||||
globalForScheduler._scheduler = new Scheduler();
|
||||
}
|
||||
|
||||
export const scheduler = globalForScheduler._scheduler;
|
||||
+29
-28
@@ -15,44 +15,45 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@tabler/icons-react": "^3.38.0",
|
||||
"@tanstack/react-hotkeys": "^0.3.1",
|
||||
"better-auth": "^1.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"@base-ui/react": "1.2.0",
|
||||
"@tabler/icons-react": "3.38.0",
|
||||
"@tanstack/react-hotkeys": "0.3.1",
|
||||
"better-auth": "1.5.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"croner": "10.0.2-dev.2",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"jotai": "^2.18.0",
|
||||
"motion": "^12.34.5",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"jotai": "2.18.0",
|
||||
"motion": "12.34.5",
|
||||
"next": "16.1.6",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"node-vibrant": "4.0.4",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-resizable-panels": "^4.7.0",
|
||||
"react-resizable-panels": "4.7.0",
|
||||
"recharts": "2.15.4",
|
||||
"shadcn": "^3.8.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
"shadcn": "3.8.5",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"uuid": "13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.5",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/bun": "1.3.10",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@types/node": "25.3.3",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3"
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.10"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user