Vercube 1.4 is out, and it is the biggest release so far. Two new packages: @vercube/scheduler runs cron and interval jobs once per occurrence however many replicas you scale to, and @vercube/testing boots your real application for a test without opening a port. Under them, the application learned to shut down properly, and a long list of places where a request was handled almost right are now handled right.
Scheduler
The problem a scheduler inside a web app has to solve is not the cron expression. It is that the app runs as four pods, and a nightly billing job written as a setInterval runs four times. @vercube/scheduler separates when something runs from who runs it: every replica computes the same schedule from the same code, and a shared store decides which one takes each occurrence.
import { Inject } from '@vercube/di';
import { Cron, Every } from '@vercube/scheduler';
import { InvoiceService } from '../services/InvoiceService';
import type { SchedulerTypes } from '@vercube/scheduler';
export class BillingJobs {
@Inject(InvoiceService)
private gInvoices!: InvoiceService;
// once per day across the whole cluster, on Warsaw wall clock time
@Cron('0 3 * * *', { name: 'billing.close-day', timezone: 'Europe/Warsaw' })
public async closeDay(ctx: SchedulerTypes.JobContext): Promise<void> {
for (const batch of await this.gInvoices.openBatches()) {
ctx.signal.throwIfAborted();
await this.gInvoices.close(batch, { idempotencyKey: ctx.occurrenceId });
}
}
// every replica clears its own cache
@Every('5m', { scope: 'instance' })
public flushLocalCache(): void {
this.gInvoices.clearCache();
}
}
A job class is an ordinary container service, bound where you bind your controllers. The plugin takes the store:
import { defineConfig } from '@vercube/core';
import { defineSchedulerStore, SchedulerPlugin } from '@vercube/scheduler';
import { RedisStore } from '@vercube/scheduler/stores/RedisStore';
export default defineConfig({
plugins: [
[SchedulerPlugin, {
store: defineSchedulerStore({ store: RedisStore, initOptions: { url: process.env.REDIS_URL } }),
}],
],
});
RedisStore claims an occurrence with SET NX PX and touches a single key per operation, so Redis Cluster works without hash tags. PostgresStore keeps claims in two tables and computes every expiry with the database's now(), so pods whose clocks drift apart still agree on when a claim expires. I left advisory locks out on purpose: they belong to a session, and that breaks behind a connection pool.
The decision I care about most is what happens without a shared store. MemoryStore only coordinates the process it lives in, and with NODE_ENV=production a cluster job on it refuses to start rather than quietly running on every pod. The failure you get is a startup error, not a customer charged four times.
The guarantee is worth stating precisely, because "exactly once" is not something a scheduler can promise. A cluster job runs at most once per occurrence in normal operation, and at least once as long as one replica is up when it is due. The edge is a lost lock: when the heartbeat cannot extend it, ctx.signal is aborted with reason lock-lost, since another replica may take over. That is why the example uses ctx.occurrenceId as an idempotency key and checks the signal between batches.
The rest is what you would otherwise build around it:
- Misfires.
misfire: 'run-once'catches up on the latest occurrence nobody ran, for example while every pod was restarting, through the normal claim, so twenty pods starting together run it once. - Queue targets. For heavy work, the replica that wins the occurrence only enqueues it to
@vercube/queue, and the queue's attempts and backoff apply. - External triggers. On platforms without a long-lived process,
vercube scheduler run billing.close-dayfires a job from a KubernetesCronJob, and still claims the occurrence, so aCronJobthat fires twice runs it once. - Telemetry and devtools. Every run is a
scheduler.runspan. Onecompletedand N-1skipped-claimedper occurrence invercube.scheduler.runsis what proves exactly one replica ran it, and devtools shows the jobs, their next runs and the recent outcomes.
Read more: Scheduler overview · Jobs · Clustering.
Testing
Until now, testing a Vercube app meant choosing between booting the whole thing on a port, with its config file and .env, or unit testing services and hoping the controller, the middleware and the validation in between behaved. @vercube/testing boots the real thing without the parts that make tests slow and leaky.
import { createTestApp } from '@vercube/testing';
import { expect, it } from 'vitest';
it('returns a user', async () => {
await using app = await createTestApp({
controllers: [UserController],
providers: [UserService],
overrides: [[UserRepository, { findById: async (id) => ({ id, name: 'Ada' }) }]],
});
const res = await app.get('/users/1').expect(200);
expect(await res.json()).toEqual({ id: '1', name: 'Ada' });
});
Controllers, dependency injection, middleware and validation are real. Requests go through app.fetch(), so no port is opened, and vercube.config.ts and .env are not read unless you ask for them. Every app gets its own container, router and configuration, so test files do not see each other's overrides.
It does not depend on a test runner. The status and header assertions are built in, everything else is your runner's expect, and the same test runs under Vitest, bun test, deno test and node --test with only the import changed. Mocks are whatever you already use.
A few details that came from writing the framework's own tests with it:
- Overrides win over plugins. They are applied after every plugin, so a fake replaces what a plugin bound.
stub()names what you forgot. A partial fake usually fails withundefined is not a functionfar from the cause. A stub fails withUserRepository.save is not stubbed.- Failures are readable. A failed
.expect()prints the request and the response, with credentials withheld. - Logs are assertable.
app.logs.find('error', /order not found/)sees everything the app logged, including what the framework logged for an unhandled error.
Extensions cover the modules that are awkward to fake by hand. testAuth() lets a request run as a user with .as({ id: '1', roles: ['admin'] }), and the scheduler ships a ManualClock, so a test of the 03:00 billing job advances time by a minute instead of waiting for it:
import { withManualClock } from '@vercube/scheduler/testing';
const time = withManualClock('2026-01-01T02:59:00Z');
const app = await createTestApp({ providers: [BillingJobs], extensions: [time] });
await time.clock.advance('1m');
The module tests in the Vercube repository now run through this package, which is the best argument I have that it holds up.
Read more: Testing overview · Requests · Dependencies · Extensions.
Shutting down properly
Both packages above needed something core did not have: a way to stop. app.close() now stops the server from accepting connections, waits for the requests in flight, then runs every callback registered with app.onClose(), last registered first. The scheduler and the queue register their own close there, so on SIGTERM the server stops first, then running jobs drain, then the process exits.
export default defineConfig({
shutdown: {
handleSignals: true,
timeout: 25_000,
},
});
Your own resources use the same hook, and app.onStart() is the counterpart: callbacks run before the port is bound, and one that throws fails listen(), so a database that cannot connect stops the deploy instead of serving errors.
app.onStart(() => pool.connect());
app.onClose(() => pool.end());
await app.listen();
Keep timeout below the pod's terminationGracePeriodSeconds, which is 30 seconds by default, or Kubernetes kills the process before the callbacks run.
Read more: Graceful shutdown.
Thirty fixes to how a request is handled
Writing @vercube/testing meant writing a lot of tests against the request path, and a lot of them found something. None of these was a crash. Each was a request handled almost right, which is the kind of bug that survives longest. The ones you are most likely to have met:
- Global middlewares run everywhere. They now run for
404,405, static files and CORS preflight too, so CORS and security headers reach responses no route produced. A405carries anAllowheader. - The body is parsed by content type, after the before middlewares, so an auth middleware rejects a request with
401before its body is read. JSON, form data and text are parsed. Anything else is415, and a body overserver.bodyLimit(1 MiB by default) is413before it is read. - Errors are JSON, with a
content-typethat says so, and an error only sets the status when it is an error status. A thrown error carryingstatus: 200used to answer with a 200. - Middlewares run in the order you wrote them, and app-wide ones run before route ones at equal priority.
- Repeated query keys:
?tag=a&tag=bresolves to the first value. Ask for all of them witharray: true. - Headers set by middleware survive errors, so a
401from an auth middleware reaches the browser as a401and not as a CORS failure. app.listen()decides whether to listen, instead of theimport.meta.mainguard our examples used. PM2 imports the entry through a wrapper, so with the guard the server never started there, and nothing said so.
The modules got the same treatment. The queue honours each handler's attempts and backoff and fixes AMQP priority, Kafka consumes every topic of a mount with a single group member, storage fails on a mount that does not exist, WebSockets report errors to the peer, the serverless adapter stops corrupting Set-Cookie, and @User() fails closed.
Two small features fell out of it. @Param takes a validationSchema, so path parameters are validated and coerced like query parameters:
@Get('/:userId/posts/:page')
getPosts(
@Param({ name: 'userId', validationSchema: z.string().uuid() }) userId: string,
@Param({ name: 'page', validationSchema: z.coerce.number().int().min(1) }) page: number,
) {}
And @MultipartFormData() parses uploads into strings and Files.
OpenAPI from what the route already says
@vercube/schema used to document what @Schema told it. It now derives what the route already declares: responses from @Status, @Redirect, @SetHeader and @Sse, the operationId and tags from the controller, and a security requirement for routes behind @Auth(). Path parameters are documented with their schema now that @Param has one, and Valibot and ArkType schemas are converted through Standard JSON Schema. With includeAllRoutes: true every route is documented, not only the ones with @Schema.
A schema that cannot be converted is left out with a warning, and a route that fails to generate is skipped, so one bad route no longer takes /_schema/ down with it.
Read more: Schema overview.
Upgrade
Every @vercube/* package is released together and has to be on the same version, so update all of them at once rather than only @vercube/core:
pnpm up "@vercube/*" --latest
Then add the new packages at the same version:
pnpm add @vercube/[email protected]
pnpm add -D @vercube/[email protected]
Most applications upgrade without touching anything. A few behaviours changed on purpose, and they are worth a search through your code:
- A missing
@QueryParamisundefined, notnull. This is the one breaking change.nullbroke.optional()and.default(), and withz.coerce.number()a missinglimitsilently became0. Code that checks=== nullshould use== null, and a schema with.nullable()but no.optional()now rejects a missing parameter. @Body()rejects content types it cannot parse with415, and bodies over 1 MiB with413. Raiseserver.bodyLimitif you accept larger ones.- A repeated query key resolves to its first value. Use
array: truewhere you expect several. - A
@Paramname that is not in the route path fails at registration, rather than resolving toundefinedon every request. - Remove an
if (import.meta.main)guard aroundapp.listen(). It is no longer needed, andVERCUBE_LISTEN=0is how tools that import your entry keep it from listening.
The full list of changes is in the changelog.
I would like to hear how the scheduler holds up on real clusters, and which test runner people actually use with @vercube/testing. Issues and discussions are on GitHub, and I am on X.
