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
}
  • store is the same framework DataStore handlers use — already migrated by the time the hook runs.
  • log is scoped to the file's basename, so voltro logs --filter startup:<id> isolates a hook's output.
  • onShutdown(cb) registers a teardown callback. On SIGTERM / SIGINT the 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.
  • id is 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 failure REFUSES the boot. A throw or rejection aborts startup, naming the file and the cause. So do a startup file that cannot be imported, and one with no default-exported function. This used to be a warning that let the server come up, and the reason it changed is that a startup is where an app REGISTERS things the request path depends on — setRowFilter above all. A server that came up without its row filter looked healthy and was not. If a failure is genuinely acceptable for one startup, catch it inside that function, where a reviewer can see the decision.
  • Long-lived in effect, but the function RETURNS. Start the work, hand the teardown to onShutdown, return. That's the point — startups are for persistent process-wide work, unlike seeds which run once and return. A function that never returns refuses the boot after VOLTRO_STARTUP_TIMEOUT_MS (60s default), because awaiting forever is what made a slow startup that then failed impossible to report: the boot had already moved on, so the failure arrived with nothing left to refuse.
  • Torn down cleanly — every onShutdown callback 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.