Palbase
Sign inGet started

Backend SDK

Scheduled Jobs

A scheduled job is a class decorated with @Job, listed in a module's providers, whose run method the platform calls on a cron schedule — nightly cleanups, periodic syncs, digest mail. There is no external scheduler and nothing to register with: palbase push extracts each job's schedule into a manifest that travels with your code, and the stack's scheduler reads it. Discovery is not a directory — there is no jobs/ and nothing reads one; a @Job class exists because a module names it, exactly like a controller. Jobs are also the only background rail in the SDK — Queue and defineWorker were removed, and a workers/ directory deploys nothing.

Quick example

// modules/todos/cleanup-completed.job.ts
import { Database, Job, Log, type JobMeta } from "@palbase/backend";

@Job({ name: "cleanup-completed", schedule: "0 3 * * *", timeout: 120 })
export class CleanupCompletedJob {
  async run(meta: JobMeta): Promise<void> {
    // A job has no signed-in user, so owner-scoped RLS policies match nothing.
    const db = Database.$asService();
    const stale = await db.public.todos.findMany({ where: { done: true } });
    for (const todo of stale) {
      await db.public.todos.delete(todo.id);
    }
    Log.info(`cleaned up ${stale.length} completed todos`);
  }
}
// modules/todos/todos.module.ts
// What makes the job exist. The lists are typed, so there is nothing to cast.
import { Module } from "@palbase/backend";

import { CleanupCompletedJob } from "./cleanup-completed.job";

@Module({
  controllers: [],
  providers: [CleanupCompletedJob],   // surfaces go in `providers`, beside the services
  exports: [],
  imports: [],
})
export class TodosModule {}

Push, and the job runs every night at 03:00 UTC. Leave it out of providers and the build refuses it by name — a @Job class nothing claims would otherwise deploy and never fire. The job is built by the container like any other class, so it can take its dependencies through the constructor; see Modules & Dependency Injection.

@Job

import { Job, type JobMeta } from "@palbase/backend";

@Job({ name: "my-job", schedule: "0 3 * * *" })
export class MyJob {
  async run(meta: JobMeta): Promise<void> {
    // the work
  }
}
OptionTypeDefaultDescription
namestringrequiredThe job's identity — the row the scheduler holds it under, and the name a log line carries. Lowercase letters, digits and dashes.
schedulestringrequiredA 5-field cron expression (see below). Validated at build time — an invalid expression refuses the build rather than shipping a job that never fires.
timeoutnumber30Execution timeout in seconds. A positive integer, maximum 300.
retrynumber5How many times a failed run is retried before the run is recorded as failed. 0 disables retrying; maximum 10.

The class must declare an async run(meta: JobMeta): Promise<void> method — that is what runs on each trigger. Both ceilings are checked twice: once by the SDK when the manifest is written, and again by the stack when the manifest is applied, because a manifest is a file that could have been hand-edited or produced by an older SDK.

Note: The name is declared, not derived. It used to be the FILE's name, which put a scheduler identity in the file system: renaming cleanup-completed.ts unscheduled one job and scheduled another, silently. Now it sits beside the schedule, where a reader is already looking. Renaming the file changes nothing; changing name is what unschedules the old row and schedules a new one. Two @Job classes declaring the same name refuse the push, naming it.

Cron expressions

The schedule is a 5-field cron expression: minute hour day-of-month month day-of-week.

FieldAllowed values
minute059
hour023
day of month131
month112
day of week07

Each field accepts:

SyntaxMeaningExample
*every value* * * * * — every minute
nexact value0 3 * * * — at 03:00
a-brange0 9-17 * * * — every hour from 09:00 to 17:00
*/k, a-b/kstep*/15 * * * * — every 15 minutes
a,b,clist0 8 * * 1,3,5 — 08:00 on Mon, Wed, Fri

Note: Name aliases (MON, JAN) and macros (@daily, @hourly) are not supported — use numeric fields only. Schedules are evaluated in UTC, so 0 3 * * * means 03:00 UTC, not 03:00 wherever you are.

Common schedules

"* * * * *"      // every minute
"*/5 * * * *"    // every 5 minutes
"0 * * * *"      // top of every hour
"30 6 * * *"     // every day at 06:30
"0 9 * * 1-5"    // weekdays at 09:00
"0 0 1 * *"      // first day of every month at midnight
"0 12 * * 0"     // Sundays at noon

What ships, and what refuses

The Go half of the stack cannot read a decorator, so what a job DECLARES has to travel as data. palbase push builds the bundle, imports it, builds the container your modules describe, and asks that container which of its classes carry @Job — never the disk. It writes what they declare as jobs.manifest.json{"jobs":[{name, schedule, timeout, retry}]} — and prints what it found:

bundled 2 job(s) → cleanup-completed, send-daily-reminders

Two @Job classes declaring the same name, or any option the decorator rejects, refuses the push there, before anything ships. The manifest travels inside the push tarball, and applying it upserts the definitions the artifact declares and prunes the ones it does not — which is how deleting a job class unschedules it. A project with no jobs ships no manifest at all, rather than a stale one that would keep firing something the bundle no longer carries.

Note: The manifest is a build product, not a file in your repository. It is written under a temporary bundle root (<tmp>/palbase-bundle-*/.palbase/jobs/) that lives for the length of the one command that made it, and tarred straight from there. Since CLI 0.61.1 nothing is written into your checkout to build a push — if you find a .palbase/jobs/ in a working tree, it is litter from an older CLI and deleting it changes nothing.

Asking the container rather than the file system also closed a gap that could not be closed before it. Until 2026-08-26 the bundle entry emitted a hardcoded empty array for jobs, and the consequence was measured on a live project: four @Job classes deployed, none of them in the bundle, no error anywhere, and zero rows in the job table while the deploy reported success. A directory-based gate could only compare a folder against a bundle; now a job exists because a module lists it and the same module is what the bundle imports, so the two cannot disagree.

Warning: Be on a CLI newer than 2026-08-26. An older binary emits the empty array instead of your jobs, and it does so silently: the push succeeds, your endpoints come up, and every @Job in the project is quietly absent from the deployed artifact. Check with palbase --version before pushing a project that declares any. See Deploying.

Build-time validation throws on a missing or malformed name, a missing or malformed schedule, a timeout that is not a positive integer or exceeds 300, a retry that is not a whole number or exceeds 10, and a class with no async run() method. palbase build asks the same container the deploy builds and reports each surface for itself, so a total can never hide a subtraction — and a count read from the declaration cannot go quiet when a job moves folders:

build OK — 14 route(s) across the controllers would deploy cleanly, plus 2 job(s), 1 webhook(s), 1 hook(s)

JobMeta

The run method receives a single meta argument. Jobs are system-initiated: there is no user, no request and no payload — if the work needs input, read it from the database.

FieldTypeDescription
envRecord<string, string>A snapshot of process.env, into which the runtime mirrors your Environment's vault — see Secrets.
environmentIdstringThe stack's own boot ref. Read the warning below before using it.

Services are not on meta. Import the singletons — Database, Auth, Documents, Storage, Cache, Secrets, Log, Notifications, Flags, Realtime — from @palbase/backend exactly as you would in a controller, because a job came out of the same bundle and resolves the same clients.

Because env is process.env, a secret rotated in the vault reaches the next run with nothing restarted — the runtime re-reads the vault when the secrets generation moves and updates the variables it owns. The engine's own credentials are deleted from process.env before your bundle is imported, so DATABASE_URL, PALBASE_SERVICE_ROLE_KEY and REALTIME_INGESTION_SECRET are not in meta.env and are not meant to be.

Warning: meta.environmentId is not your Environment's ref. It is the runtime's PALBASE_STACK_REF, and on the Palbase cloud every project boots with the same compile-time constant — the literal string project — because a stack's identity is deliberately not something a request can choose. Do not use it to tell staging from production. What does differ per Environment is the stack's own address, in meta.env.PALBASE_PUBLIC_ORIGIN (https://<ref>.palbase.studio), or anything you set yourself with palbase secret set.

Note: With no signed-in user, the default Database surface satisfies no owner-scoped Row-Level Security policy — a job that queries such a table gets nothing back rather than an error. Use Database.$asService(), as in the example above.

Where a job actually runs

The scheduler is part of the platform beside your code; only your runtime can execute a decorated TypeScript class. So the scheduler posts the job's name to POST /_internal/job/run on the runtime's probe listener, never on the port that serves your API — a URL that runs your job must not be reachable from the internet. The run executes inside the same service scope the request path uses, which is why the singletons resolve at all.

A name the loaded bundle does not carry answers 404, not 500. That distinction is deliberate: it means a definition outlived its code — a deploy that half-landed — rather than a fault inside your job, and the run history records it as such.

While a run is in flight the scheduler waits for timeout + 15 seconds, so the runtime is the side that reports a timeout by name rather than the caller guessing.

Run semantics

  • Runs never overlap. If a run is still in progress when the next tick arrives, that tick is skipped and recorded as skipped_overlap. The skipped tick is not queued for later, so a job that takes 10 minutes on a */5 * * * * schedule effectively runs every 10 minutes.
  • Failed runs are retried. A run that fails or times out is retried with backoff — first after 5 seconds, doubling, never longer than 60 — up to the job's retry limit. Each attempt is its own row in the run history, so "succeeded on the third try" and "succeeded first time" read differently afterwards.
  • A tick that is too late is skipped, not caught up. Due times are checked against a grace window of 5 minutes. A due time inside the window still runs; one older than it is recorded as skipped_stale and the job moves on to its next scheduled time. After a three-hour outage you get the next scheduled run, not six backlogged ones at once.
  • Start times are approximate. The scheduler polls for due work every 10 seconds and takes at most 100 jobs per tick, so a run starts within a few seconds after its scheduled minute rather than exactly on it.
  • Run history is kept for 30 days. An operator reads it from the stack at GET /v1/jobs and GET /v1/jobs/{name}/runs; both are admin-only.
  • UTC, as above.

Warning: A job that exceeds its timeout is recorded as timed out and abandoned — not killed. The platform stops waiting; JavaScript cannot stop work already in flight, so whatever the job was doing may keep going until it finishes on its own, and a later run can overlap it even though the scheduler thinks it did not. Anything half-finished stays half-finished and there is no rollback across a run. Write jobs that are idempotent and that record progress per item.

Note: On a stack you host yourself, the scheduler is a module that has to be mounted. Both shipped compose files name jobs in --modules, and if the stack cannot resolve its runtime's address it refuses to start the scheduler and says so — definitions are still written, but nothing fires.

Environments and schedules

Jobs travel with a deploy, so each Environment runs its own copy against its own database. The same job in two Environments is two independent schedules, and neither knows about the other.

The deploy is also what defines the job set. Changing name retires one row and creates another; deleting the class — or dropping it from the module's providers — unschedules it on the next push, and the removed job's run history is kept. Changing schedule moves the next run; pushing without changing it leaves the next run where it was, so a deploy every ten minutes never keeps pushing an hourly job's hour forward.

Long-running work: make the batch resumable

The 300-second ceiling is a hard limit and there is nothing to hand overflow to — a job is the only background rail, and there is no queue to push individual items onto. So the shape that works is a batch that can stop anywhere and resume: select only the unfinished rows, and mark each one as it succeeds.

// modules/orders/notify-pending-orders.job.ts
import { Database, Job, Log, Notifications, type JobMeta } from "@palbase/backend";

@Job({ name: "notify-pending-orders", schedule: "0 8 * * *", timeout: 300 })
export class NotifyPendingOrdersJob {
  async run(meta: JobMeta): Promise<void> {
    const db = Database.$asService();
    // Only what is still outstanding: a run that dies halfway leaves the rest
    // for the next run instead of redoing the whole batch.
    const due = await db.public.orders.findMany({ where: { status: "pending" } });
    let sent = 0;
    for (const order of due.slice(0, 200)) {
      const { error } = await Notifications.push.send({
        to: order.user_id,
        title: "Your order is ready",
        body: `Order ${order.id}`,
      });
      if (error) {
        Log.warn("order notice failed", { id: order.id, code: error.code });
        continue;
      }
      // The marking write happens PER ITEM, and the column it moves is the one
      // the query above filters on — so the next run selects exactly the rest.
      await db.public.orders.update({
        where: { id: order.id },
        set: { status: "notified" },
      });
      sent += 1;
    }
    Log.info(`notified ${sent} of ${due.length} pending orders`);
  }
}

Two properties make that safe under the abandonment rule above: the marking write happens per item rather than at the end, and the slice bounds the work so one run cannot outgrow its timeout as the table grows. If the backlog is larger than a run can clear, the next tick picks up where this one stopped.

  • Modules & Dependency Injection — the providers list that makes a job exist
  • Webhooks — react to a third-party service's events
  • Event Hooks — gate or watch the events this stack raises
  • DatabaseDatabase.$asService() and the typed table surface
  • Secrets — the vault behind meta.env and process.env
  • Deploying — what a push carries, and the gate that refuses a dropped surface
  • Notifications — sending from a job, and what a send needs first