import type { RebaseServerClient } from "../controllers/client.js"; /** * Cron Job type definitions for Rebase. * * These types define the shape of cron job definitions, their runtime * status, and execution log entries — used across server, client, * and studio packages. */ /** * A cron job definition file exports this shape as its default export. * See the example cron files in `app/backend/crons/` for usage. */ export interface CronJobDefinition { schedule: string; /** * IANA time zone the schedule is read in, e.g. `"Europe/Madrid"`. * * Without it the expression is read in the process's own zone — UTC in * nearly every container, the developer's own on a laptop — so `0 3 * * *` * fires at a different wall-clock hour either side of a deploy. Name the * zone and the schedule means one thing everywhere it runs. An unknown zone * is refused when the job loads rather than silently read as local time. */ timezone?: string; /** * Human-readable name shown in the Studio UI. Defaults to the file's own * name — `crons/nightly-cleanup.ts` is `nightly-cleanup`. * * Optional because the loader has always treated it that way * (`definition.name ?? loaded.id`), while this declared it required. So a * cron without one ran happily under `rebase dev` and then failed * `rebase build` with "Property 'name' is missing" — the dev loop and the * build disagreeing about whether the project compiles, which is the worst * place for a type to be stricter than the code. */ name?: string; /** Optional description shown in the Studio UI. */ description?: string; /** * Whether the job is enabled at startup. Defaults to `true`. * Can be toggled at runtime via the Admin API. */ enabled?: boolean; /** * Maximum number of seconds the handler may run before being * considered timed-out. Default: 300 (5 min). */ timeoutSeconds?: number; /** * How far back to look, on startup, for a slot that elapsed while no * instance was holding a timer for it. Off by default. * * The scheduler drives jobs with in-process `setTimeout` and computes the * next slot from *now* on every boot, so a slot only fires if some instance * happened to be alive and ticking when it came round. That is not a * scale-to-zero problem: a platform that recycles containers — Cloud Run * rotating an instance under `--min-instances 1`, a rolling deploy, a crash * loop — drops any slot that falls inside the changeover, and the * replacement schedules the slot *after* it. The run is skipped in silence. * * Set this to a window comfortably wider than a restart (a few minutes for * a frequent job; an hour or more for a daily one) and startup will run a * slot it finds unclaimed inside that window. * * Two deliberate limits: * * - **Only the most recent missed slot runs.** Booting after a six-hour * outage catches an hourly job up once, not six times. Catch-up exists to * stop a run going missing, not to replay history. * - **A claims-capable store is required.** Catch-up is skipped entirely * when the store has no `tryClaimRun` (or no store is attached), because * the claim is the only thing that distinguishes "this slot never ran" * from "this slot already ran on the instance I am replacing". Without * it, an instance recycled every 30 minutes would re-run the same hourly * job every time it booted. * * @example catchUpWindowSeconds: 3600 // daily job: tolerate an hour of downtime */ catchUpWindowSeconds?: number; /** * The handler function executed on each tick. * Receives a context object with the data driver and logger. * May return arbitrary JSON-serialisable data stored in the log. */ handler: (ctx: CronJobContext) => Promise | unknown; } /** * Context passed to each cron handler invocation. */ export interface CronJobContext { /** The job's unique ID (derived from filename). */ jobId: string; /** The current scheduled tick timestamp. */ scheduledAt: Date; /** A simple logger scoped to this job run. */ log: (...args: unknown[]) => void; /** * Aborted when the run exceeds `timeoutSeconds`. * * The timeout has always stopped the scheduler *waiting* — it loses the * race and the run is recorded as failed. It has never stopped the handler: * a `fetch` to an unresponsive host kept its socket, and a job on a * five-minute schedule with a five-minute timeout accumulated one abandoned * request per tick until the process ran out of sockets, all of it invisible * because the run was already marked failed. * * Pass it to anything that takes one, and the work stops when the run does: * * @example * export default defineCron({ * name: "Sync inventory", * schedule: "*\/15 * * * *", * timeoutSeconds: 60, * async handler({ signal, log }) { * const res = await fetch("https://supplier.example.com/stock", { signal }); * log(`fetched ${res.status}`); * } * }); */ signal: AbortSignal; /** * The server-side Rebase singleton — the **same object** `import { rebase } * from "@rebasepro/server"` returns, and the same one `defineFunction` * hands its callback. Spelled the same way here so that one thing has one * name across every server-side authoring surface. * * Its data plane is {@link RebaseServerClient.dataAsAdmin}, scoped as * `{ uid: "service", roles: ["admin"] }` — **admin-scoped, not * RLS-bypassing**. Statements still run as `rebase_user` with the policies * evaluated against that identity; the admin role merely clears the default * policies through their `rolesOverlap(['admin'])` arm, and * `policy.serverContext()` (`rebase.uid() IS NULL`) is *false* for it. * `rebase.sql()` is the unconditional bypass. A cron has no per-request * user, so there is no user-scoped alternative here: scope every query's * filters yourself. * * @example * export default defineCron({ * name: "Nightly cleanup", * schedule: "0 3 * * *", * async handler({ rebase, log }) { * const expired = await rebase.dataAsAdmin.sessions.findAll({ * where: { expired: ["==", true] } * }); * for (const session of expired) { * await rebase.dataAsAdmin.sessions.delete(session.id as string); * } * log(`Deleted ${expired.length} expired sessions`); * } * }); */ rebase: RebaseServerClient; } export type CronJobRunState = "idle" | "running" | "success" | "error" | "disabled"; /** * Full runtime information about a registered cron job. */ export interface CronJobStatus { /** Unique identifier (derived from filename, e.g. "cleanup-sessions"). */ id: string; /** Human-readable name from the definition. */ name: string; /** Description from the definition. */ description?: string; /** The cron schedule expression. */ schedule: string; /** Whether the job is currently enabled. */ enabled: boolean; /** Current run state. */ state: CronJobRunState; /** ISO timestamp of the last execution start. */ lastRunAt?: string; /** ISO timestamp of the next scheduled execution. */ nextRunAt?: string; /** Duration of the last run in milliseconds. */ lastDurationMs?: number; /** Error message from the last failed run. */ lastError?: string; /** Total number of executions since server start. */ totalRuns: number; /** Total number of failed executions since server start. */ totalFailures: number; } export type CronLogLevel = "info" | "error" | "warn"; /** * A single execution log entry stored in the in-memory ring buffer. */ export interface CronJobLogEntry { /** The job ID this log belongs to. */ jobId: string; /** ISO timestamp when execution started. */ startedAt: string; /** ISO timestamp when execution finished. */ finishedAt: string; /** Duration in milliseconds. */ durationMs: number; /** Whether this run succeeded. */ success: boolean; /** Error message if the run failed. */ error?: string; /** Arbitrary result data returned by the handler. */ result?: unknown; /** Captured log lines. */ logs: string[]; /** Whether this was a manual trigger. */ manual?: boolean; }