velocious
v1.0.514
Published
* 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 * Built-in record auditing for model lifecycle ch
Readme
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
- Built-in record auditing for model lifecycle changes (see docs/auditing.md)
- Declarative state machines for models (see docs/state-machine.md)
- Migrations for schema changes and UTC datetime storage (see docs/database-migrations.md)
- External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see docs/packages.md)
- Controllers and views for HTTP endpoints
- Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses (see docs/frontend-models.md)
- Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see docs/offline-sync.md)
- Declarative client sync scopes with per-scope cursors, automatic mutation tracking, realtime delivery, and
sync/pullprogress reporting for "X of Y" import screens (see docs/sync-client.md) - Reactive
useLiveQuery(Model.where(...))queries that stay current from committed local model changes across local writes, pulls, and realtime (see docs/live-queries.md) - Server-side sync envelope replay orchestration for app-owned sync receivers (see docs/sync-envelope-replay-service.md)
- SQLite web persistence that automatically prefers OPFS, then IndexedDB, and migrates legacy persisted bytes when possible (see docs/sqlite-web-persistence.md)
- Expo / Metro compatibility guidance and a real Expo export check (see docs/expo-metro-compatibility.md)
- Gap-less positional lists with automatic reordering via
actsAsList(see docs/acts-as-list.md) - Rails-style nested-attribute writes on frontend-model
save()(see docs/nested-attributes.md) - Per-row association counts via
.withCount(...)on frontend and backend queries (see docs/with-count.md) - Consumer-defined per-row SQL aggregates/computations via
.queryData(...)on frontend and backend queries (see docs/query-data.md) - Per-record ability checks via
.abilities(...)on frontend queries +record.can(action)(see docs/abilities.md) - Translated model attributes with current-locale relationship sorting (see docs/translations.md)
- Cross-process broadcast bus for
broadcastToChannelviavelocious beacon, including background job runner processes (see docs/beacon.md) - Configurable HTTP server worker handlers for higher request and websocket throughput (see docs/http-server.md)
- Background jobs with failure events for production reporting (see docs/background-jobs.md)
- Rails-style request and database query logging (see docs/logging.md)
- EJS-backed mailers with delivery, queueing, and payload rendering support (see docs/mailers.md)
- Trusted reverse proxy handling for
request.remoteAddress()(see docs/trusted-proxies.md) - In-process driver schema metadata caching (see docs/schema-metadata-cache.md)
- Planned local-first shared-resource sync architecture (see docs/offline-sync.md)
- Named database connection checkouts, bounded pool waits, and debugging held connections (see docs/database-connections.md)
- Optional built-in debug endpoint for inspecting server and database connection state (see 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)
Setup
Make a new NPM project.
mkdir project
cd project
npm install velocious
npx velocious initBy 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().
Development
When working on Velocious itself, npm scripts are cross-platform (Windows cmd/PowerShell and POSIX shells):
npm run build
npm run test
npm run test:expoCode quality (fallow)
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.
# 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:baselineBaselines 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
Tag tests to filter runs.
describe("Tasks", {tags: ["db"]}, () => {
it("creates a task", {tags: ["fast"]}, async () => {})
})# 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 slowTarget a test by line number or description.
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.
// 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.
describe("Tasks", () => {
it("retries a flaky check", {retry: 2}, async () => {})
})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.
// 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.
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.
# 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 fastFiles are distributed using a greedy load-balancing algorithm. Each file is
weighted by its 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:
npm run test:browserBrowser system tests must be named *.browser-test.js or *.browser-spec.js (override with VELOCIOUS_BROWSER_TEST_PATTERN).
Use beforeAll/afterAll for suite-level setup/teardown.
// src/config/testing.js
export default async function configureTesting() {
beforeAll(async () => {
// setup shared resources
})
afterAll(async () => {
// teardown shared resources
})
}Expectations
Common matchers:
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.
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.
<b>Hello <%= mailer.user.name() %></b>
<p>
Task <%= task.id() %> has just been created.
</p>Deliver immediately or enqueue via background jobs:
await new TasksMailer().newNotification(task, user).deliverNow()
await new TasksMailer().newNotification(task, user).deliverLater()Build the rendered payload without sending when the app needs to store an audit copy or hand delivery to its own transport:
const payload = await new TasksMailer().newNotification(task, user).buildPayload()If your mailer needs async setup, keep the action sync and pass actionPromise:
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:
setDeliveryHandler(async ({to, subject, html}) => {
// send the email via your provider
})Mailer backends can also be configured via your app configuration.
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: "[email protected]"
})
})Install the SMTP peer dependency in your app:
npm install smtp-connectionWhen connectionOptions.auth is present, the SMTP backend authenticates before sending the message.
Test deliveries are stored in memory:
const sent = deliveries()Translations
Velocious uses gettext-universal by default. Configure your locales and fallbacks in the app configuration:
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):
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 _:
<b><%= _("Hello %{userName}", {userName}) %></b>If you want a different translation backend, set a custom translator:
configuration.setTranslator((msgID, args) => {
// return translated string
})Models
npx velocious g:model Account
npx velocious g:model TaskFrontend models from backend resources
You can generate lightweight frontend model classes from resource definitions in your configuration.
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 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.
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:
class AuditLogResource extends FrontendModelBaseResource {
static abilities = ["read"]
static attributes = ["id", "message", "createdAt"]
}Generate classes:
npx velocious g:frontend-modelsFrontend-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"); 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: "[email protected]"})await User.findByOrFail({email: "[email protected]"})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)await user.destroy()user.markForDestruction()to queue a loaded child for destruction on the next parent save (see docs/nested-attributes.md)- State helpers like
user.isNewRecord(),user.isPersisted(),user.isChanged(), anduser.changes() - Attribute methods like
user.name()anduser.setName(...) - Relationship helpers (when
relationshipsare configured), for exampletask.project(),await task.projectOrLoad(),await project.tasks().toArray(),await project.tasks().load(), andproject.tasks().build({...}) - Preload relationships onto records you already have with
await record.preload(Model.preload({...}).select({...}))(orPreloader.preload(records, ...)for arrays), includingselectsExtra(...)and a{force: true}reload option — see docs/frontend-models.md - Attachment helpers (when
attachmentsare configured), for exampleawait task.descriptionFile().attach(file),await task.descriptionFile().download(), andawait task.update({descriptionFile: file})
React components can subscribe to lifecycle broadcasts without manual cleanup code:
import useModelClassEvent from "velocious/build/src/frontend-models/use-model-class-event.js"
useModelClassEvent(Subscription, ["create", "update"], () => {
void loadSubscriptionStatus()
})useCreatedEvent, useUpdatedEvent, and useDestroyedEvent are also available. useUpdatedEvent and useDestroyedEvent accept either a model class or model instance. Lifecycle subscriptions accept the same projection options as frontend-model queries for event records, including select, preload, withCount, abilities, and queryData.
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:
Task.hasManyAttachments("files")
Task.hasOneAttachment("descriptionFile")
Task.hasOneAttachment("archivedPdf", {driver: "s3"})You can also pass a driver class or instance directly on the attachment:
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:
await task.descriptionFile().attach({
content: "my file content",
filename: "file.doc"
})
const descriptionFileUrl = await task.descriptionFile().url()
await task.update({
descriptionFile: {
contentBase64: Buffer.from("my file content").toString("base64"),
filename: "my-doc.doc"
}
})Configure attachment storage drivers in Configuration:
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:
new Configuration({
attachments: {
allowPathInput: true,
allowedPathPrefixes: ["/var/app/uploads"]
}
})Then {path: "..."}
inputs are only accepted when the file resolves inside one of the allowed prefixes.
For frontend models, configure resourceConfig().attachments and use:
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.
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. Direct metadata queries require owner filters: recordType, recordId, and name.
When your frontend app calls a backend on another host/port (or under a path prefix), configure transport once:
import FrontendModelBase from "velocious/build/src/frontend-models/base.js"
FrontendModelBase.configureTransport({
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)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.
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 stay client-safe in production with errorMessage: "Request failed.".
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.
In development and test, Velocious also includes debugErrorClass, debugErrorMessage, and debugBacktrace fields so browser/system-test failures are easier to diagnose without exposing those details in production.
Other non-production environments, such as staging, keep the same client-safe default unless you explicitly opt in with exposeInternalErrorsToClients: true:
const configuration = new Configuration({
environment: "staging",
exposeInternalErrorsToClients: true
})This opt-in is ignored in production; production frontend-model responses never include internal exception details.
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. This is useful for attaching an error-reporting URL while keeping the normal production error message generic:
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 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:
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:
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.
export default new Configuration({
// ...
routeResolverHooks: [
({currentPath}) => {
if (currentPath !== "/special-route") return null
return {controller: "hijacked", action: "index"}
}
]
})Hook return value:
nullto skip{controller, action}to resolve the request- Optional
controllerClassto resolve without importing a controller path - Optional
paramsobject to merge into request params - Optional
controllerPathstring to resolve a controller file outside the app route directory - Optional
viewPathstring override for view rendering lookups
Plugin routes helper
For plugin-style integrations, you can register routes with a simple DSL:
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?})
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 TaskGenerated 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.
Translated models also get a currentTranslation hasOne relationship scoped to the first available row in the current locale fallback order. See 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 for the eager and lazy initialization behavior.
Lifecycle callbacks
Register lifecycle callbacks with either a function or an instance method name. Registrations run in order, so you can stack multiple callbacks on the same lifecycle hook.
class Task extends Record {
async validateSomething() {
await doSomethingElse()
}
}
Task.beforeValidation(async (task) => {
await doSomething(task)
})
Task.beforeValidation("validateSomething")Preloading relationships
const tasks = await Task.preload({project: {translations: true}}).toArray()
const projectNames = tasks.map((task) => task.project().name())Load a relationship after init
const task = await Task.find(5)
const project = await task.projectOrLoad()
await task.loadProject()
const sameProject = task.project()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.
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:
// 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:
Task.belongsTo("project", {autoload: false})Disable auto-load globally via the framework configuration:
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:
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:
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.
Through relationships
Use the through option on hasMany to define a relationship that traverses an intermediate (join) table:
Invoice.hasMany("invoiceGroupLinks")
Invoice.hasMany("invoiceGroups", {through: "invoiceGroupLinks", className: "InvoiceGroup"})Through relationships work with both instance-level loading and batch preloading:
// 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).
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:
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:
const query = Task.joins({project: {account: true}})
const accountTable = query.getTableForJoin("project", "account")Inside relationship scopes, getTableForJoin() is relative to the current scope path:
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(...).
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:
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
const task = new Task({identifier: "task-4"})
task.assign({name: "New task})
await task.save()const task = await Task.create({name: "Task 4"})Bulk insert
Use insertMultiple to insert many rows in one call:
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:
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)Find or create records
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.
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")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
npx velocious g:migration create-tasksWrite 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.
export default new Configuration({
database: {
production: {
default: {
type: "pgsql",
primaryKeyType: "bigint"
}
}
}
})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")
}
}Run migrations from the command line
npx velocious db:migrateRun project seeds from src/db/seed.js (default export should be an async function):
npx velocious db:seedYou can chain multiple commands in one invocation:
npx velocious db:create db:migrateRun script files with initialized app/database context:
npx velocious run-script src/scripts/my-task.jsEvaluate inline JavaScript (Rails-style runner) with initialized app/database context:
npx velocious runner "const users = await db.query('SELECT COUNT(*) AS count FROM users'); console.log(users[0].count)"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:
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:
npx velocious db:schema:dumpdb: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. This preserves the migration ledger 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:
npx velocious db:schema:loaddb: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.
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 for details.
If another process changes the schema outside Velocious while the current process is still running, clear the cache before reading metadata again:
await configuration.ensureConnections(async (dbs) => {
dbs.default.clearSchemaCache()
})To disable schema metadata caching for a database connection, set schemaCache: false on that database config:
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:
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:
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:
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:
await globalThis.velociousCLI.run("db:migrate")In React, you can use the hook which sets globalThis.velociousCLI:
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:
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
import {Task} from "@/src/models/task"
const tasks = await Task.all().toArray()Filtering
const tasks = await Task.where({status: "open"}).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.
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:
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:
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
const tasks = await Task.where("tasks.completed_at IS NULL").toArray()Joins
const tasks = await Task
.joins({project: true})
.where({projects: {public: true}})
.toArray()Preloading relationships
const tasks = await Task.preload({project: {account: true}}).toArray()
const accountNames = tasks.map((task) => task.project().account().name())Selecting columns
const tasks = await Task.select(["tasks.id", "tasks.name"]).toArray()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 *.
const baseQuery = Task.where({state: "open"})
const counts = await baseQuery.reselect("COUNT(*) AS count").results()Ordering
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
const tasks = await Task.order("name").reorder("created_at").reverseOrder().toArray()Limiting and offsetting
const tasks = await Task.limit(10).offset(20).toArray()Grouping
const tasks = await Task.group("tasks.project_id").toArray()Distinct records
const tasks = await Task.joins({project: true}).distinct().toArray()Paging
const tasks = await Task.page(2).perPage(25).toArray()Counting
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
const firstTask = await Task.first()
const lastTask = await Task.last()Find by attributes
const task = await Task.findBy({identifier: "task-5"})
const taskOrFail = await Task.findByOrFail({identifier: "task-5"})Find or initialize/create
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
await Task.where({tasks: {status: "archived"}}).destroyAll()Plucking columns
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:
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.
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, and queued pendingCheckouts[].waitingForMs so production diagnostics can distinguish long-held checkouts from pool-capacity waits.
Websockets
Velocious includes a lightweight websocket entry point for API-style calls and server-side events.
Connect and call a controller
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.
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"
}))
})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
loggingconfig is provided, Velocious sets up a console logger withinfo,warn, anderrorlevels. - Configuration: Supply a
loggingobject when creating your configuration:
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
}
})- Custom logger list: Configure an explicit list of logger instances with levels:
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
BaseLoggerand implement eitherwrite(...)ortoOutputConfig(...):
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>.logby default. - Custom handlers can override
getDefaultLogDirectory,getLogFilePath, andwriteLogToFileif needed.
- Node handler writes to
Debug logging: When
configuration.debugis true or aLoggeris 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 togglelogger.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:
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
infolevel by default with Rails-style elapsed time:
Task Load (1.9ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`id` = 1 LIMIT 1
↳ src/routes/tasks/controller.js:12:in showModel 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.
Listen for framework errors
Velocious emits framework errors (including uncaught controller action errors) on the configuration error event bus:
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 a client-safe Request failed. response, but it also emits them as framework-error/all-error (with context.frontendModelEndpoint === true) so they are reported instead of being silently swallowed. 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 / errorType-marked errors keep their expected-error status — none of these reach the error bus.
Use the Websocket client API (HTTP-like)
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.
Subscribe to events
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, subs
