Startup hooks
*.startup.tsx — run code once when the app boots: warm a cache, open a long-lived connection, start a background consumer. Default-export a function; register teardown via onShutdown.
A startup hook runs code once when the app boots — after migrations, once the rpc server is listening. Use it for the long-lived, app-singleton work that doesn't fit any request-driven primitive: warm a cache, open a persistent connection (a message-broker consumer, a websocket to an upstream), start a background interval, or register a process-wide service.
A *.startup.tsx (or *.startup.ts) file default-exports a function — there's no define* wrapper:
// startup/warmCache.startup.ts
import type { StartupContext } from '@voltro/runtime'
export default async ({ store, log, onShutdown, id }: StartupContext) => {
log.info('warming the dashboard cache')
const timer = setInterval(() => void refreshDashboardCache(store), 60_000)
// Register teardown — runs on SIGTERM / SIGINT, LIFO across all startups.
onShutdown(() => clearInterval(timer))
}Discovery walks every *.startup.tsx; the default export must be a function. Multiple startups in one app are fine — each gets its own scope and is torn down independently.
The context
interface StartupContext {
readonly store: DataStore // already-migrated
readonly log: SyncLogger // scope=startup:<id>
readonly onShutdown: (cb: () => void | Promise<void>) => void // register teardown
readonly id: string // basename without .startup.tsx
}storeis the same frameworkDataStorehandlers use — already migrated by the time the hook runs.logis scoped to the file's basename, sovoltro logs --filter startup:<id>isolates a hook's output.onShutdown(cb)registers a teardown callback. OnSIGTERM/SIGINTthe framework runs every registered callback in reverse order (LIFO), awaiting each — with a hard 5s timeout per callback so a hung teardown can't block shutdown. Always release what you acquire here.idis the stable basename (no.startup.tsx), handy for keys / log scoping.
Lifecycle
- Runs once, at boot — after the schema is migrated and the rpc server is listening. A throw/rejection is logged and does NOT block boot; the rpc surface stays up regardless.
- Long-lived — the function may hold sockets, intervals, or a fiber that resolves only on shutdown. That's the point — startups are for persistent process-wide work, unlike seeds which run once and return.
- Torn down cleanly — every
onShutdowncallback fires on process exit (LIFO), so connections and timers release without leaking across supervisor restarts.
When to use it vs the alternatives
| Need | Use |
|---|---|
| Seed rows once at boot, then return | *.seed.ts |
| Hold a long-lived connection / interval / background consumer for the process lifetime | *.startup.tsx (this) |
| React to every commit on a table | *.subscribe.ts |
| Periodic work on a cron schedule | *.cron.tsx |
| Durable, crash-surviving multi-step work | *.workflow.tsx |
If the work is "do X once and finish", it's a seed. If it's "keep X running until the process stops", it's a startup.