# README

* Concurrent multi threadded web server
* Database framework with familiar MVC concepts
* Database models with migrations and validations
* Database models that work almost the same in frontend and backend, including online CRUD for [composite primary keys](docs/composite-primary-keys.md)
* Connection-scoped advisory locks with automatic cleanup before pooled connections are reused or closed (see [docs/advisory-locks.md](docs/advisory-locks.md))
* Built-in record auditing for model lifecycle changes (see [docs/auditing.md](docs/auditing.md))
* Declarative state machines for models, with typed event methods generated into the base model (see [docs/state-machine.md](docs/state-machine.md))
* Migrations for schema changes and UTC datetime storage, including caller-selected `pre-runtime` and `post-publication` execution sets and recorded `changeTable` batches that combine operations into one `ALTER` on bulk-capable drivers (see [docs/database-migrations.md](docs/database-migrations.md), [docs/migration-execution-phases.md](docs/migration-execution-phases.md), and [docs/change-table.md](docs/change-table.md))
* Tenant-selected base-model and structure generation with one immutable, fail-closed physical database context; tenant-only model metadata initializes only after that context is active (see [docs/tenant-selected-database-generation.md](docs/tenant-selected-database-generation.md))
* Read-only tenant migration deploy preflight with stable JSON output and fail-closed ledger reads (see [docs/tenant-migration-deploy-preflight.md](docs/tenant-migration-deploy-preflight.md))
* External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
* Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
* Controllers and views for HTTP endpoints
* Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, including committed counter-cache parent updates, structured per-attribute validation error responses, immutable per-operation remote request context, registration-local tenant subscription partitioning, and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/websocket-channels.md](docs/websocket-channels.md))
* Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
* Declarative client sync scopes with per-scope cursors, generation-fenced identity replacement and selected-scope cache reset, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery and reconnect gap closure, computed server-publish scope metadata with atomic null-safe publisher row identities, a shared public maintenance upsert helper and legacy-duplicate reconciliation, and immutable-handle project clients whose local database state plus remote pull/replay/realtime request context stay tenant-bound through reconnect (see [docs/sync-client.md](docs/sync-client.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/offline-sync.md](docs/offline-sync.md))
* Reactive `useLiveQuery(Model.where(...))` queries for default databases plus immutable-handle tenant live-query sources whose committed events and refreshes stay on the captured physical tenant (see [docs/live-queries.md](docs/live-queries.md))
* Server-side sync envelope replay orchestration for app-owned sync receivers, including allowlisted authoritative conflict snapshots that retain submitted aliases in conflict metadata while keying `serverModel` by canonical model attributes (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
* Self-sustaining sync feeds: upstream imports triggered by the changes pull itself, with framework-owned coalescing and throttling (see [docs/sync-upstream-imports.md](docs/sync-upstream-imports.md))
* AwesomeTasks-shaped offline sync proof using routed resources, domain commands, signed offline grants, and peer-forwarded mutations (see the [developer guide](docs/shared-resource-sync-guide.md) and [proof](docs/awesome-tasks-offline-sync-proof.md))
* SQLite web persistence that automatically prefers OPFS, then IndexedDB, and migrates legacy persisted bytes when possible (see [docs/sqlite-web-persistence.md](docs/sqlite-web-persistence.md))
* Bounded frontend tenant SQLite handles with independently deduplicated per-database migrations/model readiness, React lifecycle integration, durable flush/close, backend-complete deletion, clean-only LRU eviction, and scoped pins (see [docs/frontend-tenant-sqlite-lifecycle.md](docs/frontend-tenant-sqlite-lifecycle.md))
* Expo / Metro compatibility guidance and a real Expo export check (see [docs/expo-metro-compatibility.md](docs/expo-metro-compatibility.md))
* Gap-less positional lists with automatic reordering via `actsAsList`, including models with numeric, string, or UUID primary keys (see [docs/acts-as-list.md](docs/acts-as-list.md))
* Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
* Async-aware test-data factories with inherited traits, graph-first native association autosave, metadata-aware override precedence, callbacks, sequences, linting, and a process-global reload-retention budget that bounds cache-busted re-import memory (see [docs/factories.md](docs/factories.md))
* Opt-in Benchmark-style test profiling with privacy-safe rich JSON, directly reusable duration-aware shard manifests, and strict generic shard-profile aggregation (see [docs/test-profiling.md](docs/test-profiling.md))
* Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters, safe batching of structurally identical aggregates, and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
* Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
* Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
* Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
* Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
* Websocket channel primitives for 1:N pub/sub over the shared session socket (`channel-subscribe` protocol): `matches()`-routed streams, interest-gated event-log persistence with 10-minute retention, stream-scoped `lastEventId` replay with `channel-replay-gap` gap reporting, and `{liveOnly: true}` channels that never reach the replay log (see [docs/websocket-channels.md](docs/websocket-channels.md))
* Rails-style application process initializer teardown with immutable process identity, reverse idempotent shutdown, and explicit HTTP/background-job ownership (see [docs/application-process-lifecycle.md](docs/application-process-lifecycle.md))
* Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
* Explicit database-free in-process HTTP applications with optional request and buffered-response byte limits (see [docs/http-server.md](docs/http-server.md#database-free-applications))
* Default-on buffered HTTP response compression with Brotli/gzip content negotiation, global and per-response opt-outs, and HEAD-correct representation headers (see [docs/http-server.md](docs/http-server.md#response-compression))
* Background jobs with Node SQL/TCP workers plus a Browser/Expo local SQLite store and in-process dispatcher, including failure events, authorized database-scoped dashboard counts, and an opt-in release-scoped main/worker generation protocol with acknowledged activation, asynchronous retirement, and retired-main recovery. Production compliance additionally requires downstream supervisor retention/activation ordering and release pins (see [docs/background-jobs.md](docs/background-jobs.md), [docs/local-background-jobs.md](docs/local-background-jobs.md), and [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md))
* Durable one-off background-job scheduling with exact epoch timestamps plus stable replacement, cancellation, normalized readback, and duplicate-free wake across Node SQL/TCP and Browser/Expo local SQLite adapters (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
* Rails-style request and database query logging with structured credential redaction (see [docs/logging.md](docs/logging.md))
* EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
* Trusted reverse proxy handling for `request.remoteAddress()` (see [docs/trusted-proxies.md](docs/trusted-proxies.md))
* In-process driver schema metadata caching (see [docs/schema-metadata-cache.md](docs/schema-metadata-cache.md))
* Planned local-first shared-resource sync architecture (see [docs/offline-sync.md](docs/offline-sync.md))
* Selective named database connection checkouts, bounded pool waits, debugging held connections, and checkout-scoped MySQL/MariaDB raw session state (see [docs/database-connections.md](docs/database-connections.md))
* Explicit singular-database operation transactions whose model scopes preserve ownership through records, relationships, lifecycle work, nested savepoints, pre-commit guards, and commit callbacks (see [docs/operation-scoped-transactions.md](docs/operation-scoped-transactions.md))
* AbortSignal-driven MySQL/MariaDB query cancellation for raw, model, and cross-tenant aggregate queries (see [docs/database-query-cancellation.md](docs/database-query-cancellation.md))
* Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
* Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
* Backend record attachments with filesystem, S3, native callback, bounded Node path-input persistence, and model-declared client sync policy (see [docs/attachments.md](docs/attachments.md))

# Setup

Make a new NPM project.
```bash
mkdir project
cd project
npm install velocious
npx velocious init
```

Pinned Git commits can be installed without lifecycle scripts by using a GitHub
commit archive. Velocious checks in generated `build/` output for this purpose;
see [Git dependency installation](docs/git-installation.md).

By default, Velocious looks for your configuration in `src/config/configuration.js`. If you keep the configuration elsewhere, make sure your app imports it early and calls `configuration.setCurrent()`.

Application initializers may implement `teardown()` and inspect their frozen
`getProcessContext()` value. Long-lived process owners call
`configuration.shutdown()` before framework connection cleanup; see the
[application process lifecycle guide](docs/application-process-lifecycle.md) for
promise identity, errors, process types, and pooled/forked runner semantics.

# Node SQLite driver

Projects using `velocious/build/src/database/drivers/sqlite/index.js` must install its optional peer dependencies:

```bash
npm install sqlite sqlite3
```

Projects that do not use the Node SQLite driver do not need these packages. Browser and Expo SQLite drivers use their platform-specific dependencies instead.

# Operation-scoped transactions

Use `configuration.withTransaction` for an atomic unit of model work on one database:

```js
await configuration.withTransaction({databaseIdentifier: "default", name: "accept ticket"}, async (operation) => {
  const ticket = await operation.forModel(Ticket).find(ticketId)

  ticket.setAccepted(true)
  await ticket.save()

  await operation.beforeCommit(async ({operation: guardedOperation}) => {
    const currentTicket = await guardedOperation
      .forModel(Ticket)
      .findByOrFail({id: ticketId})

    if (!currentTicket.acceptanceStillOwnedBy(workerId)) {
      throw new Error("Ticket acceptance ownership changed")
    }
  })

  await operation.afterCommit(async () => {
    await publishAcceptedTicket(ticket.id())
  })
})
```

Use operation-bound model scopes and their loaded records throughout the callback. `operation.beforeCommit` runs a final operation-owned guard after callback success but before outer commit or nested savepoint release; a rejection rolls back that frame. `operation.transaction` adds a nested savepoint, and `operation.connection()` is the deliberate escape hatch for owned raw SQL. Cross-database models, same-identifier tenant switches to another physical database, and operation handles used after the callback are rejected. On shared SQLite/SQL.js pools, unrelated work waits for the operation lease, while admission during an already-open ordinary transaction is rejected. See [operation-scoped transactions](docs/operation-scoped-transactions.md) for guard, pool, after-commit failure, and migration semantics.

# Development

When working on Velocious itself, npm scripts are cross-platform (Windows `cmd`/PowerShell and POSIX shells):

```bash
npm run build
npm run test
npm run test:expo
```

Maintainers cutting a package release must follow the [Velocious release runbook](docs/releasing.md); `npm run release:patch` commits, pushes, and publishes rather than acting as a local-only version command.

# Docker development environment

The checked-in root `Dockerfile` and `compose.yml` define one canonical `dev` service used by humans, CI, and agent systems alike (see [docs/docker-development-environment.md](docs/docker-development-environment.md)). The image is Ubuntu 26.04 LTS (pinned by digest) with Node.js 24.x from signed NodeSource, the universal apt coding/debugging baseline, the newest published retained provider CLIs, and owner-pinned native Qwen Code 0.23.3; it is source-independent — no project source is copied and no project dependencies are installed at image build time.

Prerequisites: Docker with the Compose v2 plugin, and this repository checked out at `$DEV_HOME_PATH/velocious` (default `DEV_HOME_PATH`: `/home/dev`).

First-use setup: copy `.env.example` to the git-ignored `.env`, set `GH_CONFIG_SOURCE_PATH` to an existing host GitHub CLI config directory, set `AI_PROVIDER_RUNTIME_SOURCE_PATH` to the dedicated writable provider runtime, and replace `AGENT_CONTEXT_SOURCE_PATH` with one exact immutable bundle directory:

```bash
cp .env.example .env
```

`$DEV_HOME_PATH` must be a dedicated development home that already exists, holds no credentials or secrets, and is owned by (or at least writable by) UID/GID 1000 — the in-container `dev` user. Do not point it at a general host home directory, and do not recursively chown an existing home; the external environment owns safe initial provisioning.

Normal usage:

```bash
docker compose up --build --detach dev
docker compose exec dev bash
scripts/docker-run.sh npm ci   # one-off command in a disposable container
```

The dev service preserves the complete `$DEV_HOME_PATH` bind at `/home/dev`, so dependencies, lane-local caches, settings, and `node_modules` persist naturally across runs. Codex, Kimi, and OpenCode authentication survives lane recreation through the dedicated provider-runtime bind, while `/opt/hermes-agent-context` supplies one read-only reviewed guide/skills bundle. Install dependencies with the normal package commands inside the service (for example `docker compose exec dev npm ci`), never at image build time.

Concurrent isolated instances use the standard Compose project-name contract plus a distinct development home per instance:

```bash
COMPOSE_PROJECT_NAME=velocious-review DEV_HOME_PATH=/srv/dev-homes/review \
  docker compose up --build --detach dev
```

The authorized credential boundaries are the writable provider runtime and the read-only GitHub CLI config. The normal dev service mounts no npm credentials, SSH keys, `/opt/data`, mutable agent-context discovery links, or broad shared roots. At startup, the provider bootstrap requires real `.local` and `.local/share` runtime directories, validates and preserves the provider runtime's canonical relative aliases, serializes shared provider-link migration, then creates exact lane-home provider and `/opt/hermes-agent-context` discovery links, preserving conflicts only at those managed targets under `~/.provider-runtime-migration-backups/<timestamp>-<unique-suffix>/`. The preflight rejects resolved provider-runtime and agent-context sources that contain one another. Threadwire remains parent orchestration, with its provider executable overrides pointed directly at `/usr/local/bin/codex`, `/usr/local/bin/kimi`, and `/usr/local/bin/opencode`. See the setup guide for source-path preflight and the exact lane-local OpenCode state contract.

After changing the Docker artifacts, run the checked-in static contract verifier:

```bash
npm run verify:docker-dev-environment
```

# Code quality (fallow)

[fallow](https://github.com/fallow-rs/fallow) analyzes the codebase for unused/dead code, duplication, and complexity. CI runs it as a **regression gate**: it fails only on findings beyond the committed baseline in `fallow-baselines/`, so existing backlog never blocks a PR but new issues do.

```bash
# Regression gate (what CI runs) — fails on new dead code / dupes / complexity beyond the baseline
npm run fallow

# Refresh the baseline after intentionally adding/removing code (commit the updated fallow-baselines/*.json)
npm run fallow:baseline
```

Baselines are generated against a fresh checkout (no generated dummy `configuration.js`), so the gate is deterministic in CI and locally. Tuning (entry points, ignores) lives in `.fallowrc.json`.

# Testing

Application tests may import the testing DSL from the independent public package.
`@velocious/testing` `0.0.17` owns framework-neutral expectations, matchers,
equality, change observation, mocks, discovery, filtering, execution, reporting,
deterministic weighted grouping, profiling, and timing manifests. Compatible
installed copies share one protocol-1/schema-3 default registry.
Velocious retains application/configuration startup, database and transaction
lifecycle, browser execution, environment handling, framework profile spans, and
report translation. Existing Velocious import paths remain supported as thin
delegating compatibility facades. Package diagnostics are canonical when legacy
facades differ only in wording or formatting.

```js
import {describe, expect, it} from "@velocious/testing"

describe("Tasks", () => {
  it("adds a task", () => expect(1 + 1).toEqual(2))
})
```

The dummy database configurations require `MSSQL_SA_PASSWORD` whenever they include
the shared MSSQL test database. Set it in the local process environment rather than
writing the password into `spec/dummy/src/config/configuration*.js`. TensorBuzz CI
provides one shared test value to both the build and MSSQL service environments.

Tag tests to filter runs.

```js
describe("Tasks", {tags: ["db"]}, () => {
  it("creates a task", {tags: ["fast"]}, async () => {})
})
```

```bash
# Only run tagged tests (focused tests still run)
npx velocious test --tag fast
npx velocious test --include-tag fast,api

# Exclude tagged tests (always wins)
npx velocious test --exclude-tag slow
```

`*.browser-spec.js` files remain eligible for both the Node database matrix and
the browser runner. Add the reserved `browser-only` tag only when a suite requires
the real browser environment; the Node runner discovers the file but does not
execute its tagged tests.

```js
describe("Browser integration", {tags: ["browser-only"]}, () => {
  it("uses browser runtime behavior", async () => {})
})
```

Target a test by line number or description.

```bash
npx velocious test spec/path/to/test-spec.js:34
npx velocious test --example "filters on nested relationship attributes"
npx velocious test --example "/nested.*attributes/i"
npx velocious test --name "filters on nested relationship attributes"
```

Exclude tags via your testing config file.

```js
// src/config/testing.js
import {configureTests} from "velocious/build/src/testing/test.js"

export default async function configureTesting() {
  configureTests({excludeTags: ["mssql"]})
}
```

Retry flaky tests by setting a retry count on the test args, or set CLI defaults for
retries and lifecycle timeout. Import repeatable global setup before the testing
configuration and test declarations with `--setup`.

```js
describe("Tasks", () => {
it("retries a flaky check", {retry: 2}, async () => {})
})
```

```bash
npx velocious test --retry=2 --timeout=30000 --setup=spec/setup.js spec/
```

Velocious prints the slowest tests after every run so suite hotspots are easy to spot. Each line shows the duration, full description and `file:line`.

```bash
# Default: the 10 slowest tests are printed after the run summary.
npx velocious test

# Report the 25 slowest tests instead.
VELOCIOUS_SLOW_TEST_COUNT=25 npx velocious test

# Disable the report.
VELOCIOUS_SLOW_TEST_COUNT=0 npx velocious test
```

```
Slowest 10 tests:
    1145ms  Background jobs - store backfills execution modes for legacy queued jobs (spec/background-jobs/store-spec.js:984)
     913ms  Background jobs - store prunes completed rows past the retention window (spec/background-jobs/store-spec.js:825)
    ...
```

The report is skipped for single-test runs. See [docs/testing-guidelines.md](docs/testing-guidelines.md).

Add `--profile` for a compact Benchmark-style phase and pool summary. Use
`--profile-json <path>` for versioned, privacy-safe detail or
`--timing-manifest-output <path>` to generate a sorted per-file duration map for
the existing `--timing-manifest` shard input; either output flag implies
profiling.

```bash
npx velocious test --profile-json tmp/test-profile.json \
  --timing-manifest-output tmp/test-timings.json
npx velocious test --groups=4 --group-number=1 \
  --timing-manifest tmp/test-timings.json

# After every shard wrote rich JSON, merge the complete set for the next run
npx velocious test:timing-manifest:merge --output tmp/test-timings.json \
  tmp/profile-1.json tmp/profile-2.json tmp/profile-3.json tmp/profile-4.json
```

See [test profiling](docs/test-profiling.md) for lifecycle accounting, custom
activity spans, schema, and privacy guarantees.

Prefer waiting for a real signal or condition over sleeping a fixed duration. `waitForEvent(emitter, eventName, {timeoutMs, filter})` resolves the instant a matching event fires (a background job finishing, a model update, a websocket message) and rejects on timeout; for polling an arbitrary condition, use awaitery's `waitFor`. Both `@velocious/testing` and the backward-compatible `velocious/build/src/testing/test.js` facade are supported imports; the Velocious runner consumes public-package declarations and adds the framework-specific startup, database, request, browser, profile-span, and cleanup behavior.

```js
import {waitForEvent} from "velocious/build/src/testing/test.js"
import waitFor from "awaitery/build/wait-for.js"

// Event-driven: resolves as soon as the matching event fires (no polling latency).
const {result} = await waitForEvent(jobRunner, "jobFinished", {filter: (event) => event.jobId === jobId})

// Condition polling: retries the callback until it stops throwing.
await waitFor(() => expect(await Message.count()).toEqual(1))
```

Velocious captures console output emitted while each test executes, but does not print passing-test output by default. When a test fails, Velocious prints a truncated `Console output:` block for that failed test and saves the full captured log under `tmp/screenshots` next to failure screenshots/browser logs/HTML. Each failed test summary prints the saved console log path.

Configure console output behavior in your testing config file.

```js
// src/config/testing.js
import {configureTests} from "velocious/build/src/testing/test.js"

export default async function configureTesting() {
  configureTests({
    consoleOutput: "failure", // default: print captured output only for failed tests
    failedConsoleOutputMaxLines: 200 // default: print the last 200 lines inline
  })
}
```

Use `consoleOutput: "live"` to preserve the previous passthrough behavior where test console output is printed while tests run.

Listen for attempt and retry events if you need to reset shared state after a failed attempt or log retry lifecycle details. `testAttemptFailed` fires after every failed attempt, including the final failed attempt when no retries remain. `testRetrying` only fires before a retry, and `testFailed` only fires after retries are exhausted.

A shared resource owner may throw an error carrying `terminalResource: {scope: "run", name: "shared-resource"}`. With a compatible `@velocious/testing` runner, Velocious preserves this contract through causes and aggregate errors, reports the originating failure with full causal stacks, suppresses retries, and emits `testNotRun` for later selected cases instead of running their callbacks. `testNotRun` receives `{configuration, test, testRunner}`; `test` has `status: "not-run"`, `fullName`, `location`, and the originating `reason`. `testRunner.getNotRunTests()` and `getNotRunTestDetails()` expose this separate accounting. Entered suites clean up once; the CLI exits unsuccessfully even for terminal setup with zero executed cases. Unmatched filters retain their explanatory message and `no-tests` profile status, independently of terminal setup failures. This does not restart a browser or cancel already-issued work. Upgrade the shared runner and this reporter adapter together before enabling terminal resource owners; earlier shared runners ignore the marker. See [terminal resource reporting](docs/terminal-resource-reporting.md).

```js
import {testEvents} from "velocious/build/src/testing/test.js"

testEvents.on("testAttemptFailed", async ({testDescription, attemptNumber, willRetry}) => {
  console.log(`Failed ${testDescription} attempt ${attemptNumber}`)

  if (willRetry) {
    await resetBrowserOrExternalServices()
  }
})

testEvents.on("testRetrying", ({testDescription, nextAttempt}) => {
  console.log(`Retrying ${testDescription} (attempt ${nextAttempt})`)
})

testEvents.on("testRetried", ({testDescription, attemptNumber}) => {
  console.log(`Retry attempt finished for ${testDescription} (attempt ${attemptNumber})`)
})
```

## Parallel test splitting

Split test files across parallel CI jobs using `--groups` and `--group-number`.

```bash
# Run group 1 of 4
npx velocious test --groups=4 --group-number=1

# Run group 2 of 4
npx velocious test --groups=4 --group-number=2

# Combine with tags
npx velocious test --groups=3 --group-number=1 --tag fast

# Prefer recorded file durations when balancing groups
npx velocious test --groups=4 --group-number=1 --timing-manifest=tmp/test-timings.json
```

Files are distributed using a greedy load-balancing algorithm. Each file is
primarily weighted by a positive finite duration from the optional timing
manifest. Manifest keys are normalized project-relative test paths using `/`
separators:

```json
{
  "spec/system/sign-in-spec.js": 42.7,
  "spec/controller/accounts-spec.js": 8.1
}
```

Files absent from the manifest and zero-duration entries use the existing
deterministic heuristic; stale entries are ignored. When `--timing-manifest` is
explicitly supplied, its file must be readable and contain a plain JSON object
with canonical relative paths and finite non-negative durations. Invalid input
fails the command even without sharding flags. A compact
measured/heuristic/stale summary reports coverage.
The heuristic weights files by spec directory (`system/` = 20,
`frontend-models/` = 10, `controller/` = 3, default = 1) with a 2x multiplier
for `.browser-spec.js` files. The heaviest files are assigned first to the group
with the least accumulated weight, producing balanced wall-clock times across
groups.

The algorithm is deterministic: the same file list always produces the same
group assignments.

## Browser system tests

Run browser compatibility tests via System Testing:

```bash
npm run test:browser
```

Browser system tests must be named `*.browser-test.js` or `*.browser-spec.js` (override with `VELOCIOUS_BROWSER_TEST_PATTERN`).
The runner validates and persists the exact Chrome/ChromeDriver pair selected by `scripts/prewarm-chromedriver.js`, then owns ChromeDriver and Chrome as a managed process group. Startup failures report the runtime paths and versions, service URL, retained logs under `tmp/browser-test-chrome/`, and Chrome process state before and after cleanup.

Use beforeAll/afterAll for suite-level setup/teardown.

```js
// src/config/testing.js
export default async function configureTesting() {
  beforeAll(async () => {
    // setup shared resources
  })

  afterAll(async () => {
    // teardown shared resources
  })
}
```

## Expectations

Common matchers:

```js
expect(value).toBeTruthy()
expect(value).toMatchObject({status: "success"})
expect({a: 1, b: 2}).toEqual(expect.objectContaining({a: 1}))
expect([1, 2, 3]).toEqual(expect.arrayContaining([2, 3]))
```

# Mailers

Mailers live under `src/mailers`, with a `mailer.js` and matching `.ejs` templates.

```js
import VelociousMailer, {deliveries, setDeliveryHandler} from "velocious/build/src/mailer.js"

class TasksMailer extends VelociousMailer {
  newNotification(task, user) {
    this.task = task
    this.user = user
    this.assignView({task, user})
    return this.mail({to: user.email(), subject: "New task"})
  }
}
```

Velocious infers the action name from the mailer action method when `this.mail(...)` is called from that method. Pass `actionName` explicitly when rendering a different action template or when a shared helper should override the inferred action.

```ejs
<b>Hello <%= mailer.user.name() %></b>
<p>
  Task <%= task.id() %> has just been created.
</p>
```

Deliver immediately or enqueue via background jobs:

```js
await new TasksMailer().newNotification(task, user).deliverNow()
await new TasksMailer().newNotification(task, user).deliverLater()
```

For a provider that advertises duplicate suppression, a producer can require one stable mail operation across outbox replay and native-job retries:

```js
await new TasksMailer().newNotification(task, user).deliverLater({
  deliveryOperation: {
    id: `project-command:${command.id()}`,
    idempotency: "required"
  }
})
```

Required delivery fails before enqueue on unsupported backends, rejects the same id with changed rendered content, and fails closed after the provider retention window. Direct required `deliverPayload()` calls also fail before provider I/O when the background-jobs database connection is already inside a caller-owned transaction, because an outer rollback could erase the first-attempt marker. Generic SMTP remains at-least-once. Velocious includes a dedicated `ResendSmtpMailerBackend` for Resend's 24-hour `Resend-Idempotency-Key` contract; see [Mailers](docs/mailers.md#provider-backed-idempotent-background-delivery) for setup, expiry, reconciliation, and non-exactly-once guarantees.

Build the rendered payload without sending when the app needs to store an audit copy or hand delivery to its own transport:

```js
const payload = await new TasksMailer().newNotification(task, user).buildPayload()
```

If your mailer needs async setup, keep the action sync and pass `actionPromise`:

```js
resetPassword(user) {
  return this.mail({
    to: user.email(),
    subject: "Reset your password",
    actionPromise: (async () => {
      this.token = await user.resetToken()
      this.assignView({user, token: this.token})
    })()
  })
}
```

Configure a delivery handler for non-test environments:

```js
setDeliveryHandler(async ({to, subject, html}) => {
  // send the email via your provider
})
```

Mailer backends can also be configured via your app configuration.

```js
import {SmtpMailerBackend} from "velocious/build/src/mailer.js"

export default new Configuration({
  mailerBackend: new SmtpMailerBackend({
    connectionOptions: {
      host: "smtp.example.com",
      port: 587,
      secure: false,
      auth: {user: "smtp-user", pass: "smtp-pass"}
    },
    defaultFrom: "no-reply@example.com"
  })
})
```

Install the SMTP peer dependency in your app:

```bash
npm install smtp-connection
```

When `connectionOptions.auth` is present, the SMTP backend authenticates before sending the message.

Test deliveries are stored in memory:

```js
const sent = deliveries()
```

# Translations

Velocious uses gettext-universal by default. Configure your locales and fallbacks in the app configuration:

```js
export default new Configuration({
  locale: () => "en",
  locales: ["en"],
  localeFallbacks: {en: ["en"]}
})
```

Load compiled translations for gettext-universal (for example, JS files generated from .po files):

```js
import gettextConfig from "gettext-universal/build/src/config.js"
import en from "./locales/en.js"

Object.assign(gettextConfig.getLocales(), {en})
```

Use translations in mailer views with `_`:

```ejs
<b><%= _("Hello %{userName}", {userName}) %></b>
```

If you want a different translation backend, set a custom translator:

```js
configuration.setTranslator((msgID, args) => {
  // return translated string
})
```

# Models

```bash
npx velocious g:model Account
npx velocious g:model Task
```

## Frontend models from backend resources

You can generate lightweight frontend model classes from resource definitions in your configuration.

```js
import FrontendModelBaseResource from "velocious/build/src/frontend-model-resource/base-resource.js"

class UserResource extends FrontendModelBaseResource {
  static resourceConfig() {
    return {
      attributes: ["id", "name", "email"],
      relationships: {
        projects: {type: "hasMany", model: "Project"}
      }
    }
  }
}

export default new Configuration({
  // ...
  backendProjects: [
    {
      path: "/path/to/backend-project",
      frontendModels: {
        User: UserResource
      }
    }
  ]
})
```

`frontendModels` entries must be `FrontendModelBaseResource` subclasses. Built-in CRUD/find/index/serialize behavior lives in the base class, and app resources override only the pieces they actually need.
Resource-level index customization should prefer `indexQuery()` or the pagination/search/sort hooks over replacing `records()`, so built-in pluck and aggregate count support can keep using the same query. See [`docs/frontend-model-resources.md`](docs/frontend-model-resources.md) for the resource extension points.

Custom class- and instance-level commands are declared via `collectionCommands` / `memberCommands`. Each entry is a plain camelCase method name, or a `{name, args?, returnType?}` object that types the command's arguments and response — e.g. `memberCommands: ["suspend", {name: "refresh", args: [{name: "age", type: "number"}], returnType: "string"}]`. Plain string commands derive args and return types from the backend resource method JSDoc; shared DTO `@import` types outside the backend `src` tree are preserved in generated frontend models, while backend-local helper types such as `ReturnType<typeof serializePayload>` are rejected. A command whose args are a single object literal with only optional fields generates an omittable parameter (`record.command()` works without passing `{}`); any required field keeps the argument mandatory. See [`docs/frontend-model-resources.md#custom-commands`](docs/frontend-model-resources.md#custom-commands).

Resources expose the full CRUD ability set (`create`, `destroy`, `read`, `update`) by default. To restrict the API surface — for example to a read-only resource — declare an explicit subset:

```js
class AuditLogResource extends FrontendModelBaseResource {
  static abilities = ["read"]
  static attributes = ["id", "message", "createdAt"]
}
```

Generate classes:

```bash
npx velocious g:frontend-models
```

Frontend-model attributes can usually be declared by name. The generator infers JSDoc typedefs and nullability from backend model columns and translated attribute columns. When an attribute entry needs resource-specific options such as `selectedByDefault: false`, keep only that option in the resource config, for example `{name: "archivedAt", selectedByDefault: false}`; the column type and nullability are still inferred. For computed resource attributes, add a typed `${attributeName}Attribute(model)` method with an `@returns` tag in the backend project's `src` tree. Resource attribute return types take precedence over column types because the resource method controls the serialized value. If Velocious cannot infer a read attribute from a column, generated model accessor, resource method JSDoc, or explicit metadata, generation fails with a clear error instead of emitting a broad fallback type.

This creates `src/frontend-models/user.js` (and one file per configured resource). Import each model directly by its file path (e.g. `import User from ".../frontend-models/user.js"`); named and default exports remain class declarations, so they can be constructed and referenced as instance types. `src/frontend-models/setup.js` side-effect-imports every model file so they self-register (import it once at app startup). No barrel/`index.js` is generated. Every generated file — the per-model files and `setup.js` here, and the base-model files from `g:base-models` — starts with an auto-generated banner stating it must not be edited manually because changes are overwritten on the next regeneration, and naming the command that regenerates it. Apply changes at their source (resource/model definitions or the generator) and regenerate. Generated classes support:

- `await User.find(5)`
- `await User.findBy({email: "john@example.com"})`
- `await User.findByOrFail({email: "john@example.com"})`
- `await User.toArray()`
- `await User.create({name: "John"})`
- `await Task.sort("-createdAt").toArray()`
- `await Task.order("-createdAt").toArray()`
- `await Task.limit(10).offset(20).toArray()`
- `await Task.page(2).perPage(25).toArray()`
- `await Task.where({project: {creatingUser: {reference: "owner-b"}}}).toArray()`
- `await Task.joins({project: {creatingUser: true}}).where({project: {creatingUser: {reference: "owner-b"}}}).toArray()`
- `await Task.sort({project: {creatingUser: ["reference", "desc"]}}).toArray()`
- `await Task.sort({project: {account: [["name", "desc"], ["createdAt", "asc"]]}}).toArray()`
- `await Task.group({project: {account: ["id"]}}).toArray()`
- `await Task.sort({comments: ["body", "asc"]}).distinct().toArray()`
- `await Task.count()`
- `await Task.pluck("id")`
- `await Task.pluck({project: ["id"]})`
- `await User.preload({projects: ["tasks"]}).toArray()`
- `await Task.load()`
- `await Project`
  `.preload(["tasks"])`
  `.select({Project: ["id", "createdAt"], Task: ["updatedAt"]})`
  `.toArray()`
- `await user.update({...})`
- `await user.save()` (persists new records and updates existing records; also carries dirty nested children through the single request when the parent opts in — see [docs/nested-attributes.md](docs/nested-attributes.md))
- `await user.destroy()`
- `user.markForDestruction()` to queue a loaded child for destruction on the next parent save (see [docs/nested-attributes.md](docs/nested-attributes.md))
- State helpers like `user.isNewRecord()`, `user.isPersisted()`, `user.isChanged()`, and `user.changes()`
- Attribute methods like `user.name()` and `user.setName(...)`
- Relationship helpers (when `relationships` are configured), for example `task.project()`, `await task.projectOrLoad()`, `await project.tasks().toArray()`, `await project.tasks().load()`, and `project.tasks().build({...})`
- Preload relationships onto records you already have with `await record.preload(Model.preload({...}).select({...}))` (or `Preloader.preload(records, ...)` for arrays), including `selectsExtra(...)` and a `{force: true}` reload option — see [docs/frontend-models.md](docs/frontend-models.md#preloading-onto-loaded-records)
- Attachment helpers (when `attachments` are configured), for example `await task.descriptionFile().attach(file)`, `await task.descriptionFile().download()`, `await task.files().purgeAll()`, and `await task.update({descriptionFile: file})`

React components can subscribe to lifecycle broadcasts without manual cleanup code:

```js
import useModelClassEvent from "velocious/build/src/frontend-models/use-model-class-event.js"

useModelClassEvent(Subscription, ["create", "update"], () => {
  void loadSubscriptionStatus()
}, {
  query: Subscription.where({workspaceId}),
  requestContext: {workspaceId}
})
```

`useCreatedEvent`, `useUpdatedEvent`, and `useDestroyedEvent` are also available. `useUpdatedEvent` and `useDestroyedEvent` accept either a model class or model instance. Generated lifecycle callbacks retain the concrete generated model type; scalar event ids are strings and composite event ids are objects keyed by the configured primary-key attributes. Lifecycle subscriptions accept the same projection options as frontend-model queries for event records, including `select`, `preload`, `withCount`, `abilities`, and `queryData`. Pass a registration-local `requestContext` when several tenant routes for the same model can be mounted concurrently. Omitting it inherits the configured transport context; passing `{}` explicitly replaces that context with an unscoped registration. Velocious captures it immutably, sends it to the tenant resolver, and partitions server subscriptions by its value: equal contexts retain multiplexing, while distinct contexts never share an event-filter request. The backend must still authorize the resolved tenant; request context is not proof of access.

Frontend-model `group(...)` is attribute/path based and does not accept raw SQL fragments. Use model/relationship shapes (for example `Task.group({project: {account: ["id"]}})`) so grouping resolves through known relationships and mapped columns.
Frontend-model `where(...)` supports nested relationship descriptors (for example `Task.where({project: {creatingUser: {reference: "owner-b"}}})`) and does not accept raw SQL fragments.
Frontend-model `joins(...)` supports relationship-object descriptors only (for example `Task.joins({project: {creatingUser: true}})`) and rejects raw SQL join strings.
Frontend-model `distinct(...)` only accepts booleans (`true` by default) and is applied server-side through the backend query API.
Frontend-model `pluck(...)` validates attribute/path descriptors against configured resource/model metadata and does not accept SQL fragments or hidden raw model columns when the resource declares an explicit attribute list.
Frontend-model query fields are limited to attributes exposed by the backend resource. Use `{name: "attributeName", selectedByDefault: false}` for fields that may be selected or filtered explicitly but should stay out of default payloads.

When backend payloads include `__preloadedRelationships`, nested frontend-model relationships are hydrated recursively. Relationship methods can use `getRelationshipByName("relationship").loaded()` and will throw when a relationship was not preloaded.

When queries include `select(...)`, backend frontend-model actions only serialize selected attributes for each model class. Reading a non-selected attribute on a frontend model raises `AttributeNotSelectedError`.

You do not need to manually define `frontend-index` / `frontend-find` / `frontend-create` / `frontend-update` / `frontend-destroy` routes for those resources. Velocious can auto-resolve frontend model command paths from `backendProjects.frontendModels`.

For backend models, you can declare attachment helpers directly:

```js
Task.hasManyAttachments("files")
Task.hasOneAttachment("descriptionFile")
Task.hasOneAttachment("archivedPdf", {driver: "s3"})
User.hasOneAttachment("profilePicture", {
  sync: {
    fetch: "eager",
    offlineRequirement: "optional",
    retention: "evictable"
  }
})
```

`db:migrate` provisions the framework-owned attachment table before runtime
attachment work begins. See [Backend record attachments](docs/attachments.md)
for the complete input, storage-driver, lifecycle, and path-security contracts.
Offline-capable clients can apply synchronized attachment descriptors through
the platform-neutral [Synchronized asset cache](docs/synchronized-assets.md),
while Expo and web packages own their respective byte-storage adapters.

You can also pass a driver class or instance directly on the attachment:

```js
import NativeDriver from "./storage/native-driver.js"

Task.hasOneAttachment("mobileCache", {driver: NativeDriver})
// or:
Task.hasOneAttachment("mobileCache", {driver: new NativeDriver()})
```

Then use them from backend records:

```js
await task.descriptionFile().attach({
  content: "my file content",
  filename: "file.doc"
})
await task.archivedPdf().attach({
  path: "/var/app/uploads/archive.pdf",
  contentType: "application/pdf"
})
const descriptionFileUrl = await task.descriptionFile().url()
await task.update({
  descriptionFile: {
    contentBase64: Buffer.from("my file content").toString("base64"),
    filename: "my-doc.doc"
  }
})
```

Purge a record's attachments — both the stored files and their rows — for example before destroying the owner record:

```js
const purgedCount = await task.files().purgeAll()
```

`purgeAll()` deletes each attachment's backing storage and then its row, and removes only the attachments that existed when the purge started (a concurrent `attach()` for the same record/name is left intact). It throws without deleting anything if a storage driver has no `delete` operation, so a driver configured without deletion can never silently leak storage. It is a no-op for unpersisted records and returns the number of attachments purged.

Configure attachment storage drivers in `Configuration`:

```js
export default new Configuration({
  attachments: {
    defaultDriver: "filesystem",
    // Path-based attachment input is disabled by default.
    // Enable explicitly only when backend-side file ingestion is needed.
    allowPathInput: false,
    // Optional allowlist when allowPathInput is true.
    allowedPathPrefixes: ["/var/app/uploads"],
    drivers: {
      filesystem: {
        directory: "/tmp/velocious-attachments"
      },
      native: {
        write: async ({attachmentId, contentBase64, filename}) => {
          // Persist using your native file API and return a storage key
          return {storageKey: `${attachmentId}-${filename}`}
        },
        read: async ({storageKey}) => {
          // Return Buffer, Uint8Array, ArrayBuffer or base64 string
          return await readNativeFile(storageKey)
        },
        url: async ({storageKey}) => {
          return `file://${storageKey}`
        }
      },
      s3: {
        bucket: "my-bucket",
        region: "eu-west-1",
        signedUrlExpiresIn: 3600
      }
    }
  }
})
```

If you want backend-side path ingestion, enable it explicitly:

```js
new Configuration({
  attachments: {
    allowPathInput: true,
    allowedPathPrefixes: ["/var/app/uploads"]
  }
})
```

Then `{path: "..."}`
inputs are only accepted when the path resolves inside one of the allowed
prefixes and the once-opened handle identifies a regular file. Its exact byte
size comes from that handle's stat snapshot. The filesystem driver copies it
with a bounded, backpressured stream and the S3 driver sends a Node `Readable`;
on current nullable schemas neither driver first materializes the whole file as
a Buffer or Base64 value. Legacy schemas with a non-null `content_base64`
column instead materialize the opened snapshot once before driver persistence
and reuse those exact bytes for storage and the database Base64. Path
replacement cannot switch the opened source, truncation is rejected, later
appends are ignored, and the source is closed after persistence.

The native driver's documented `write({contentBase64, ...})` callback remains
source-compatible. For path input only, that driver reads and Base64-encodes the
opened source after driver selection on current schemas; legacy path input
arrives pre-materialized with the same Base64 written to the database. Existing
Buffer, string, browser, and `UploadedFile` inputs keep their in-memory
behavior. See
[docs/attachments.md](docs/attachments.md#normalized-storage-driver-input) for
the normalized input passed to custom drivers.

For a resource with a backing model, the model attachment declaration
automatically generates `resourceConfig().attachments`; do not repeat it on the
resource. Use the generated attachment handles normally:

```js
await frontendTask.update({descriptionFile: file})
const descriptionFile = await frontendTask.descriptionFile().download()
const descriptionFileUrl = await frontendTask.descriptionFile().url()
const descriptionFileMetadata = await frontendTask.descriptionFile().first()
const filesMetadata = await frontendTask.files().toArray()
await frontendTask.attach(file)
```

Frontend model attachment input does not support `{path: ...}`.
Use `File`/`Blob`/bytes/`contentBase64` payloads instead.
The optional model-level `sync` block is client-safe policy metadata for asset
cache adapters. It distinguishes eager/on-demand fetching,
durable/evictable retention, and optional/required offline availability.
Required offline assets must be durable. Backend driver configuration never
appears in generated frontend models or API manifests. A descriptor ID keeps
its digest, byte size, and content type immutably. Cache descriptors that share
a digest must agree on byte size and content type, and eager synchronization
attempts each shared digest only once per reconciliation. On-demand resolution
rechecks the backing blob after cleanup and returns `null` instead of a stale
local URI when concurrent eviction removed it. Cleanup deferred by an active
cached resolution runs again after that digest's final guard releases.
Attachment metadata is exposed through the built-in `VelociousAttachment` frontend model with safe fields only: `id`, `recordType`, `recordId`, `name`, `position`, `filename`, `contentType`, `byteSize`, `createdAt`, and `updatedAt`. Storage internals such as `driver`, `storageKey`, and `contentBase64` remain hidden and non-queryable. Metadata collection queries require owner filters: `resourceName`, `recordType`, `recordId`, and `name`. Composite `recordId` values retain the complete canonical tuple without a 255-character limit, and key-changing saves rekey attachment ownership in the record transaction. `VelociousAttachment.find(id)` uses the member endpoint and authorizes against configured resource aliases backed by the attachment owner type.

When your frontend app calls a backend on another host/port (or under a path prefix), configure transport once:

```js
import FrontendModelBase from "velocious/build/src/frontend-models/base.js"

FrontendModelBase.configureTransport({
  requestContext: () => ({projectId: currentProject.id, routingEpoch: currentProject.routingEpoch}),
  url: "http://127.0.0.1:4501/frontend-models",
  timeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone
})
```

Available transport options:

- `url` (can also be a relative path like `"/frontend-models"` on web)
- `requestContext` (a scalar plain object or synchronous function returning one) captures immutable remote tenant-routing params independently for each CRUD/custom command and event subscription. See [docs/remote-request-context.md](docs/remote-request-context.md).
- `timeZone` (an IANA timezone string or a function returning one). Browser clients auto-detect this when it is not configured. Frontend-model datetime strings without an explicit timezone are interpreted in this request timezone and stored/queried as UTC instants.
- `timeout` (milliseconds or a function returning milliseconds) bounds each request, while `signal` (an `AbortSignal` or a function returning one) supports caller cancellation. See [docs/frontend-models.md](docs/frontend-models.md#core-transport) for timeout and cancellation behavior.

Use `await FrontendModelBase.waitForIdle()` when a test harness or app lifecycle needs to wait for queued, scheduled, and active frontend-model transport requests to finish before resetting state.

Frontend-model HTTP requests always use `credentials: "include"` so shared custom commands can set session cookies without app-level transport overrides.

Unexpected frontend-model endpoint failures return their original message and full stack trace by default in every environment, including production. Responses use `errorType: "internal_error"`, a server-generated `correlationId` shared with the matching framework-error report, and the established `debugErrorClass`, `debugErrorMessage`, and `debugBacktrace` fields. Expected application failures can use `VelociousError.safe(message, {errorType, details, code})`; generated frontend-model callers preserve the server's safe error fields without adding irrelevant debug fields. See [docs/frontend-models.md](docs/frontend-models.md#error-payloads).
Invalid client query descriptors, such as unknown `select`, `where`, `search`, `joins`, `preload`, `group`, `sort`, `pluck`, or Ransack attributes, return the specific frontend-model query error message with `velocious.code: "frontend-model-query-error"` and are not emitted as framework errors. Shared index payloads reject unknown top-level keys per request, including wrapper nesting such as `payload.query.where`; use `payload.where` directly. Legacy shared index callers may send `payload.authenticationToken`, which is extracted into request context before validation; it is not a query option, `requestContext.authenticationToken` wins when both are present, and unrelated unknown keys still fail. See [frontend-model transport](docs/frontend-models.md#core-transport).
Invalid frontend-model write attributes and attachment names, including attributes rejected by `permittedParams()`, return the specific safe error message with `velocious.code: "frontend-model-attribute-error"` and are not emitted as framework errors.
To mask unexpected internal details, explicitly opt out for the application configuration:

```js
const configuration = new Configuration({
  exposeInternalErrorsToClients: false
})
```

With this opt-out, built-in commands, custom commands, and sync replay failures return `errorMessage: "Request failed."` and omit the debug message and stack fields in every environment. `secureFrontendModelErrors: true` remains a deprecated compatibility alias when `exposeInternalErrorsToClients` is omitted; an explicit `exposeInternalErrorsToClients` value always wins.

Backends can append client-safe metadata to frontend-model error responses with `configuration.addClientErrorPayloadReporter(...)`. Reporters receive the caught `error`, the current `request`, a safe `requestDetails` snapshot, and a small `context` object, and should only return fields that are safe for clients to see. Frontend-model endpoint failures include `context.frontendModelEndpoint`, `action`, `commandType`, `model`, `requestId`, and `expectedError`. When exposure is disabled, Velocious strips the established debug fields even if a reporter supplies them. This is useful for attaching an error-reporting URL while keeping an opted-out error message generic:

```js
configuration.addClientErrorPayloadReporter(async ({error, requestDetails, context}) => {
  const report = await reportErrorToService({error, requestDetails, context})

  return {bugReportUrl: report.url}
})
```

`requestDetails` includes `httpMethod`, `path`, and a parsed `body` snapshot when available. The body snapshot redacts common secret keys, truncates large strings and arrays, summarizes uploaded files and buffers without bytes, and compacts oversized frontend-model batches while preserving `requestId`, `model`, `commandType` / `customPath`, and payload shape.

For sqlite web databases, Velocious automatically picks the best browser persistence backend it can use: OPFS when a smoke test succeeds, then IndexedDB. Existing persisted bytes in a worse backend are migrated into the selected backend when possible. If neither OPFS nor IndexedDB is usable, Velocious keeps the legacy localStorage-style backend as a compatibility fallback. See [docs/sqlite-web-persistence.md](docs/sqlite-web-persistence.md) for the backend selection details.

Velocious defaults to `https://sql.js.org/dist/<file>` for `sql.js` wasm loading. You can override wasm resolution per database config with `locateFile`:

```js
import SqliteDriver from "velocious/build/src/database/drivers/sqlite/index.web.js"

export default new Configuration({
  database: {
    test: {
      default: {
        driver: SqliteDriver,
        type: "sqlite",
        name: "app-db",
        locateFile: (file) => `/assets/sqljs/${file}`
      }
    }
  }
})
```

If you want to serve `sql.js` assets directly from your running Velocious backend, install the built-in sql.js asset route plugin and point `locateFile` to it:

```js
import installSqlJsWasmRoute, {sqlJsLocateFileFromBackend} from "velocious/build/src/plugins/sqljs-wasm-route.js"
import SqliteDriver from "velocious/build/src/database/drivers/sqlite/index.web.js"

const configuration = new Configuration({
  // ...
  database: {
    development: {
      default: {
        driver: SqliteDriver,
        type: "sqlite",
        name: "app-db",
        locateFile: sqlJsLocateFileFromBackend({
          backendBaseUrl: "http://127.0.0.1:4501",
          routePrefix: "/velocious/sqljs"
        })
      }
    }
  }
})

installSqlJsWasmRoute({
  configuration,
  routePrefix: "/velocious/sqljs"
})
```

Frontend-model command transport preserves `Date` and `undefined` by encoding them as marker objects in JSON and decoding them on the other side:
- `Date` -> `{__velocious_type: "date", value: "<ISO string>"}`
- `undefined` -> `{__velocious_type: "undefined"}`
- `bigint` -> `{__velocious_type: "bigint", value: "<decimal string>"}`
- `NaN` / `Infinity` / `-Infinity` -> `{__velocious_type: "number", value: "NaN" | "Infinity" | "-Infinity"}`

Frontend-model commands raise an `Error` when the backend responds with `{status: "error"}` (using `errorMessage` when present), so unauthorized or missing-record update/find/destroy responses fail fast in frontend code.

## Route resolver hooks

Libraries can hook unresolved routes and hijack them before Velocious falls back to the built-in 404 controller.

```js
export default new Configuration({
  // ...
  routeResolverHooks: [
    ({currentPath}) => {
      if (currentPath !== "/special-route") return null

      return {controller: "hijacked", action: "index"}
    }
  ]
})
```

Hook return value:

- `null` to skip
- `{controller, action}` to resolve the request
- Optional `controllerClass` to resolve without importing a controller path
- Optional `params` object to merge into request params
- Optional `controllerPath` string to resolve a controller file outside the app route directory
- Optional `viewPath` string override for view rendering lookups

## Plugin routes helper

For plugin-style integrations, you can register routes with a simple DSL:

```js
configuration.routes((routes) => {
  routes.get("/velocious/sqljs/:sqlJsAssetFileName", {
    to: [SqlJsController, "downloadSqlJs"]
  })
})
```

Supported route helpers:

- `routes.get(path, {to: [ControllerClass, "action"], params?})`
- `routes.post(path, {to: [ControllerClass, "action"], params?})`

## Rampway deployment control plane

Applications can install `rampway@^0.4.0` and mount its package-owned Velocious
control plane through the existing routes DSL. Keep bearer tokens in backend
secrets and provide explicit allowlisted config paths and release branches:

```sh
npm install rampway@^0.4.0 velocious@^1.0.577
```

Rampway 0.4.0 declares `velocious ^1.0.574` as its peer range, but applications
mounting this API must use Velocious 1.0.577 or newer. Versions 1.0.574 through
1.0.576 attempted an app-local controller import before the package-supplied
`controllerClass`, allowing a same-named app controller to shadow Rampway's
authenticated controller.

```js
import RampwayDeploymentApi from "rampway/velocious"
import deploymentSecrets from "./secrets/deployments.js"

routes.draw((route) => {
  route.mount(RampwayDeploymentApi, {
    accessTokens: deploymentSecrets.rampwayAccessTokens,
    at: "/rampway/deployments",
    projects: {
      "my-app": {
        stages: {
          production: {
            configPath: "/srv/my-app/control/rampway.config.mjs",
            releaseBranch: "main"
          }
        }
      }
    },
    workerBootstrapPath: "/srv/my-app/control/rampway-velocious-worker.mjs"
  })
})
```

Rampway owns authentication, deployment execution, idempotency, durable runs,
audits, reconciliation, and the detached worker. Velocious supplies its normal
route, request, error-event, and database abstractions. See
[docs/rampway-integration.md](docs/rampway-integration.md) for bootstrap,
persistence, security, and rollback requirements.


```js
import Record from "velocious/build/src/database/record/index.js"

class Task extends Record {
}

Task.belongsTo("account")
Task.translates("description", "subTitle", "title")
Task.validates("name", {presence: true, uniqueness: true})

export default Task
```

`presence: true` rejects only `null`, `undefined`, and trimmed-empty strings; non-string values are present as-is, so a `Date`, a number (including `0`), or a boolean (including `false`) never fails presence validation. See [docs/validations.md](docs/validations.md) for the full validator contract.

Generated belongs-to setters synchronize the loaded relationship and foreign key before save, so `task.setProject(project)` updates `task.projectId()`, `task.changes()`, callbacks, and scoped features such as `actsAsList`. Custom relationship primary keys are also used after autosaving an assigned new or dirty related record. Generated backend write attributes accept belongs-to relationship names for `create` and `update` payloads. See [docs/relationships.md](docs/relationships.md#assigning-belongs-to-relationships).

Translated models also get a `currentTranslation` `hasOne` relationship scoped to the first available row in the current locale fallback order. See [docs/translations.md](docs/translations.md) for preloading and frontend-model sorting behavior.

Async class APIs initialize record metadata on first use when a model has not
already been initialized eagerly. See [docs/model-initialization.md](docs/model-initialization.md)
for the eager and lazy initialization behavior, including atomic shared bootstrap
and complete recovery after an eager initialization failure or database-connection
closure without overlapping stale and current bootstrap side effects.

## Lifecycle callbacks

Records support implicit same-named lifecycle methods plus explicit function or
string method-name registrations. Registrations run in order, so independent
responsibilities can use multiple small named callbacks.

```js
class Task extends Record {
  beforeSave() {
    this.setName(this.name().trim())
  }

  async validateSomething() {
    await doSomethingElse()
  }
}

Task.beforeValidation(async (task) => {
  await doSomething(task)
})

Task.beforeValidation("validateSomething")
```

Function registrations receive the record argument; arrow-function `this` is
lexical and is not rebound to the record. Keep implicit hooks cohesive, use
multiple explicit registrations when concerns or ordering should be visible, and
put irreversible effects behind `afterCommit` rather than directly in
`afterSave`. See [Record lifecycle callbacks](docs/lifecycle-callbacks.md) for
phase ordering, transaction boundaries, bulk-operation caveats, and testing
guidance.

## Preloading relationships

```js
const tasks = await Task.preload({project: {translations: true}}).toArray()
const projectNames = tasks.map((task) => task.project().name())
```

## Load a relationship after init

```js
const task = await Task.find(5)

const project = await task.projectOrLoad()

await task.loadProject()

const sameProject = task.project()
```

```js
const project = await Project.find(4)
const tasks = await project.tasks().toArray()
const refreshedTasks = await project.tasks().load()

await project.loadTasks()

const tasks = project.tasks().loaded()
```

## Auto-batch-preload (cohort loading)

When records are loaded as part of a batch (e.g. `Task.where(...).toArray()`), the first lazy access to a relationship on any sibling batch-loads that relationship for every sibling record in one query — avoiding the classic N+1.

```js
const tasks = await Task.where({state: "open"}).toArray()

// First call issues ONE query to load the project for every task in the batch.
const firstProject = await tasks[0].projectOrLoad()

// Subsequent sibling accesses hit the preloaded cache — no extra query.
const secondProject = tasks[1].project()
```

Auto-load is triggered by the async access paths that already exist: `model.${name}OrLoad()`, `model.relationshipOrLoad("...")`, and `model.relationship().toArray()` / `model.relationship().load()` for hasMany. The synchronous accessor `model.relationship()` still throws when the relationship has not been loaded — call the async form if you want the lazy-load behavior.

Scoped queries opt out of cohort batching by design, because the filter is specific to the accessing record:

```js
// Triggers cohort batch — all cohort siblings get their comments preloaded in one query.
await firstTask.comments().load()

// Does NOT trigger cohort — scoped filter is unique to this call.
await firstTask.comments().query().where({isResolved: true}).load()
```

Disable auto-load per relationship:

```js
Task.belongsTo("project", {autoload: false})
```

Disable auto-load globally via the framework configuration:

```js
new Configuration({
  autoload: false,
  // ...
})
```

Both flags default to `true`. When disabled, lazy access falls back to a per-record load.

The same cohort auto-batch-preload applies to **frontend models**. When a batch is loaded from the backend (`Task.where(...).toArray()` or similar), the first async relationship access on any cohort sibling triggers one combined HTTP request that preloads that relationship for every sibling at once:

```js
const tasks = await Task.toArray()

// First call issues ONE request to preload the project for every task in the batch.
const firstProject = await tasks[0].projectOrLoad()

// Sibling has been populated from the same response — no extra request.
const secondProject = tasks[1].project()
```

The generator threads the per-relationship `autoload: false` flag through automatically, so `Task.belongsTo("project", {autoload: false})` on the backend also disables cohort batching on the generated frontend model.

Disable auto-batch-preload globally on the frontend:

```js
import FrontendModelBase from "velocious/frontend-models"

FrontendModelBase.setAutoload(false)
```

Scoped frontend queries (e.g. `Task.where(...).preload([name]).toArray()` from user code) bypass cohort batching by design, same as the backend. Siblings with locally set state from `.setRelationship()` / `.build()` are preserved across cohort batches.

Backend relationship `build(...)` / `create(...)` helpers and generated singular builders with a concrete target use that model's generated write-attribute type. Model-valued relationship attributes are accepted, while unknown and invalid attributes fail type checking. Targetless polymorphic `belongsTo` builders remain generic because no single target write contract exists. See [docs/relationships.md](docs/relationships.md#building-related-records).

## Through relationships

Use the `through` option on `hasMany` to define a relationship that traverses an intermediate (join) table:

```js
Invoice.hasMany("invoiceGroupLinks")
Invoice.hasMany("invoiceGroups", {through: "invoiceGroupLinks", className: "InvoiceGroup"})
```

Through relationships work with both instance-level loading and batch preloading:

```js
// Instance-level loading
const invoice = await Invoice.find(1)
const groups = await invoice.invoiceGroups().toArray()

// Batch preloading
const invoices = await Invoice.preload({invoiceGroups: true}).toArray()
const groups = invoices[0].invoiceGroupsLoaded()
```

The intermediate relationship (e.g. `invoiceGroupLinks`) must be defined as a separate `hasMany` on the same model. The `foreignKey` option on the through relationship specifies the column on the target table that points to the intermediate table (defaults to the conventional foreign key).

## Dependent relationships

`dependent` controls what happens to child records when a parent is destroyed:

```js
Project.hasMany("tasks", {dependent: "restrict"})
Project.hasOne("projectDetail", {dependent: "destroy"})
```

`dependent: "destroy"` loads and destroys children before deleting the parent. For `hasOne`, Velocious destroys at most one matching child; when no matching child exists, the parent destroy continues without error. Polymorphic `hasOne` dependencies match both the foreign key and type column so another model with the same ID is not destroyed. `dependent: "restrict"` blocks the parent destroy when dependent rows exist.

## Relationship scopes

You can pass a scope callback to `hasMany`, `hasOne`, or `belongsTo` to add custom filters. The callback receives the query and is also bound as `this`:

```js
Project.hasMany("acceptedTasks", (scope) => scope.where({state: "accepted"}), {className: "Task"})
Project.hasOne("activeDetail", function() { return this.where({isActive: true}) }, {className: "ProjectDetail"})
Comment.belongsTo("acceptedTask", (scope) => scope.where({state: "accepted"}), {className: "Task"})
```

### Join path table references

When joining relationships, use `getTableForJoin` to retrieve the table (or alias) for a join path:

```js
const query = Task.joins({project: {account: true}})
const accountTable = query.getTableForJoin("project", "account")
```

Inside relationship scopes, `getTableForJoin()` is relative to the current scope path:

```js
Project.hasMany("acceptedTasks", function() {
  return this.where(`${this.getTableForJoin()}.state = 'accepted'`)
}, {className: "Task"})
```

## Model scopes

Backend records and frontend models can define reusable named scopes with `defineScope(...)`.

```js
class Task extends TaskBase {
  static withAccepted = this.defineScope(({query}, accepted) => query.where({accepted}))
}

await Task.withAccepted(true).toArray()
await Task.where({projectId: 1}).scope(Task.withAccepted.scope(true)).toArray()
await Task.joins({project: {tasks: true}}).scope(["project", "tasks"], Task.withAccepted.scope(true)).toArray()
```

`Model.scopeName(args...)` starts a fresh query for that model. `Model.scopeName.scope(args...)` returns a reusable scope descriptor for `.scope(...)` on an existing query. Backend record queries also support `.scope(path, descriptor)` to apply a scope to a joined relationship path.

Backend record scopes receive alias-aware SQL context:

```js
class Task extends TaskBase {
  static nameLike = this.defineScope(({driver, query, table}, value) => query.where(
    `${driver.quoteTable(table)}.${driver.quoteColumn("name")} LIKE ${driver.quote(`%${value}%`)}`
  ))
}
```

The `table` value is the active table reference for the current query and may be an alias from `FROM ... AS ...`, not just `Task.tableName()`.

Joined-path scopes receive the joined path in `context.path` and may only add `where(...)` and `joins(...)` clauses.

### Finding records

`find()` and `findByOrFail()` throw an error when no record is found. `findBy()` returns `null`. These apply to records.

### Create records

```js
const task = new Task({identifier: "task-4"})

task.assign({name: "New task})

await task.save()
```

```js
const task = await Task.create({name: "Task 4"})
```

### Bulk insert

Use `insertMultiple` to insert many rows in one call:

```js
await Task.insertMultiple(
  ["project_id", "name", "created_at", "updated_at"],
  [
    [project.id(), "Task 1", new Date(), new Date()],
    [project.id(), "Task 2", new Date(), new Date()]
  ]
)
```

If a batch insert fails, you can retry each row and collect results:

```js
const results = await Task.insertMultiple(
  ["project_id", "name"],
  [
    [project.id(), "Task A"],
    [project.id(), "Task A"]
  ],
  {retryIndividuallyOnFailure: true, returnResults: true}
)

console.log(results.succeededRows, results.failedRows, results.errors)
```

Large batches are split into multiple `INSERT ... VALUES` statements so each
statement stays within database limits. Two database-configuration keys control
the splitting:

- `maxRowsPerInsert` — maximum rows per statement (default: `500`).
- `maxInsertSqlBytes` — maximum serialized SQL size in bytes per statement
  (default: `1048576`, i.e. 1 MiB).

A new chunk is started when the next row would exceed either limit. Row order is
preserved across chunks.

**Important:** when `insertMultiple` is called outside a transaction, each chunk
commits independently. If a later chunk fails, earlier chunks remain persisted.
Wrap the call in a transaction when you need all-or-nothing semantics:

```js
await Task.transaction(async () => {
  await Task.insertMultiple(
    ["project_id", "name", "created_at", "updated_at"],
    thousandsOfTasks
  )
})
```

### Find or create records

```js
const task = await Task.findOrInitializeBy({identifier: "task-5"})

if (task.isNewRecord()) {
  console.log("Task didn't already exist")

  await task.save()
}

if (task.isPersisted()) {
  console.log("Task already exist")
}
```

## User module

Use the user module to add password helpers to a record class. It attaches `setPassword()` and `setPasswordConfirmation()` to the model and stores encrypted values on the record. Your users table should include an `encryptedPassword` column for this to work.

```js
import Record from "velocious/build/src/database/record/index.js"
import UserModule from "velocious/build/src/database/record/user-module.js"

class User extends Record {
}

new UserModule({secretKey: process.env.USER_SECRET_KEY}).attachTo(User)

const user = new User()
user.setPassword("my-password")
user.setPasswordConfirmation("my-password")
```

```js
const task = await Task.findOrCreateBy({identifier: "task-5"}, (newTask) => {
  newTask.assign({description: "This callback only happens if not already existing"})
})
```

# Migrations

## Make a new migration from a template

```bash
npx velocious g:migration create-tasks
```

## Write a migration
Implicit `id` primary keys and `references(...)` columns use UUIDs by default. Set `primaryKeyType` on a database config to change the implicit type for that database, or pass an explicit `id` / reference `type` for legacy schemas and external compatibility.

```js
export default new Configuration({
  database: {
    production: {
      default: {
        type: "pgsql",
        primaryKeyType: "bigint"
      }
    }
  }
})
```

```js
import Migration from "velocious/build/src/database/migration/index.js"

export default class CreateEvents extends Migration {
  async up() {
    await this.createTable("tasks", (t) => {
      t.timestamps()
    })

    // Legacy numeric primary key
    await this.createTable("legacy_events", {id: {type: "bigint"}}, (t) => {
      t.references("task", {type: "bigint"})
      t.timestamps()
    })

    // Column helper examples
    await this.createTable("examples", (t) => {
      t.bigint("count")
      t.blob("payload")
      t.boolean("published")
      t.datetime("published_at")
      t.integer("position")
      t.json("metadata")
      t.string("name")
      t.text("body")
      t.tinyint("priority")
      t.uuid("uuid_column")
      t.references("user")
      t.timestamps()
    })

    await this.createTable("task_translations", (t) => {
      t.references("task", {foreignKey: true, null: false})
      t.string("locale", {null: false})
      t.string("name")
      t.timestamps()
    })

    await this.addIndex("task_translations", ["task_id", "locale"], {unique: true})
  }

  async down() {
    await this.removeIndex("task_translations", ["task_id", "locale"])
    await this.dropTable("task_translations")
    await this.dropTable("examples")
    await this.dropTable("tasks")
  }
}
```

To reverse an `addReference("tasks", "project", {foreignKey: true, type: "uuid"})` migration, use `await this.removeReference("tasks", "project")` in `down()`. It removes the generated foreign key, index, and column; see [removing references and foreign keys](docs/database-migrations.md#removing-references-and-foreign-keys) for custom-column and foreign-key-only cases.

Migrations that must be rerunnable can guard changes with `tableExists(...)`, `columnExists(table, column)` and `indexExists(table, index)` — a missing table yields `false` rather than throwing. See [docs/database-migrations.md](docs/database-migrations.md#guarding-schema-changes-in-rerunnable-migrations).

## Run migrations from the command line

```bash
npx velocious db:migrate
```

Migrations default to the `pre-runtime` phase. A migration can declare
`Migration.runInPhase("post-publication")`, and callers can run exactly one
declared set while preserving timestamp order, package migrations, database
targets, and ledger behavior:

```bash
npx velocious db:migrate --phase pre-runtime
npx velocious db:migrate --phase post-publication
```

Omitting `--phase` remains backward-compatible and runs all pending migrations.
Velocious does not choose when either set runs; the application or deployment
caller owns invocation timing. See [migration execution phases](docs/migration-execution-phases.md)
for the class API, programmatic selector, require-context behavior, and tenant
commands.

Run project seeds from `src/db/seed.js` (default export should be an async function):

```bash
npx velocious db:seed
```

You can chain multiple commands in one invocation:

```bash
npx velocious db:create db:migrate
```

Database creation waits for the server-side DDL to finish. On MS-SQL, only the `CREATE DATABASE` request bypasses node-mssql's ordinary request deadline; later schema and application queries keep their configured timeout.

Run script files with initialized app/database context:

```bash
npx velocious run-script src/scripts/my-task.js
```

Evaluate inline JavaScript (Rails-style runner) with initialized app/database context:

```bash
npx velocious runner "const users = await db.query('SELECT COUNT(*) AS count FROM users'); console.log(users[0].count)"
```

Successful CLI commands exit with status `0`. If command execution rejects, Velocious marks the process failed and always runs final database cleanup. Successful cleanup preserves and rethrows the original command error unchanged; if cleanup also fails, an `AggregateError` retains the command error first and as its `cause`, followed by the cleanup error. Cleanup-only failures also exit nonzero. These failures therefore remain nonzero even when application code installs an `uncaughtException` listener that reports and consumes the error. See [CLI process exit behavior](docs/cli.md#process-exit-behavior).

By default, migrations write `db/structure-<identifier>.sql` files for each database in non-test environments. Test skips these automatic writes unless you explicitly opt in. Configure allow/deny lists in your configuration:

```js
export default new Configuration({
  // ...
  structureSql: {
    enabledEnvironments: ["development"],
    disabledEnvironments: ["test"]
  }
})
```

If you only want automatic writes in one or two environments, prefer `enabledEnvironments`. `db:schema:dump` is an explicit schema-generation command and still writes missing structure files regardless of the current environment.

If you need to regenerate missing structure files without rerunning migrations, use:

```bash
npx velocious db:schema:dump
```

`db:schema:dump` generates a structure SQL file for each configured database identifier under `db/structure-<identifier>.sql`. It only writes files when one or more expected files are missing. The generated file includes the full DDL (tables, indexes, views, triggers, etc.) followed by `INSERT INTO schema_migrations (version) VALUES (...)` for every currently applied migration version. MySQL and MariaDB dumps place same-schema referenced base tables before their dependent tables. The migration ledger preserves applied versions in the checked-in snapshot so fresh databases loaded from it do not re-run migrations that already shaped the schemas in the file.

If you need to load the checked-in structure files for each configured database, use:

```bash
npx velocious db:schema:load
```

`db:schema:load` reads `db/structure-<identifier>.sql` for each configured database identifier and executes those statements against the current connections. Because the structure file includes the migration ledger rows, a load marks every listed version as already applied. The next `db:migrate` sees those versions and skips them, so migrations whose schema changes are already part of the loaded structure do not run again.

The same load logic is available programmatically for provisioning a database from a structure dump (for example a tenant or test database) in one pass — much faster than materializing a schema table by table:

```js
import StructureSqlLoader from "velocious/build/src/database/structure-sql-loader.js"

await new StructureSqlLoader().load({db, structureSql})
```

`load({db, structureSql})` disables foreign keys around the load (so the order tables reference each other in the dump does not matter) and uses the driver's native multi-statement `exec` in a single round-trip when available. See [docs/database-migrations.md](docs/database-migrations.md).

## Schema metadata cache

Velocious caches schema metadata on each database driver instance so repeated model initialization, table lookups, column introspection, and structure SQL generation can reuse the same database results. The cache is cleared automatically after schema-changing SQL runs through Velocious, such as migrations, `createTable`, `dropTable`, `renameColumn`, `ALTER TABLE`, `CREATE INDEX`, and `COMMENT ON`. See [docs/schema-metadata-cache.md](docs/schema-metadata-cache.md) for details.

If another process changes the schema outside Velocious while the current process is still running, clear the cache before reading metadata again:

```js
await configuration.ensureConnections(async (dbs) => {
  dbs.default.clearSchemaCache()
})
```

To disable schema metadata caching for a database connection, set `schemaCache: false` on that database config:

```js
export default new Configuration({
  database: {
    development: {
      default: {
        type: "mysql",
        schemaCache: false
      }
    }
  }
})
```

## Configure CLI commands (Node vs Browser)

Node loads CLI commands from disk automatically via the Node environment handler:

```js
import Configuration from "velocious/build/src/configuration.js"
import NodeEnvironmentHandler from "velocious/build/src/environment-handlers/node.js"

export default new Configuration({
  // ...
  environmentHandler: new NodeEnvironmentHandler()
})
```

Browser builds can still register commands, but only the browser-safe wrappers are bundled:

```js
import Configuration from "velocious/build/src/configuration.js"
import BrowserEnvironmentHandler from "velocious/build/src/environment-handlers/browser.js"

export default new Configuration({
  // ...
  environmentHandler: new BrowserEnvironmentHandler()
})
```

## Run CLI commands in the browser

Enable the browser CLI and run commands from devtools or app code:

```js
import BrowserCli from "velocious/build/src/cli/browser-cli.js"

const browserCli = new BrowserCli({configuration})
browserCli.enable()

await browserCli.run("db:migrate")
```

Once enabled, you can also run commands directly from the browser console:

```js
await globalThis.velociousCLI.run("db:migrate")
```

In React, you can use the hook which sets `globalThis.velociousCLI`:

```js
import useBrowserCli from "velocious/build/src/cli/use-browser-cli.js"

export default function App() {
  useBrowserCli({configuration})

  return null
}
```

## Run migrations from anywhere if you want to:

```js
const migrationsPath = `/some/dir/migrations`
const files = await new FilesFinder({path: migrationsPath}).findFiles()

await this.configuration.ensureConnections(async () => {
  const migrator = new Migrator({configuration: this.configuration})

  await migrator.prepare()
  await migrator.migrateFiles(files, async (path) => await import(path))
})
```

# Querying

Each query feature has its own focused example.

### Basic retrieval

```js
import {Task} from "@/src/models/task"

const tasks = await Task.all().toArray()
```

### Filtering

Use `{in: [...]}` for explicit null-aware membership. Mixed lists match either
IN members or SQL NULL, grouped locally so sibling and chained filters still
apply. Null-only lists use IS NULL; empty lists match nothing. Direct arrays keep
their existing driver-specific behavior, including string-null quoting and
column collation. See [Query filtering](docs/query-filtering.md) for
validation, normalization, negation, relationships and raw-query boundaries.

```js
const tasks = await Task.where({status: "open"}).toArray()

const nullOrManualTasks = await Task
  .where({projectId: project.id(), description: {in: [null, "manual"]}})
  .toArray()

const tasksForActiveProjects = await Task.where({
  project: {projectDetail: {isActive: true}}
}).toArray()

const specificTask = await Task.where({
  id: 1,
  project: {nameEn: "Alpha"}
}).toArray()

const tasksWithRecentCreators = await Task.where({
  project: {creatingUser: [["createdAt", ">=", new Date("2026-01-01T00:00:00.000Z")]]}
}).toArray()
```

### Ransack-style filtering

Use `.ransack(...)` on record queries, record classes, frontend-model queries, and frontend-model classes when you want Rails/Ransack-style predicate keys without hand-writing nested `where(...)` or `search(...)` calls.

Supported predicates include `_eq`, `_not_eq`, `_gt`, `_gteq`, `_lt`, `_lteq`, `_cont`, `_start`, `_end`, `_in`, `_not_in`, and `_null`.

```js
const tasks = await Task.ransack({
  name_cont: "deploy",
  project_project_detail_is_active_eq: true
}).toArray()

const frontendTasks = await FrontendTask
  .ransack({name_cont: "deploy", id_in: ["1", "2"]})
  .toArray()
```

Simple OR predicates use Ransack's `_or_` shortcut:

```js
const matchingUsers = await User
  .ransack({email_or_reference_cont: "john"})
  .toArray()
```

Grouped Ransack hashes support `m` (`"and"` / `"or"`), `c` condition arrays, and nested `g` groups:

```js
const matchingUsers = await User
  .ransack({
    c: [
      {a: ["email"], p: "cont", v: ["jane"]}
    ],
    g: [
      {
        c: [
          {a: "reference", p: "cont", v: ["user-2"]},
          {a: "email", p: "cont", v: ["john"]}
        ],
        m: "and"
      }
    ],
    m: "or"
  })
  .limit(20)
  .offset(0)
  .toArray()
```

Frontend-model `.ransack(...)` filters run on the backend, so `count()`, `limit(...)`, `offset(...)`, and `toArray()` all share the same query scope instead of loading records into memory first.

### Raw where clauses

```js
const tasks = await Task.where("tasks.completed_at IS NULL").toArray()
```

### Joins

```js
const tasks = await Task
  .joins({project: true})
  .where({projects: {public: true}})
  .toArray()
```

### Preloading relationships

```js
const tasks = await Task.preload({project: {account: true}}).toArray()
const accountNames = tasks.map((task) => task.project().account().name())
```

### Selecting columns

```js
const tasks = await Task.select(["tasks.id", "tasks.name"]).toArray()
```

Loaded records expose cached physical model columns through `attributes()`. A
terminal calculated `AS` alias explicitly requested by that query is included
under the alias spelling returned by the database driver. Other unmapped row
keys are omitted from `attributes()` so an older process can safely read rows
after a rolling schema addition; they remain available through
`rawAttributes()` and `readColumn()`, while `readAttribute()` remains strict.

### Reselecting columns

`reselect` replaces any previously accumulated `SELECT` clauses — useful
when repurposing a shared base query for an aggregate or a column-
projected read. `reselect()` with no argument drops the projection so
the driver falls back to `SELECT *`.

```js
const baseQuery = Task.where({state: "open"})
const counts = await baseQuery.reselect("COUNT(*) AS count").results()
```

### Ordering

```js
const tasks = await Task.order("name").toArray()
const sortedTasks = await Task.order({tableName: "tasks", column: "name", direction: "ASC"}).toArray()
```

Use structured order descriptors for runtime-selected columns so identifiers are quoted by the active database driver. Plain string orders are still available for fixed SQL expressions that cannot be represented as a column descriptor.

### Reordering and reverse order

```js
const tasks = await Task.order("name").reorder("created_at").reverseOrder().toArray()
```

### Limiting and offsetting

```js
const tasks = await Task.limit(10).offset(20).toArray()
```

### Grouping

```js
const tasks = await Task.group("tasks.project_id").toArray()
```

### Distinct records

```js
const tasks = await Task.joins({project: true}).distinct().toArray()
```

### Paging

```js
const tasks = await Task.page(2).perPage(25).toArray()
```

### Counting

```js
const totalTasks = await Task.count()
const distinctProjects = await Task.joins({project: true}).distinct().count()
```

Frontend-model `count()` runs as a backend aggregate, so list UIs can request counts without loading and serializing every matching model.

### First and last

```js
const firstTask = await Task.first()
const lastTask = await Task.last()
```

### Find by attributes

```js
const task = await Task.findBy({identifier: "task-5"})
const taskOrFail = await Task.findByOrFail({identifier: "task-5"})
```

### Find or initialize/create

```js
const task = await Task.findOrInitializeBy({identifier: "task-5"})
const task2 = await Task.findOrCreateBy({identifier: "task-6"}, (newTask) => {
  newTask.assign({description: "Only runs when new"})
})
```

### Destroy all records

```js
await Task.where({tasks: {status: "archived"}}).destroyAll()
```

### Plucking columns

```js
const names = await Task.pluck("name")                     // ["Task A", "Task B"]
const idsAndNames = await Task.order("name").pluck("id", "name") // [[1, "Task A"], [2, "Task B"]]
```

# Global connections fallback

`AsyncTrackedMultiConnection` uses `AsyncLocalStorage` to pin a connection to the current async context. If you need to call `getCurrentConnection()` outside of `ensureConnections`/`withConnection`, ask the pool to create a global fallback connection for you:

```js
import AsyncTrackedMultiConnection from "velocious/build/src/database/pool/async-tracked-multi-connection.js"

const pool = configuration.getDatabasePool("default")

// Create (or reuse) a dedicated fallback connection.
await pool.ensureGlobalConnection()

// Later, outside an async context, this will return the ensured fallback connection:
const db = pool.getCurrentConnection()
```

To prime *all* configured pools at once, call `configuration.ensureGlobalConnections()`. It will invoke `ensureGlobalConnection()` on pools that support it and perform a `checkout` on simpler pools so `getCurrentConnection()` is safe everywhere.

When an async context exists, that connection is still preferred over the global one.

Checked-in `AsyncTrackedMultiConnection` connections are closed after 5 seconds of idle time by default. This keeps tenant-scoped and short-lived background-job connections from accumulating in long-running processes while still allowing immediate reuse by nearby async work. Configure `database.<environment>.<identifier>.pool.idleTimeoutMillis` to change the timeout, set it to `0` to close idle connections immediately unless a matching checkout is already waiting, or set it to `null` to disable idle reaping for that pool. A matching idle connection is reused before expired idle connections are reaped.

```js
database: {
  production: {
    default: {
      driver: MysqlDriver,
      poolType: AsyncTrackedMultiConnection,
      type: "mysql",
      pool: {
        idleTimeoutMillis: 10000,
        max: 25
      }
    }
  }
}
```

`pool.max` caps live async-tracked connections for that pool and defaults to `10` when omitted. When the cap is reached, new checkouts wait until a matching checked-in connection can be handed over or capacity is freed. Set `pool.max` to `null` only when a process is deliberately allowed to open an unbounded number of database connections. The built-in debug endpoint reports each in-use connection's `checkedOutForMs`, each idle connection's `idleForMs`, queued `pendingCheckouts[].waitingForMs`, and matching idle capacity plus checkout-drain state so production diagnostics can distinguish long-held checkouts, pool-capacity waits, and an invariant violation where compatible capacity is unexpectedly idle.

Debug snapshots also expose cumulative connection-creation, checkout-wait and
timeout, idle-reap, and peak-live-connection telemetry. Opt-in test profiles can
attribute safe aggregate deltas to their current spans. The [MySQL idle-reaping
benchmark and methodology](docs/mysql-pool-idle-reaping-research.md) compare the
5-second default with 60 seconds and disabled reaping under a fixed cap; absent
representative measured evidence, retain the 5-second default.

# Websockets

Velocious includes a lightweight websocket entry point for API-style calls and server-side events.

Inbound frames remain ordered when TCP splits a frame across reads. Velocious limits a single final client data frame and a reassembled fragmented message to 16 MiB; larger payloads close the connection. Decoded inbound work is bounded independently per session before authorization or request handling: the defaults retain at most 256 active/queued messages or 16 MiB of raw UTF-8 payload bytes. Exact FIFO is preserved within the limits. A next message exceeding either limit is rejected before decoding and permanently closes that session with status `1008` and reason `Inbound message backlog exceeded`. Configure positive safe integers with `httpServer.websocketInboundQueue.maxPendingMessages` and `maxPendingBytes`.

Server-to-client WebSocket delivery is bounded independently per client. The defaults retain at most 256 queued/in-flight completed frames or 16 MiB of serialized frame output; the HTTP 101 upgrade response and ordinary HTTP/file output remain outside that budget, while exact FIFO is preserved. A slow client that exceeds either limit is reported through the framework error events and deterministically closed without affecting other clients. V2 channel broadcast delivery and persistence remain isolated to the originating configuration when several applications share a process. Configure positive safe integers with `httpServer.websocketOutboundQueue.maxPendingFrames` and `maxPendingBytes`. See [WebSocket connections](docs/websocket-connections.md) for configuration and wire-protocol details.

## Connect and call a controller

```js
const socket = new WebSocket("ws://localhost:3006/websocket")

socket.addEventListener("open", () => {
  socket.send(JSON.stringify({
    id: "req-1",
    method: "POST",
    path: "/api/version",
    body: {extra: true},
    type: "request"
  }))
})

socket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data)

  if (msg.type === "response" && msg.id === "req-1") {
    console.log("Status", msg.statusCode, "Body", msg.body)
  }
})
```

## Attach WebSocket metadata

Clients can send session metadata over the shared WebSocket. Metadata is exposed to WebSocket-borne controller requests and frontend-model subscription authorization through `request.metadata(...)`; it is not merged into HTTP headers.

```js
socket.addEventListener("open", () => {
  socket.send(JSON.stringify({
    data: {locale: "da", sessionToken: "abc"},
    type: "metadata"
  }))

  socket.send(JSON.stringify({
    id: "req-2",
    method: "POST",
    path: "/api/version",
    type: "request"
  }))
})
```

```js
const sessionToken = this.getRequest().metadata("sessionToken")
```

# Logging

Velocious includes a lightweight logger that can write to both console and file and is environment-aware.

- **Defaults**: When no `logging` config is provided, Velocious sets up a console logger with `info`, `warn`, and `error` levels.
- **Configuration**: Supply a `logging` object when creating your configuration:

```js
const configuration = new Configuration({
  // ...
  logging: {
    console: false,            // disable console output
    file: true,                // enable file output
    directory: "/custom/logs", // optional, defaults to "<project>/log" in Node
    filePath: "/tmp/app.log",  // optional explicit path
    sensitiveNames: ["integrationPin"] // optional app-specific additions
  }
})
```

- **Custom logger list**: Configure an explicit list of logger instances with levels:

```js
import ConsoleLogger from "velocious/build/src/logger/console-logger.js"
import FileLogger from "velocious/build/src/logger/file-logger.js"

const configuration = new Configuration({
  // ...
  logging: {
    loggers: [
      new ConsoleLogger({levels: ["info", "warn", "error"]}),
      new FileLogger({path: `log/${environment}.log`, levels: ["debug", "info", "warn", "error"]})
    ]
  }
})
```

- **Base logger**: Custom loggers should extend `BaseLogger` and implement either `write(...)` or `toOutputConfig(...)`:

```js
import BaseLogger from "velocious/build/src/logger/base-logger.js"

class MyLogger extends BaseLogger {
  async write({message}) {
    console.log(message)
  }
}
```

- **Environment handlers**: File-path resolution and file writes are delegated to the environment handler so browser builds stay bundle-friendly.
  - Node handler writes to `<directory>/<environment>.log` by default.
  - Custom handlers can override `getDefaultLogDirectory`, `getLogFilePath`, and `writeLogToFile` if needed.

- **Debug logging**: When `configuration.debug` is true or a `Logger` is constructed with `{debug: true}`, messages are emitted regardless of environment.

- **Per-instance control**: You can create a `new Logger("Subject", {configuration, debug: false})` to honor the configuration defaults, or toggle `logger.setDebug(true)` for verbose output in specific cases.

- **Request completion logging**: HTTP and websocket-routed controller requests log a Rails-style completion line after the response is served:

```text
Completed 200 OK in 1603ms (Controller: 107.8ms | Views: 1097.4ms | DB: 381.8ms (2 queries) | Velocious: 16.0ms)
```

`Controller` measures before callbacks plus action work, excluding nested view rendering and database queries. `Views` measures JSON/view rendering and file-response setup. `DB` measures database driver query time and query count. `Velocious` is the remaining framework overhead, including routing, request setup, timeout handling, and response writing.

- **Query logging**: Database queries log at `info` level by default with Rails-style elapsed time:

```text
Task Load (1.9ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`id` = 1 LIMIT 1
  ↳ src/routes/tasks/controller.js:12:in show
```

Model queries use operation names such as `Task Load`, `Task Count`, `Task Pluck`, `Task Create`, `Task Update`, and `Task Destroy`. Raw driver queries use `SQL`. The source arrow is included only when Velocious can identify an application frame; dependency and framework frames such as `node_modules` are omitted.

Query logging defaults to off in the `test` environment to keep CI output quiet and is skipped when no output emits `info`. Override it with `logging: {queryLogging: true}` when a test build should write SQL timing logs, and use the normal logging output settings to send those logs to console or file.

- **Credential redaction**: Request headers, parsed body/params, nested arrays, URL queries, WebSocket authentication params, rendered SQL diagnostics, and request/frontend-model errors are redacted before formatting and output fan-out. Defaults match common authorization, authentication, credential, password, secret, token, API-key, cookie/session, and base64-content name variants case-insensitively. Add application names with `logging.sensitiveNames`; entries must be non-blank strings. Exact request-scoped values are replaced in SQL/error text while safe fields, SQL shape, timing, source lines, error class/backtrace, and correlation metadata remain visible. Import `LOG_REDACTION_MARKER` from `velocious/build/src/log-redactor.js` when code needs to compare the deterministic marker. See [logging and credential redaction](docs/logging.md#credential-redaction).

## Listen for framework errors

Velocious emits framework errors (including uncaught controller action errors) on the configuration error event bus:

```js
configuration.getErrorEvents().on("framework-error", ({error, request, response, context}) => {
  // Send to your error reporting tool of choice
  console.error("Framework error", error, context)
})

configuration.getErrorEvents().on("all-error", ({error, errorType}) => {
  console.error(`Velocious error (${errorType})`, error)
})
```

Genuinely unexpected frontend-model command failures reach this bus too. The frontend-model controller catches them to return an `internal_error` response with the original message and stack trace by default (or `Request failed.` without debug fields when `exposeInternalErrorsToClients: false`) and a correlation ID, then emits them as `framework-error`/`all-error` with the same correlation ID and `context.frontendModelEndpoint === true`. Expected user-flow errors are excluded: validation failures are forwarded with their real message (for example `Name can't be blank`), invalid client query descriptors are returned as frontend-model query errors, and `error.velocious`-annotated / `safeToExpose` errors keep their expected-error status without irrelevant debug fields. A raw `errorType` property alone is not considered safe and does not suppress reporting.

Unexpected inbound decoded WebSocket dispatch failures emit one `framework-error` and one matching `all-error`. Established expected client-flow errors remain excluded from both events.

MySQL/MariaDB transaction contention that will run another outer attempt emits a structured
`database-deadlock-retry` event through `configuration.getErrorEvents()` and mirrors it to `all-error` with
`errorType: "database-deadlock-retry"`. Required context fields are `stage`, `driverType`,
`contentionKind`, `attempt`, `maxAttempts`, `willRetry` (always `true` for this retry-only event), and
`transactionAttemptDurationMs`. Exhausted/non-retried contention emits no retry event.
Pool-owned connections optionally add logical `databaseIdentifier` and opaque
`databaseIdentifierFingerprint`/`databaseIdentityFingerprint` fields; the logical identifier itself
is always redacted. Named checkouts always redact `operationName` and add only its bounded opaque
`operationNameFingerprint` for correlation. Query failures add
`sqlOperation`/`sqlFingerprint`. True deadlocks may add `statusCapture` and the bounded structural
`innodbDeadlockSummary`, including explicit bounded MariaDB counterparty conflict edges whose owner
is intentionally not inferred; lock-wait timeouts report `statusCapture: "not-applicable"` and never attach a
historical graph. Capture, parsing, and listeners remain detached from rollback and retry control
flow. See the exact identity, redaction, structural bounds, failure-channel, and optional-field
contract in [deadlock retry diagnostics](docs/logging.md#deadlock-retry-diagnostics).

## Use the Websocket client API (HTTP-like)

```js
import WebsocketClient from "velocious/build/src/http-client/websocket-client.js"

const client = new WebsocketClient({url: "ws://localhost:3006/websocket"})
await client.connect()

// Call controller actions like normal HTTP helpers
const response = await client.post("/api/version", {locale: "en"})
console.log(response.statusCode, response.json())

// Listen for broadcast events
const unsubscribe = client.on("projects", (payload) => {
  console.log("Project event", payload)
})

// Trigger a broadcast from another action
await client.post("/api/broadcast-event", {channel: "projects", payload: {id: 42}})

unsubscribe()
await client.close()
```

For long-lived Node clients, the constructor also accepts opt-in liveness options (all default off, so browser/Expo usage is unchanged): `webSocketImplementation` (inject Node's `ws`, since the global/undici WebSocket exposes neither protocol ping nor an unref-able socket), `heartbeatIntervalMs` (a ping heartbeat that drops a socket whose peer stops ponging, so a client notices a vanished server), and `unref` (unref the underlying socket so an idle connection can't keep the process alive on its own). See [docs/websocket-channels.md](docs/websocket-channels.md).

`await client.close()` is a final graceful shutdown that releases resumable server-session state. Every channel subscription still tracked by the client, including a pending subscription, becomes closed and receives `onClose("client_close")`. A pending `subscription.ready` still rejects because no subscribe acknowledgement arrived, and its error identifies `client_close`. Throwing channel close callbacks do not stop the remaining handles or transport/base cleanup; after cleanup, `client.close()` rejects with the original error for one failed callback or teardown step, or with an `AggregateError` for multiple failures.

Unexpected transport drops first attempt to resume server-session state. On a multi-worker server, the client automatically puts the prior session identity in the reconnect upgrade URL so the host can route it to its owning worker; routing is session-based and never source-IP-based. A successful resume retains the existing server-side connection and channel instances. If the server instead rejects the old session with `session-gone`, SnapReq promotes the already-established fresh session, reopens still-live one-to-one connection handles, and re-subscribes still-live channel handles. Those public handles remain usable and channel readiness resolves on the fresh session; explicitly closed handles stay closed. A channel that the server permanently closes or rejects remains terminal and is not automatically reopened; register a new listener after changing the authorization or request context. See [the WebSocket channel lifecycle guarantees](docs/websocket-channels.md#lifecycle-guarantees-phase-1b).

## Subscribe to events

```js
const socket = new WebSocket("ws://localhost:3006/websocket")

socket.addEventListener("open", () => {
  socket.send(JSON.stringify({type: "subscribe", channel: "projects"}))
})

socket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data)

  if (msg.type === "event" && msg.channel === "projects") {
    console.log("Got project event payload", msg.payload)
  }
})
```

If `websocketChannelResolver` is configured, subscribe messages are treated as channel identifiers (see below).

## Broadcast an event from backend code

Any backend code (controllers, services, jobs) can publish to subscribed websocket clients using the shared event bus on the configuration:

```js
// Inside a controller action
const {channel, payload} = this.getParams() // or compose your own payload
this.getConfiguration().getWebsocketEvents().publish(channel, payload)
this.renderJsonArg({status: "published"})
```

Publishes are queued per channel: events on the same channel are persisted and dispatched in FIFO order, while a slow or failing channel never delays unrelated channels. `configuration.awaitPendingBroadcasts()` settles once every broadcast accepted before the call has settled. See [docs/websocket-channels.md](docs/websocket-channels.md#publish-ordering-and-failure-semantics) for the full ordering and failure contract.

## Websocket channels

You can resolve websocket channel classes from subscribe messages and let them decide which streams to allow:

```js
import WebsocketChannel from "velocious/build/src/http-server/websocket-channel.js"

class NewsChannel extends WebsocketChannel {
  async subscribed() {
    if (this.params().token !== process.env.NEWS_TOKEN) return

    await this.streamFrom("news")
  }

  async unsubscribed() {
    // Optional: cleanup when the socket closes
  }
}

const configuration = new Configuration({
  // ...
  websocketChannelResolver: ({request, subscription}) => {
    const channel = subscription?.channel
    const params = subscription?.params || {}

    if (channel === "news") return NewsChannel

    const query = request?.path?.().split("?")[1]
    const legacyChannel = new URLSearchParams(query).get("channel")

    if (legacyChannel === "news") return NewsChannel
  }
})
```

Channel classes are the recommended place to authorize subscriptions and decide which streams a connection should receive. If authorization fails, simply return without calling `streamFrom` or close the socket in `subscribed()`.

Subscribe from the client using a channel identifier and params:

```js
socket.send(JSON.stringify({
  type: "subscribe",
  channel: "news",
  params: {token: "secret"}
}))
```

### V2 channel registration, replay, and live-only channels

`configuration.registerWebsocketChannel(name, ChannelClass)` registers a channel class for the `channel-subscribe` protocol — distinct from the `websocketChannelResolver` above, which serves the legacy `{type: "subscribe"}` path. Subscribers are routed by the class's `matches(broadcastParams)`, and publishers emit with `configuration.broadcastToChannel(name, broadcastParams, body)`.

- **Stream-scoped replay.** Delivered `channel-message` frames carry the server `eventId`; a client can pass its last-seen `lastEventId` on (re)subscribe and the server replays only the persisted events belonging to that subscription's stream (10-minute retention, written only while the channel is interested). A checkpoint that expired or belongs to a different stream reports `channel-replay-gap` and the subscription continues with live delivery.
- **Live-only channels.** `registerWebsocketChannel(name, ChannelClass, {liveOnly: true})` declares a channel that must never reach the replay event log; marking one interested throws, and the persistence decision rejects it even if stale interest state exists.
- **Replayable params.** Channel classes can override the static `replayableBroadcastParams(broadcastParams)` hook to control which broadcast params are persisted for replay (for example, stripping server-only values).

See [docs/websocket-channels.md](docs/websocket-channels.md) for the full wire protocol, V1/V2 gap semantics, and lifecycle guarantees.

## Raw websocket handlers

If you need to accept custom websocket message formats (for example, a vendor that does not use the Velocious request/subscribe protocol), provide a `websocketMessageHandlerResolver` in your configuration. It receives the upgrade request and can return a handler object with `onOpen`, `onMessage`, `onClose`, and `onError` hooks:

```js
const configuration = new Configuration({
  // ...
  websocketMessageHandlerResolver: ({request, configuration}) => {
    const path = request.path().split("?")[0]

    if (path === "/custom/socket") {
      return {
        onOpen: ({session}) => {
          session.sendJson({event: "connected"})
        },
        onMessage: ({message}) => {
          console.log("Inbound message", message)
        }
      }
    }
  }
})
```

When a raw handler is attached, Velocious skips channel resolution and forwards parsed JSON messages directly to the handler.

## Combine: subscribe and invoke another action

You can subscribe first and then call another controller action over the same websocket connection to trigger broadcasts:

```js
socket.send(JSON.stringify({type: "subscribe", channel: "news"}))

socket.send(JSON.stringify({
  type: "request",
  id: "req-broadcast",
  method: "POST",
  path: "/api/broadcast-event",
  body: {channel: "news", payload: {headline: "breaking"}}
}))
```

# Testing

If you are using Velocious for an app, Velocious has a built-in testing framework. You can run your tests like this:
```bash
npx velocious test
```

Test declarations can be imported from `@velocious/testing`; the compatibility
`velocious/build/src/testing/test.js` facade uses the same package registry and remains
supported. Its `expect`, `arrayContaining`, and `objectContaining` exports, along
with the retained deep `expect.js` and `expect-utils.js` paths, delegate directly
to the browser-safe package implementation. Under Velocious, ordinary callbacks
receive `testArgs`, while `it.each` callbacks receive their row arguments followed
by `testArgs`.

If you are developing on Velocious, you can run the tests with:

```bash
./run-tests.sh
```

Tests default to a 60-second timeout. Override per test with `{timeoutSeconds: 5}` or set a suite-wide default via `configureTests({defaultTimeoutSeconds: 30})`.
Failed root setup records every selected descendant as a zero-attempt failure and reports the original message and stack, including when the package runner's timeout settles before the framework adapter hook returns.

Database-backed tests default `testArgs.databaseCleaning` to transaction rollback. The configured testing hook uses this metadata to cover `beforeEach`, the test body, and `afterEach` hooks on one pinned connection. Use `{databaseCleaning: {transaction: false, truncate: true}}` only for behavior that requires physical root transactions, independent commits, DDL that auto-commits or cannot run inside the wrapper transaction, lock contention, or genuine concurrency. Transaction-disabled non-request tests use ordinary independently owned checkouts instead of a runner-pinned connection. Tests that own their pool lifecycle or use only private databases can disable configured cleaning with `{databaseCleaning: {transaction: false, truncate: false}}`. See [database cleanup guidance](docs/testing-guidelines.md#preferred-strategy).

Truncation-based test cleanup batches eligible tables into one request on PostgreSQL,
SQL Server, and SQLite while preserving each driver's existing identity behavior,
foreign-key restoration, stale-schema retry, and SQL.js persistence guarantees.
MySQL/MariaDB batching requires the database's existing `multipleStatements: true`
option; the default configuration keeps sequential `TRUNCATE TABLE` requests. See
[database cleanup guidance](docs/testing-guidelines.md#truncation-cleanup).

Request tests share transaction-active, non-tenant database connections with their in-process HTTP handlers. Eligibility is evaluated when each request is dispatched, so a hook can start a transaction and issue a request in the same callback. This makes uncommitted setup visible to handlers while preserving rollback isolation. Without an active transaction, handlers use independent pooled connections, so concurrency and locking tests can opt out of transaction cleanup and exercise production-style connections. Shared connection state is scoped to the test lifecycle and cleared around each test. See [docs/testing-guidelines.md](docs/testing-guidelines.md#request-test-database-connections).

Transactional tests also share active non-tenant connections with real forked, reusable pooled, and spawned background-job child runners through a per-attempt test-only loopback broker. Parent setup and child writes therefore occupy the same physical transaction and roll back together, including background-job persistence. Backend harnesses can use [`TestTransactionSession`](docs/test-transaction-sessions.md) to propagate an ephemeral capability to already-running services and lazily enroll exact tenant physical identities. A current async-context connection remains authoritative when it already matches the resolved tenant; a context-selected tenant connection substitutes only for a stale inherited request pin and must match the currently resolved physical identity or request routing fails loudly instead of crossing tenants or adopting another fallback. Tests using `{transaction: false, truncate: true}` retain ordinary independent connections for DDL, lock contention, independent commits, and genuine concurrency. See [docs/testing-guidelines.md](docs/testing-guidelines.md#request-test-database-connections).

In broker-backed background-job specs, await an out-of-band job-body completion signal before polling durable job status so the parent poll does not contend with the child's database work on the shared physical connection.

Multiple configured databases route by identifier. Tenant-only databases remain excluded by default; a test can explicitly call `registerTransactionalTenant({databaseIdentifier, tenant})` from its attempt args to share one transaction with same-process paths resolving that exact physical tenant configuration. That registration remains active through `afterEach` and is revoked, rolled back, and released afterward. Emergency cleanup for a lifecycle hung beyond timeout grace revokes pending setup before it can publish stale state, bounds cleanup waits, and discards its physical tenant connection, so stale resumed work cannot use a driver recycled into a successor attempt. See [docs/testing-guidelines.md](docs/testing-guidelines.md#in-process-test-database-connections).

Warm pooled children receive the active broker capability per job, discard retained proxy state when the capability changes, and fail closed if a transactional dispatch lacks coordinates. Child transaction/savepoint work holds a FIFO lease on the parent physical connection until the matching root release or rollback.

# Writing a request test

First create a test file under something like the following path 'src/routes/accounts/create-test.js' with something like the following content:

```js
import {describe, expect, it} from "velocious/build/src/testing/test.js"
import Account from "../../models/account.js"

describe("accounts - create", {type: "request"}, async () => {
  it("creates an account", async ({client}) => {
    const response = await client.post("/accounts", {account: {name: "My event company"}})

    expect(response.statusCode()).toEqual(200)
    expect(response.contentType()).toEqual("application/json")

    const data = JSON.parse(response.body())

    expect(data.status).toEqual("success")

    const createdAccount = await Account.last()

    expect(createdAccount).toHaveAttributes({
      name: "My event company"
    })
  })
})
```

# Routes

Create or edit the file `src/config/routes.js` and do something like this:

```js
import Routes from "velocious/build/src/routes/index.js"

const routes = new Routes()

routes.draw((route) => {
  route.resources("projects")

  route.resources("tasks", (route) => {
    route.get("users")
  })

  route.namespace("testing", (route) => {
    route.post("truncate")
  })

  route.get("ping")
})

export default {routes}
```

# Controllers

Create the file `src/routes/testing/controller.js` and do something like this:

```js
import Controller from "velocious/build/src/controller.js"

export default class TestingController extends Controller {
  async truncate() {
    await doSomething()
    await this.render({json: {status: "database-truncated"}})
  }

  async anotherAction() {
    render("test-view")
  }
}
```

When `render({json: ...})` receives Velocious backend model instances, it now auto-serializes them with frontend-model transport markers. After transport deserialization on the client, registered frontend models hydrate automatically:

```js
import {deserializeFrontendModelTransportValue} from "velocious/build/src/frontend-models/transport-serialization.js"

const tasks = await Task.toArray()

await this.render({
  json: {
    tasks
  }
})

const response = await fetch("/tasks")
const result = deserializeFrontendModelTransportValue(await response.json())

result.tasks[0] instanceof Task //=> true
result.tasks[0].name() //=> frontend model accessor
```

## Cookies

Set cookies from controllers:

```js
this.setCookie("session_id", "abc123", {httpOnly: true, sameSite: "Lax"})
```

Read cookies from the request:

```js
const cookies = this.getCookies()
const sessionCookie = cookies.find((cookie) => cookie.name() === "session_id")
```

Encrypted cookies use `cookieSecret` from configuration:

```js
this.setCookie("user_token", "secret", {encrypted: true, httpOnly: true})
```

# Views

Create the file `src/routes/testing/another-action.ejs` and so something like this:

```ejs
<p>
  View for path: <%= controller.getRequest().path() %>
</p>
```

# Background jobs

Velocious includes a simple background jobs system inspired by Sidekiq.

In a release-directory production topology, one jobs generation is a
same-release `background-jobs-main` and worker pool on its own endpoint. Start the
complete candidate generation before activation. A retired main stops schedules,
new dispatch, and new handoffs but remains with its old workers to supervise the
handoffs it already owns until every worker drains. Old workers never transfer to
the new main. Deploy and HTTP/WebSocket drain completion are independent of this
potentially hours-long lifecycle. See [release-generation
draining](docs/background-jobs.md#release-generation-draining).

Jobs that remain owned by a retired generation may still use the unchanged
`performLater()` / `performLaterWithOptions()` APIs to create follow-up work.
Velocious carries the producer's exact handoff through its asynchronous
execution context and atomically validates that lease with insertion or queued
deduplication. Each call has its own internal replay identity, so two identical
calls create distinct jobs unless the caller explicitly requests queued
deduplication or durable idempotency. The retired main commits and wakes the
queue but never dispatches the follow-up; the active generation owns that work.
If the first sent request loses its acknowledgement after the main accepts the
generation fence, whether through connection loss or deadline expiry, the Node
client makes one recovery attempt with the exact same producer proof and
invocation identity. Legacy/default connections never replay automatically, even
if execution context supplied producer metadata. Pre-send generation failures
and explicit enqueue rejections are not retried. Ordinary retired enqueue,
replace, and cancel requests remain rejected.

Velocious provides the opt-in generation protocol; production still requires a
supervisor that preserves old generation units and release pins, and a deploy
coordinator that retires the old generation before activating the healthy
candidate without waiting for retired work to finish.

Candidate activation performs bounded durable concurrency reconciliation: it
examines queue-derived keys and counters that are active or stale instead of
running a job-table count query for every historical key. If recovery retires a
candidate while that work is still in flight, the retirement fence wins and
activation cannot later restore ownership or acknowledge success. The SQL store
also repairs secondary indexes missed by older background-job add-column
upgrades through a one-time internal migration, with conflict-safe SQLite index
creation across generation processes.

Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options, or by deriving the key in a hydrated job instance's non-static `concurrencyKey()` method. Explicit enqueue options win. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).

Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata. Process-level pooled-runner failures also carry one shared `context.runnerFailure` snapshot for every affected job, with active handoff identities, runner/worker lifecycle and PIDs, exit code/signal, termination reason, and an explicit nullable OOM verdict. Listen for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).

## Setup

Create a jobs directory in your app:

```
src/jobs/
```

Start the background jobs main process (the queue router):

```bash
npx velocious background-jobs-main
```

Start one or more workers:

```bash
npx velocious background-jobs-worker
```

## Configuration

You can configure the main host/port in your configuration:

```js
export default new Configuration({
  // ...
  backgroundJobs: {
    mode: "background",
    host: "127.0.0.1",
    port: 7331,
    databaseIdentifier: "default",
    maxConcurrentForkedJobs: 4,
    maxConcurrentInlineJobs: 4,
    pooledRunnerCount: 4,
    pooledRunnerConcurrency: 1,
    pooledRunnerMaxJobs: 100,
    pooledRunnerMaxRssBytes: 536870912,
    pooledRunnerMaxLifetimeMs: 3600000,
    dispatchStrategy: "beacon",
    jobTimeoutMs: null,
    // Release-directory deployments opt in with one exact id and local socket:
    generationId: "release-20260828.1",
    initialGenerationState: "candidate",
    lifecycleSocketPath: "/srv/app/releases/20260828.1/run/background-jobs.sock"
  }
})
```

`backgroundJobs.mode` is separate from a job's `executionMode`. The default
`"background"` mode preserves the Node SQL queue, TCP main/worker transport, and
per-job `"pooled"` execution default. `"inline"` is a platform-neutral,
non-durable application mode: `performLater` performs immediately and rejects
queue/scheduling/retry/execution options whose guarantees require durable state.
Custom persistence can be supplied as a `BackgroundJobsAdapter` instance or
synchronous factory. See [runtime modes and adapters](docs/background-jobs.md#runtime-modes-and-adapters)
for the contract, lifecycle, Node TCP/wake behavior, the explicit
`platform-job.js` browser/Expo entry, and SQL-only compatibility boundaries.

Browser and Expo configurations have a built-in local SQLite adapter in
`"background"` mode. Register portable classes statically; no main process,
worker, socket, or child process is started:

```js
import VelociousJob from "velocious/build/src/background-jobs/platform-job.js"

class UploadPendingChangesJob extends VelociousJob {
  async perform(projectId) {
    await uploadPendingChanges(projectId)
  }
}

export default new Configuration({
  backgroundJobs: {
    databaseIdentifier: "default",
    jobClasses: [UploadPendingChangesJob],
    maxConcurrentInlineJobs: 4,
    queues: {uploads: {maxConcurrent: 2, priority: 10}}
  }
})
```

Local enqueue participates in an existing application transaction and wakes the
dispatcher only after commit. It supports queue caps/priorities, explicit
concurrency, queued deduplication, one-off scheduling, retries/backoff,
`rescheduleIn`, fenced acknowledgements, graceful close/reopen, interrupted-job
recovery, and the existing SQL.js persistence backends. It supports one active
configuration-owned adapter per app/database; OS background execution and
multi-tab leadership are outside this backend. See
[local background jobs](docs/local-background-jobs.md).

Or via env vars:

```
VELOCIOUS_BACKGROUND_JOBS_HOST=127.0.0.1
VELOCIOUS_BACKGROUND_JOBS_PORT=7331
VELOCIOUS_BACKGROUND_JOBS_DATABASE_IDENTIFIER=default
VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_FORKED_JOBS=4
VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_INLINE_JOBS=4
VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_COUNT=4
VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_JOBS=100
VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_RSS_BYTES=536870912
VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_LIFETIME_MS=3600000
VELOCIOUS_BACKGROUND_JOBS_DISPATCH_STRATEGY=beacon
VELOCIOUS_BACKGROUND_JOBS_POLL_INTERVAL_MS=1000
VELOCIOUS_BACKGROUND_JOBS_WORKER_SHUTDOWN_TIMEOUT_MS=indefinite
VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS=5400000
# Opt-in release generation values (omit all three for exact legacy behavior):
VELOCIOUS_BACKGROUND_JOBS_GENERATION_ID=release-20260828.1
VELOCIOUS_BACKGROUND_JOBS_INITIAL_GENERATION_STATE=candidate
VELOCIOUS_BACKGROUND_JOBS_LIFECYCLE_SOCKET_PATH=/srv/app/releases/20260828.1/run/background-jobs.sock
```

Activate or retire that exact generation with one acknowledged local request:

```sh
npx velocious background-jobs:activate --generation release-20260828.1 --socket /srv/app/releases/20260828.1/run/background-jobs.sock
npx velocious background-jobs:retire --generation release-20260828.1 --socket /srv/app/releases/20260828.1/run/background-jobs.sock
```

Each lifecycle command sends one request with no retry and has a hard 10000ms
deadline; `--timeout-ms` accepts 1 through 60000ms. Generation-aware workers,
clients, and reporters require their hello acknowledgement before readiness or
mutation and bound it to 4000ms by default.

Generation ids supplied through config, environment, API, or CLI must be
identical and match `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`; invalid or conflicting
identity fails before listening. Omit generation settings to preserve legacy
worker ids, protocol, disconnect recovery, and custom-adapter compatibility.
An ID-only configuration derives `candidate`; that default does not conflict
with an explicit API/CLI `active` or `retired` recovery state, while multiple
actual state sources must still agree.

`VELOCIOUS_BACKGROUND_JOBS_WORKER_SHUTDOWN_TIMEOUT_MS` (default: `indefinite`) bounds how long a `background-jobs-worker` waits for in-flight jobs on `SIGTERM`/`SIGINT` before terminating any forked or spawned child runners still running. The default waits for jobs to finish and never interrupts a running job; a positive finite cap is a per-worker shutdown control for an explicitly requested process stop, not the normal deploy-completion mechanism. During release retirement, the old jobs-main and workers may drain for hours after deploy returns. See [docs/background-jobs.md](docs/background-jobs.md#worker-shutdown-and-process-job-draining).

`maxConcurrentInlineJobs` (default: `4`) caps how many `executionMode: "inline"` jobs a single `background-jobs-worker` process runs in parallel. Concurrency is at the JS event-loop level: every job in flight shares the worker's process and DB connection pool, so the cap should fit the pool, not the CPU count. Forking remains the right tool when you need memory isolation across long-running jobs or want to use more cores; select it with `executionMode: "forked"`.

New jobs default to `executionMode: "pooled"`: a worker runs them in warm, reusable Node child runners. `pooledRunnerCount` (default: `4`) bounds this independent per-worker pool, and `pooledRunnerConcurrency` (default: `1`) sets how many jobs each child runs at once on its own event loop, so total pooled capacity is `pooledRunnerCount × pooledRunnerConcurrency` — raise concurrency for I/O-bound jobs to get high throughput from a bounded, isolated set of processes. Workers advertise that exact free-slot count and the main consumes one slot per durable handoff, allowing one readiness notification to fill the pool. If an initialized child exits unexpectedly, the worker immediately advertises the freed capacity while failure reports retry; the replacement is spawned lazily by the next dispatch, and a pre-startup crash does not trigger a respawn loop. `pooledRunnerCount`, `pooledRunnerConcurrency`, and `pooledRunnerMaxJobs` must be finite positive integers; the RSS and lifetime limits must be finite positive numbers. A child is recycled after an acknowledged job when it reaches `pooledRunnerMaxJobs` (default: `100`), `pooledRunnerMaxRssBytes` (default: `536870912`, or 512 MiB), or `pooledRunnerMaxLifetimeMs` (default: `3600000`, or one hour). `execution_mode` is the single source of truth for a job's runtime — pooled rows persist as `execution_mode = "pooled"` directly. Pooled jobs also persist acceptance evidence (`child_received_at_ms`, `child_started_at_ms`, `child_instance_id`, `child_pid`) so a handed-off job can be told apart from one whose runner never picked it up. See [execution modes and pooled runners](docs/background-jobs.md#execution-modes-and-pooled-runners).

Cold pooled jobs share one atomic model-bootstrap phase. If that phase fails, all
waiting jobs receive the failure; a later job in the surviving child cannot run
until a complete model-initialization phase succeeds. The pool's configured
concurrency and per-job connection scopes are unchanged.

`maxConcurrentForkedJobs` (default: `4`) caps how many out-of-process `executionMode: "forked"` or `executionMode: "spawned"` jobs one worker may keep in flight. Forked jobs use `child_process.fork()` with an attached IPC channel. After the main process acknowledges their durable status report, forked and spawned one-shot runners exit without waiting for graceful Beacon/database teardown; the OS closes their process-owned resources. A missing or rejected status acknowledgement makes the runner exit as failed instead of reporting clean success. Spawned jobs use the legacy `background-jobs-runner` CLI process via `child_process.spawn()` and are only for callers that intentionally want that spawned behavior.

`jobTimeoutMs` (or `VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS`, milliseconds; default: disabled) is a wall-clock backstop for `"forked"` and `"pooled"` jobs. A job still running after the timeout is terminated (`SIGTERM`, then `SIGKILL` after the reaping grace) and reported `failed`, so a genuinely-hung job can't pin a worker's capacity — and its whole-app boot and DB connections — indefinitely (notably a retired-release worker draining after a deploy). For a **pooled** job the whole child running it is killed, so its concurrent in-flight siblings on that child are also reported `failed` and requeued — a hung JS job can't be cancelled any other way — before a replacement child is spawned. Set `options.timeoutMs` to a positive integer no greater than `2_147_483_647` for a per-job wall-clock timeout that overrides the worker default; a non-positive finite value disables the backstop for that job. Invalid types, non-finite values, fractions, and larger values are rejected before persistence. Omit it to retain the worker-level fallback. `"inline"` jobs are not covered — they share the worker's process and can't be killed without killing the worker. See [docs/background-jobs.md](docs/background-jobs.md#job-timeout-hung-runner-backstop).

### Dispatch strategy

`dispatchStrategy` controls how `background-jobs-main` detects new work.

- `"beacon"` (default): event-driven dispatch. `background-jobs-main` drains the queue when a job is enqueued (directly, or via a [Beacon](docs/beacon.md) broadcast from another process), when a worker comes up or reports ready, and at the exact `scheduled_at_ms` of the next future-scheduled job via a precise `setTimeout`. There is no fixed-interval polling. The `background_jobs` table is the durable log — on (re)connect to Beacon, the dispatcher does a one-shot catch-up drain so anything enqueued while the bus was unreachable is picked up.
- `"polling"`: legacy fixed-interval poll. `background-jobs-main` runs `SELECT … FROM background_jobs WHERE status='queued' AND scheduled_at_ms <= now` every `pollIntervalMs` (default `1000`). Use this if you want the previous behavior, or for environments where Beacon is unavailable and you don't want event-driven dispatch.

Beacon is opt-in for the rest of the framework, but the dispatcher uses event-driven dispatch even when Beacon is not configured — it falls back to direct in-process triggering from the enqueue/handoff paths plus `setTimeout` for scheduled jobs. Configure Beacon when you want cross-process enqueues (e.g. enqueue from an HTTP server process) to wake the main process immediately.

## Defining jobs

```js
import VelociousJob from "velocious/build/src/background-jobs/job.js"

/** @augments {VelociousJob<[string, string]>} */
export default class MyJob extends VelociousJob {
  static databaseIdentifiers = ["default"]

  /**
   * @param {string} arg1
   * @param {string} arg2
   * @returns {Promise<void>}
   */
  async perform(arg1, arg2) {
    await doWork(arg1, arg2)
  }
}
```

`VelociousJob` is generic over the tuple of arguments `perform` takes. Declaring it
with `@augments {VelociousJob<[...]>}` (or `extends VelociousJob<[...]>` in TypeScript)
lets a type-checked codebase declare `perform`'s parameters as required and typed.
Argument-less jobs use a bare `extends VelociousJob` with `async perform()` — the
default empty-tuple type argument keeps that working unchanged. Plain (unchecked)
JavaScript can ignore the annotation entirely.

Set `static databaseIdentifiers` when a job only needs specific configured databases. Velocious checks out only those connections around `perform` in every execution mode. Use `[]` for a job that needs no ambient database connection. Leaving the property undefined preserves the existing behavior of checking out every active database. See [Background Jobs](docs/background-jobs.md#database-connection-scopes).

Queue a job:

```js
await MyJob.performLater("a", "b")
```

Durably queued jobs use `executionMode: "pooled"` by default. To run one inside the worker process instead:

```js
await MyJob.performLaterWithOptions({
  args: ["a", "b"],
  options: {executionMode: "inline"}
})
```

[Schedule a one-off job](docs/scheduled-background-job-enqueue.md) for a specific epoch timestamp in milliseconds:

```js
await MyJob.performLaterWithOptions({
  args: ["a", "b"],
  options: {scheduledAtMs: Date.now() + 2 * 60 * 60 * 1000}
})
```

Until `scheduledAtMs` is reached, the job remains queued but is not eligible for dispatch. The event-driven dispatcher arms its timer for the earliest future job and wakes at that timestamp. Omitting `scheduledAtMs` keeps the immediate-enqueue behavior.

A running job that cannot proceed yet can reschedule its same durable row without
recording a failure—for example, when a non-blocking lock is busy:

```js
async perform(accountId) {
  if (!(await Account.tryAcquireRefreshLock(accountId))) {
    this.rescheduleIn(30_000)
  }

  await refreshAccount(accountId)
}
```

`rescheduleIn(delayMs)` requires a finite, non-negative safe-integer millisecond
delay and never returns: it stops the current `perform`, releases its worker and
concurrency slots, and makes the same job eligible again after the delay. This is
normal control flow, not failure retry: attempts and failure metadata remain
unchanged, retries are not consumed, and failure/error events are not emitted.
Pooled workers serialize repeated leases for that same durable row through the
prior terminal-acknowledgement boundary; other job IDs remain concurrent.
See [Rescheduling a running job](docs/background-jobs.md#rescheduling-a-running-job).

Use a durable stable key when the same logical one-off schedule must be moved or cancelled without retaining its transient job id:

```js
const result = await MyJob.replaceScheduled({
  scheduleKey: `event:${eventId}:reminder:24h`,
  args: [eventId, reminderRevision],
  options: {scheduledAtMs: reminderAtMs}
})

await MyJob.cancelScheduled(`event:${eventId}:reminder:24h`)
```

Read the durable owner/history or expedite the same queued row without creating another job:

```js
const scheduled = await MyJob.getScheduledJob(`event:${eventId}:reminder:24h`, {
  includeLatestTerminal: true
})
const wake = await MyJob.wakeScheduled(`event:${eventId}:reminder:24h`)
```

A queued owner is atomically cancelled during replacement/cancellation. On Node/TCP, its acknowledgement waits for the corresponding main-process dispatch drain lifecycle. The local adapter commits an adapter-owned transaction before its dispatcher wake runs; inside an ambient application transaction, the mutation and wake remain deferred to that outer commit. Readback returns normalized public jobs, including the transaction-assigned `scheduleOrder` used for causal history ordering; Node retains the per-key order high-water mark when terminal history is pruned. Wake moves only a future queued owner's eligibility to now while preserving its id, attempts, last error, and lineage; repeated calls never enqueue duplicates. A `previousStatus`, cancellation `outcome`, or wake `outcome` of `"handed_off"` means the worker may already be running; Velocious removes or replaces key ownership where appropriate but does not claim that JavaScript stopped. Store a generation/revision in application state, pass it to the job, and re-check it immediately before irreversible effects. Stable keys and full result shapes are documented in [Scheduling One-Off Background Jobs](docs/scheduled-background-job-enqueue.md#replacing-or-cancelling-a-logical-schedule).

Set `deduplicateWhileQueued: true` to coalesce an enqueue onto the earliest identical queued job with the same job name, arguments, and queue when that existing job is scheduled no later than the new request. A retry backed off into the future does not suppress a new immediate enqueue, while repeated immediate triggers and equal or later schedules still coalesce.

Use `options: {idempotencyKey}` when producer replay must converge on the original durable job across every state and even after terminal-job pruning. Ownership is scoped to the resolved job class name, resolved queue, and key; reusing that scope with changed canonical arguments or behavior-affecting options fails. This is distinct from queued-only deduplication, and ownership rows are intentionally retained until a future explicit reconciliation/deletion policy. See [durable idempotent enqueue](docs/background-jobs.md#durable-idempotent-enqueue).

The Node producer gives connection/handshake and post-send acknowledgement separate bounded phases, so connection or generation-handshake latency does not consume the 5-second `enqueued` acknowledgement budget. It rejects and destroys its one-shot socket when either phase stalls or when the main closes before acknowledging. Only a post-send acknowledgement deadline raises `BackgroundJobEnqueueAcknowledgementTimeoutError`, an Awaitery `TimeoutError` subtype with code `BACKGROUND_JOB_ENQUEUE_ACKNOWLEDGEMENT_TIMEOUT` and frozen, payload-free attempt history. The error proves the request was sent but cannot say whether the main committed it. A call made by an executing generation-owned job automatically makes one recovery attempt with its exact internal producer proof and per-call invocation identity, but never after a pre-send generation failure or explicit enqueue rejection. If that replay also times out, the final typed error describes both timed-out attempts. Ordinary enqueues are not retried automatically: replay one with the same durable `idempotencyKey` to recover the original job id without creating a duplicate. Direct `BackgroundJobsClient` users can set a different bounded `enqueueTimeoutMs` constructor option for both phases. See [durable idempotent enqueue](docs/background-jobs.md#durable-idempotent-enqueue).

Select a non-default runtime explicitly with `options: {executionMode: "inline" | "forked" | "spawned"}`.

Inline jobs share the worker process and run concurrently up to `maxConcurrentInlineJobs`, so a single slow inline job no longer blocks the queue. A single worker can also override the configured cap explicitly:

```js
new BackgroundJobsWorker({configuration, maxConcurrentInlineJobs: 8})
```

Standalone background-jobs main and worker processes close their configuration's
database pools during `stop()`. Embedded/test callers that share externally owned
pools can pass `closeDatabaseConnectionsOnStop: false` to `BackgroundJobsMain` or
`BackgroundJobsWorker`; sockets and Beacon still shut down normally. An async
`onStopped` constructor hook can coordinate externally owned cleanup after that
shutdown without wrapping the service's `stop()` method. Repeated `stop()` calls
share one lifecycle and invoke the hook once; dual shutdown/hook failures reject
with an `AggregateError` ordered with the shutdown failure first.

## Scheduled jobs

Velocious can enqueue recurring jobs from the `background-jobs-main` process. Configure them with `scheduledBackgroundJobs` using Sidekiq Scheduler-style `every` arrays:

```js
import BuildCleanupJob from "./src/jobs/build-cleanup-job.js"

export default new Configuration({
  // ...
  scheduledBackgroundJobs: {
    jobs: {
      buildCleanup: {
        class: BuildCleanupJob,
        every: ["1h", {firstIn: "10s"}],
        options: {executionMode: "inline"}
      }
    }
  }
})
```

Supported schedule syntax:

- `every: "5m"`
- `every: ["1h", {firstIn: "30s"}]`
- `every: ["1 day", {firstIn: "5 minutes"}]`

Each recurring tick completes after its enqueue is durably stored and workers
are notified. Dispatch runs through the coalesced background-job drain without
holding the schedule's in-flight enqueue marker, so a slow dispatcher cannot
suppress later ticks. Use `deduplicateWhileQueued` together with a durable
`concurrencyKey` / `maxConcurrency` limit when a recurring logical job must have
at most one queued or running execution.

Or a 5-field POSIX crontab expression via `cron`:

```js
scheduledBackgroundJobs: {
  jobs: {
    nightlyDigest: {
      class: NightlyDigestJob,
      cron: "0 3 * * *" // every day at 03:00 server-local time
    },
    weekdayMornings: {
      class: WeekdayMorningJob,
      cron: "0 9 * * 1-5" // 09:00 Mon–Fri
    },
    everyHour: {
      class: HourlyCleanupJob,
      cron: "@hourly"
    }
  }
}
```

Cron fields are: `minute hour day-of-month month day-of-week`. Supported syntax:

- `*` (any), single values (`5`), ranges (`1-5`), lists (`1,3,5`).
- Step expressions: `*/15` (every 15 minutes), `0-30/5` (every 5 between 0 and 30).
- Month and weekday names: `jan`-`dec`, `sun`-`sat` (case-insensitive). Both `0` and `7` mean Sunday.
- POSIX shortcuts: `@hourly`, `@daily` / `@midnight`, `@weekly`, `@monthly`, `@yearly` / `@annually`.
- Day-of-month and day-of-week interaction follows POSIX/Vixie cron: when both are restricted (neither `*`), the job fires when **either** matches.

Each job must define exactly one of `every` or `cron`. Cron times are evaluated in the **server's local timezone**, at minute granularity.

`background-jobs-main` owns the schedule and enqueues the configured jobs into the normal Velocious background-jobs queue. The HTTP server does not run scheduled jobs itself. Graceful main shutdown waits for scheduled enqueues already in flight before closing database connections, so stopped schedulers cannot write into a later application or test lifecycle.

## Persistence and retries

Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. A legacy worker disconnect returns only that socket's leases immediately. Generation mode instead preserves the exact leases through reconnect grace for the same qualified worker, starting that grace only after active ownership completes its initial startup drain, then returns absent workers' leases to the global queue on expiry. Late reports are fenced by generation-qualified worker id, lease id, and handoff time so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. A release-retiring worker revokes readiness but retains heartbeat, its unchanged old endpoint, exact-generation reconnect, accepted work, child execution, durable reports, and acknowledgements until its drain settles; retiring/retired mains reject new identities and never grant reconnecting workers readiness. Startup reconnection/adoption is an abnormal crash/legacy-recovery facility, not the normal deploy topology: during ordinary release retirement the old main remains alive and owns its old workers, and they must not reconnect to the new main. A production integration that restarts jobs-main on every deploy and depends on worker adoption is not compliant with the release-generation contract. See [release-generation draining](docs/background-jobs.md#release-generation-draining) and [worker disconnect recovery](docs/background-jobs.md#worker-disconnect-recovery).

Failed jobs are re-queued with backoff and retried up to 10 times by default (10s, 1m, 10m, 1h, then +1h per retry). You can override the retry limit per job:

```js
await MyJob.performLaterWithOptions({
  args: ["a", "b"],
  options: {maxRetries: 3}
})
```

If a handed-off job does not report back within 2 hours, it is marked orphaned and re-queued if retries remain.

Workers also send periodic heartbeats (and use TCP keepalive) so the main can drop a wedged or half-open worker that never fires a socket `close` and release its leases, and job slots are freed independently of durable, background result reporting so a transient outage can't wedge a worker or lose a completion. See [docs/background-jobs.md](docs/background-jobs.md#worker-liveness).

Every background-jobs process sets a descriptive `process.title` (`velocious background-jobs-main`/`-worker`/`server`/`beacon`), and each forked/spawned job runner is named after the job it runs (`velocious job-runner: <JobName>`, or a job class's `static processTitle`), so `ps`/`top` show which jobs are consuming resources. See [docs/background-jobs.md](docs/background-jobs.md#process-titles).

## Queues

Give a job a queue with `static queue = "..."` (or a `{queue}` job option; the option wins) and cap how many of that queue's jobs run in flight across the whole cluster under `backgroundJobs.queues`:

```js
backgroundJobs: {
  queues: {
    builds: {maxConcurrent: 100}, // I/O-bound: can run well above the core count
    default: {maxConcurrent: 8}   // CPU-bound: keep near the core count
  }
}
```

A job with no queue runs on `"default"`; a queue with no cap is unlimited. Caps are enforced through the durable per-key concurrency mechanism (the reserved `queue:<name>` key) and hold regardless of how many worker processes run. Queue-policy changes are adopted by queued backlog rows when `background-jobs-main` starts; already handed-off jobs drain under their original policy. If they return, reschedule, or retry, the reporting generation releases the original reservation without changing shared policy, and the active generation applies its current queue policy immediately before the next handoff and sends that committed policy to the worker; explicit concurrency remains unchanged. Handoff persistence is fenced against concurrent policy changes. Startup also rebuilds durable active counts under a database advisory lock. The active main then checks those counts every minute, performs no counter writes while they are exact, repairs only locked mismatches, logs a bounded structured repair summary, and immediately retries dispatch so stale capacity cannot require a restart. `db:migrate`, `db:tenants:*`, and routine store/application initialization with an intact jobs table never adopt queued jobs or rebuild global concurrency counts. If schema repair must recreate a missing `background_jobs` table while the migration marker and concurrency table survive, it resets the now-orphaned active counts against that newly empty table. Scheduled jobs honor a job's `static queue` too.

Set `priority` (default `0`) to dispatch a queue ahead of lower-priority ones regardless of enqueue order, so a small time-critical queue is never starved by a flood of low-priority work sharing a worker pool. Unlike Sidekiq's strict queue ordering, priority composes with the caps: a higher-priority queue already at its `maxConcurrent` is skipped and dispatch falls through to the next eligible job. See [docs/background-jobs.md](docs/background-jobs.md#queues-per-queue-concurrency-caps).

## Retention

Terminal `background_jobs` rows are not deleted automatically unless you configure retention, so a busy app otherwise grows the table indefinitely. Set `backgroundJobs.retention` to prune old terminal rows:

```js
backgroundJobs: {
  retention: {
    completedTtlMs: 7 * 24 * 60 * 60 * 1000, // default: 7 days (null/0 disables)
    failedTtlMs: 30 * 24 * 60 * 60 * 1000,   // default: 30 days for failed/orphaned (null/0 disables)
    batchSize: 1000,                          // default: 1000 rows per delete batch
    sweepIntervalMs: 60 * 60 * 1000           // default: 1 hour
  }
}
```

`background-jobs-main` registers a built-in `velocious:prune-terminal-background-jobs` job on the scheduler when retention is enabled, so pruning runs as an ordinary scheduled/queued job (it needs a worker, appears in the job tables, and is bounded to one non-overlapping run). See [docs/background-jobs.md](docs/background-jobs.md#retention-pruning-old-job-rows).

Candidate discovery is ordered by terminal timestamp and job id and runs outside the singleton count-revision mutation lock. Only the short transaction that revalidates and deletes the selected ids publishes a count delta, using the database's actual affected-row count, so a slow sweep or a concurrent pruner does not block enqueue acknowledgements or double-decrement counts. Queued and in-flight jobs never match retention deletion. Composite retention-index activation is intentionally staged for a later release because MariaDB online index DDL conflicts with schema inspection by older serving generations.

## Dashboard

Velocious ships a mountable read-only HTTP API for inspecting jobs (queued, running, completed, failed, orphaned and scheduled), similar in spirit to `sidekiq-web`. Mount it in your routes file the way `Sidekiq::Web` is mounted in Rails:

```js
import VelociousBackgroundJobsApi from "velocious/build/src/background-jobs/web/index.js"

routes.draw((route) => {
  route.mount(VelociousBackgroundJobsApi, {
    at: "/velocious/jobs",
    authorize: async ({request, ability}) => Boolean(ability?.can("manage", "BackgroundJobs")),
    accessTokens: [process.env.VELOCIOUS_JOBS_TOKEN]
  })
})
```

It exposes `GET /api/stats`, `/api/jobs`, `/api/jobs/:id`, `/api/schedule` and `/api/health` under the mount path, gated by a bearer token and/or an `authorize` callback (loopback-only when neither is configured). The dashboard UI is a separate Expo app. See [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md).

# Running a server

```bash
npx velocious server --host 0.0.0.0 --port 8082
```

Threaded servers default to `os.availableParallelism()` HTTP workers. Pass
`--workers` or configure `httpServer.workers` to override that count. Ordinary
connections are distributed round-robin even behind a loopback reverse proxy,
while resumable WebSockets return to their session's worker. Each worker owns a
separate configuration and database pools, so per-worker limits multiply across
the effective worker count (for example, four workers with `pool.max: 10` can
open 40 connections for that pool). The debug snapshot exposes the configured
and effective counts; default in-process mode uses one effective handler.
Only requests carrying a resumable-session query wait for upgrade headers before
worker assignment; ordinary and malformed requests continue directly to the
request parser.

When the server runs in the `development` environment, Velocious watches application `src/` trees and hot-reloads by recycling HTTP workers after `.js`/`.mjs`/`.cjs`/`.json`/`.ejs` changes. That picks up edited controllers, models, resources, routes, and views without a manual server restart while keeping production/test behavior unchanged.

Starting the HTTP server creates `tmp/server.lock` under the configured application directory before Beacon, workers, or the TCP listener start. A second server for the same app fails fast with the lock owner details instead of partially starting. Normal shutdown removes the lock; stale locks with a dead local PID are reclaimed automatically, while locks from another host or unreadable metadata should be removed manually only after confirming no server is running. See [docs/http-server.md](docs/http-server.md#server-lock).

Set `database: false` for an explicitly database-free application; its HTTP and
initializer lifecycle remains intact without creating a default pool or an
implicit route connection checkout. Optional
`httpServer.maxRequestBodyBytes` and
`httpServer.maxBufferedResponseBodyBytes` positive byte limits reject oversized
request bodies with a connection-closing `413` and oversized buffered responses
with a reported, empty `500`. Both remain unbounded when omitted, and streamed
`sendFile` responses are unaffected. The request limit is enforced while
unframed multipart bodies accumulate. See [docs/http-server.md](docs/http-server.md#database-free-applications).

Buffered string and `Uint8Array` responses are compressed with Brotli (`br`) or gzip by default whenever request negotiation and response eligibility allow — no opt-in is required. Disable compression globally with `httpServer.compression: false` or `httpServer.compression: {enabled: false}`, and tune it with `threshold`/`brotliQuality`/`gzipLevel` overrides. Negotiation honors `Accept-Encoding` q-values, wildcards, and identity semantics (empty `406` when no acceptable representation exists), combines repeated `Accept-Encoding` header fields in wire order, and advertises a framework-owned `Vary: Accept-Encoding` on every framework-selected representation (transformed, identity, `406`, and file) so caches key on the field. Streamed `sendFile` responses are never buffered or re-encoded: they are sent identity, so when the client forbids identity the server answers with the same empty `406` without opening or streaming the file. Transformation also skips already-encoded or `no-transform` responses, server-sent events, partial/range responses, bodyless statuses, and non-allowlisted content types. Transformation is additionally excluded automatically for credentialed traffic and validator-carrying responses — requests with `Authorization`/`Cookie` and responses with `Set-Cookie`, `ETag`, `Digest`, or `Content-Digest` are never compressed (compression-oracle protection, and validators stay application-owned). Controllers opt out per response with `response.disableCompression()`, and HEAD requests compute GET-equivalent representation headers without emitting a body. See [docs/http-server.md](docs/http-server.md#response-compression).

# Authorization (CanCan-style)

Define resource classes with an `abilities()` method and use `can` / `cannot` rules to constrain model access.

```js
import Ability from "velocious/build/src/authorization/ability.js"
import BaseResource from "velocious/build/src/authorization/base-resource.js"
import User from "@/src/models/user"

class UserResource extends BaseResource {
  static ModelClass = User

  abilities() {
    const currentUser = this.currentUser()

    if (currentUser) {
      this.can("read", {id: currentUser.id()})
    }
  }
}

export default new Configuration({
  // ...
  abilityResolver: ({configuration, params, request, response}) => {
    return new Ability({
      context: {
        configuration,
        currentUser: undefined, // set from your auth/session layer
        params,
        request,
        response
      },
      resources: [UserResource]
    })
  }
})
```

Then query through authorization rules:

```js
const users = await User.accessible().toArray()
```

`accessible()` reads from `Current.ability()` (request-scoped via AsyncLocalStorage on Node).

You can also pass an ability explicitly:

```js
const ability = new Ability({context: {currentUser}, resources: [UserResource]})
const users = await User.accessible(ability).toArray()
```

Or require explicit ability passing:

```js
const users = await User.accessibleBy(ability).toArray()
```

# Tenant / elevator support

Velocious can resolve a request-scoped tenant and override configured database identifiers per tenant for HTTP routes, websocket subscriptions, and websocket event delivery.

```js
import Configuration from "velocious/build/src/configuration.js"

export default new Configuration({
  // ...
  tenantResolver: async ({params, subscription}) => {
    const projectSlug = subscription?.params?.project_slug || params.project_slug

    if (!projectSlug) return

    return {
      databaseIdentifiers: ["auditTenant"],
      projectSlug
    }
  },
  tenantDatabaseResolver: ({databaseConfiguration, identifier, tenant}) => {
    if (identifier !== "auditTenant" || !tenant?.projectSlug) return

    return {name: `${databaseConfiguration.name}-${tenant.projectSlug}`}
  }
})
```

Use `configuration.runWithTenant(tenant, callback)` or `Current.tenant()` when custom model/database routing needs to read the active tenant manually. The tenant is an app-defined value — inputs (`runWithTenant`/`Current.setTenant`/`Current.withTenant`) accept any `object`, `Current.tenant()` returns `Record<string, unknown> | undefined`, and the `switchesTenantDatabase(...)` resolver callback receives `Record<string, unknown> | null | undefined`, so narrow before reading fields. See [tenant object typing](docs/tenant-databases.md#tenant-object-typing).

Tenant-switched model classes fail closed by default: if `switchesTenantDatabase(...)` cannot resolve a tenant database identifier for the current tenant, Velocious raises `TenantDatabaseScopeError` instead of running the query against the configured fallback database. Set `enforceTenantDatabaseScopes: false` only for legacy apps that still need the old fallback behavior during migration.

For Apartment-style project/account databases, mark the logical per-tenant identifier with `tenantOnly: true`, provide `tenantDatabaseProviders`, and run tenant lifecycle commands explicitly. One logical identifier can resolve to any number of physical tenant databases at runtime; provider `listTenants` is queried for every command run so added/removed tenants do not require configuration changes or redeploys. Cross-tenant `dependent: "restrict"` checks use the matching provider's optional `listRestrictTenants` when present, otherwise `listTenants`, and fail closed when the target tenant identifier has no configured provider.

```sh
npx velocious db:tenants:create projectTenant
npx velocious db:tenants:check projectTenant
npx velocious db:tenants:migrate projectTenant
npx velocious db:tenants:migrate projectTenant --parallel 20
```

Tenant lifecycle commands print start and final counts, report each completed tenant, and emit a heartbeat every 30 seconds with completed and active tenant counts while work is still running.

`afterMigrateTenant` hooks run inside the active default and tenant database connection scope for the tenant being migrated.

At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a Node async context, `Tenant.handle(tenant)` to deeply capture immutable application and physical database identity for overlapping browser/native work, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. SQLite handles additionally expose framework-owned `open`, `flush`, `close`, `delete`, `inspect`, and `withPin` lifecycle methods; `frontendTenantSqlite.maxOpenHandles` bounds resident identities and clean, idle, unpinned handles are evicted least-recently-used. `Tenant.handle(...).databaseOperation(...)` and `.transaction(...)` use bounded pool-owned checkouts and pin model/query/write/association/preload/audit/attachment/raw work to the captured database even if a later UI project switch changes ambient tenant state. The handle also builds tenant-bound live-query sources, exposes an opaque physical identity for filtered record-change subscriptions, and binds project `SyncClient` instances. `Model.usingTenant(tenant)` uses the same safe core and adds eager helpers plus general `databaseOperation`/`transaction` model callbacks; eager records preserve legacy ambient Node follow-up semantics, while browser/native follow-up database work belongs inside the callback APIs. Inactive identifiers, mixed physical tenants, unsupported/cyclic descriptors, expired operations, unscoped tenant event subscriptions, and stale/cross-tenant sync state fail closed. `Tenant.with` and `Tenant.each` retain their connection-establishing and model-initializing ambient behavior for Node request/job flows. `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).

`SchemaCloner` adds a missing auto-increment column and its separate source unique index in one schema alteration, including on MySQL/MariaDB where an auto-increment column must be keyed when it is created.

`DataCopier.move(...)` safely re-homes selected rows between different physical databases: the target write and verification commit before the source delete, target-only row transformations are supported, and retries after the source is gone preserve the target.

See [docs/tenant-databases.md](docs/tenant-databases.md) for the full configuration and migration pattern.
