Package Exports
- @onroad/core
- @onroad/core/core
- @onroad/core/database
- @onroad/core/dev
- @onroad/core/entity
- @onroad/core/filters
- @onroad/core/logging
- @onroad/core/messaging
- @onroad/core/migrations
- @onroad/core/providers/cloudtasks
- @onroad/core/providers/firebase
- @onroad/core/providers/pubsub
- @onroad/core/providers/rabbitmq
- @onroad/core/security
- @onroad/core/storage
- @onroad/core/transport
Readme
TypeRoad
TypeScript backend framework for multi-tenant Express APIs — DI Container, Filter Chain, EventBus, Provider Pattern.
v4.0.0-alpha.1 — Full TypeScript rewrite of the onRoad framework.
Table of Contents
- Installation
- Quick Start
- Architecture Overview
- DI Container & Decorators
- Controllers, Services & Repositories
- Entity System
- Filter Chain
- Security (Roles & Public)
- Sentinel & EventBus
- Providers
- Inter-Service Transport
- Request Context (AsyncLocalStorage)
- Logging
- Plugins
- Storage
- Health Endpoint
- Graceful Shutdown
- Local Development (DevServer)
- Migration CLI
- Testing
- Subpath Exports
- Known Pitfalls
Installation
npm install @onroad/corePeer dependencies (install as needed):
npm install express reflect-metadata pino uuid
# Optional — only if using HttpTransport:
npm install axiosTypeRoad is ESM-only (
"type": "module"). Yourtsconfig.jsonmust use"module": "NodeNext"or"Node16"and enable"experimentalDecorators": true.
Quick Start
import "reflect-metadata"
import { OnRoadExpress, Controller, Service, Repository } from "@onroad/core"
import { SequelizeConnectionManager } from "@onroad/core/database"
import { CorsFilter, JwtFilter, TenantFilter, RoleFilter } from "@onroad/core/filters"
// 1. Define your layers
@Repository()
class OrdemRepository { /* ... */ }
@Service()
class OrdemService { /* ... */ }
@Controller("/ordem")
class OrdemController {
/* ... */
}
// 2. Create the app
const app = new OnRoadExpress({
connections: [
new SequelizeConnectionManager({ dialect: "postgres", host: "localhost", database: "mydb" }),
],
})
// 3. Register filters
app.useFilters([
new CorsFilter({ origins: ["http://localhost:3000"] }),
new JwtFilter({ secret: process.env.JWT_SECRET! }),
new TenantFilter(),
new RoleFilter(),
])
// 4. Register modules
app.register([OrdemController, OrdemService, OrdemRepository])
// 5. Build & start
await app.buildServer({ port: 3001 })Architecture Overview
TypeRoad follows a multi-tenant architecture designed for high isolation and developer productivity. The framework orchestrates the request lifecycle using three main internal layers: RequestContext, OnRoadContainer (DI), and the Model/Repository layer.
Como o TypeRoad processa uma requisição (Multitenancy & Wired)
O TypeRoad automatiza o relacionamento de models e o isolamento de dados entre clientes (tenants) sem exigir que o desenvolvedor passe o ID do tenant manualmente em cada função.
Fluxo de execução dentro de uma request:
AsyncLocalStorage (O Contexto):
- "Vou gravar aqui que esta requisição pertence ao tenant_abc."
- Onde ocorre: Em
src/OnRoadExpress.ts(Linha 317), o métodorequestContext.runinicia o escopo assíncrono capturando o tenant dos headers (através datenantResolver).
Container (A Injeção):
- "Vou criar o Controller, o Service e o Repository para esta requisição."
- Onde ocorre: Em
src/OnRoadExpress.ts(Linha 320), o framework chamathis.container.createScopeeresolve(Controller), garantindo que todas as instâncias injetadas (Services/Repos) compartilhem o mesmo ciclo de vida da request.
Repository (A Demanda):
- "Preciso buscar dados. Typeroad, me dê a Model Ordem."
- Onde ocorre: Em
src/core/SequelizeRepository.ts, o métodogetModel()é chamado. Ele não retorna uma model estática, mas pergunta aoEntityRegistryqual model deve usar para o tenant atual.
EntityRegistry (O Wired & Cache):
- "Deixa eu ver no AsyncLocalStorage... Ah, é o tenant_abc. Deixa eu ver no meu Cache... Já tenho a Model Ordem para o tenant_abc (porque já fiz o Wired antes). Aqui está!"
- Onde ocorre: Em
src/entity/EntityRegistry.ts(Linhas 28-50). Se for o primeiro acesso dotenant_abc, o Typeroad executa o processo de Wired (lê os decorators@HasMany,@BelongsTo, etc., e cria as relações no Sequelize). Se já existir, retorna do cache globalsequelizeModels.
Sequelize (A Execução):
- Executa a query final no banco de dados isolado do cliente correto.
The "Matryoshka" Isolation Pattern
Isolation in runtime is achieved through a nested hierarchy that ensures one tenant's request never sees another's data or instances.
Nível 1: RequestContext (AsyncLocalStorage)
- O
OnRoadExpressenvolve cada execução de rota em um blocorequestContext.run(). - Atua como um "envelope" de memória global para a thread lógica atual, armazenando metadados como
tenantId,loggereappToken. - Permite que qualquer parte do sistema acesse o estado da requisição via
getRequestContext()sem precisar de injeção manual.
- O
Nível 2: OnRoadContainer (RequestScope)
- Dentro do contexto da requisição, o Container cria um objeto
RequestScope. - Este escopo é um mapa de instâncias isolado por um
UUIDúnico para aquela chamada HTTP. - Se múltiplos serviços injetarem o mesmo
Repository, o Container garante que eles recebam a mesma instância dentro daquela requisição (reuso de estado), mas instâncias completamente diferentes de outras requisições concorrentes.
- Dentro do contexto da requisição, o Container cria um objeto
Nível 3: Repository & Connection Isolation
- Repositórios são instanciados pelo Container e recebem automaticamente o
tenantIddo escopo atual. - Ao executar uma query (ex:
find()), o repositório usa seu atributo internothis.tenantpara solicitar aoConnectionManagero pool de conexões específico daquele banco de dados. - Isso garante que o isolamento chegue até o nível físico do banco de dados (schema ou DB separado).
- Repositórios são instanciados pelo Container e recebem automaticamente o
graph TD
subgraph "Nível 1: RequestContext (AsyncLocalStorage)"
direction TB
Metadata["{ tenant: 'cliente_a', logger, token }"]
subgraph "Nível 2: OnRoadContainer (RequestScope)"
direction TB
ScopeID["Scope: UUID-123 (Tenant: 'cliente_a')"]
Instances["Map { Controller, Service, Repository }"]
subgraph "Nível 3: Instância do Repository"
direction TB
Atributo["this.tenant = 'cliente_a'"]
Metodo["getConnection() -> Pede ao ConnectionManager o banco 'onroad_cliente_a'"]
end
end
endDI Container & Decorators
TypeRoad uses a decorator-based DI container with automatic class scanning. It manages the lifecycle of your components through defined scopes.
Decorators
| Decorator | Scope | Relationship | Purpose |
|---|---|---|---|
@Controller("/path") |
REQUEST |
entry-point | Injetado com Services; lida com req/res Express. |
@Service() |
REQUEST |
logic | Camada de orquestração e regras de negócio. |
@Repository() |
REQUEST |
data | Camada de acesso ao banco (Sequelize/Mongoose). |
@Injectable() |
TRANSIENT |
utility | Classes utilitárias instanciadas a cada uso. |
Scopes
SINGLETON: Uma única instância para toda a aplicação (ex:EventBus,ConnectionManager).REQUEST: Uma instância por requisição HTTP. É o escopo padrão para Controllers e Services, garantindo que o estado do tenant esteja isolado.TRANSIENT: Uma nova instância é criada toda vez que a classe é injetada ou resolvida.
Controller, Service and Repository linkage
The container manages a strict hierarchy: Controller -> Service -> Repository.
- Automatic Wiring: When you register a module via
app.register([MyController, MyService, MyRepository]), the container scans the metadata and prepares the injection tree. - Stateful Injection: A Repository inheriting from
SequelizeRepositoryis automatically configured by the container with the currenttenantandconnectionManagerduring its instantiation in theRequestScope.
import { Service, AbstractService } from "@onroad/core"
@Service()
class OrdemService extends AbstractService<OrdemRepository> {
constructor() {
// Shared state: the container ensures OrdemRepository
// is instantiated within the same RequestScope as this service.
super({ repository: OrdemRepository })
}
}Controllers, Services & Repositories
AbstractController
import { Controller, AbstractController, type RouteConfig } from "@onroad/core"
@Controller("/ordem")
class OrdemController extends AbstractController<OrdemService> {
constructor() {
super({
service: OrdemService,
routes: {
findAll: { method: "get" },
create: { method: "post" },
update: { method: "put", path: "/:id" },
remove: { method: "delete", path: "/:id" },
},
})
}
async findAll(req: Request, res: Response) {
const data = await this.service.findAll(req.query)
res.json({ content: data })
}
async create(req: Request, res: Response) {
const sentinel = new Sentinel(req, res)
const result = await this.service.create(req.body, sentinel)
await sentinel.finishRequest(result)
}
}AbstractService
import { Service, AbstractService } from "@onroad/core"
@Service()
class OrdemService extends AbstractService<OrdemRepository> {
constructor() {
super({ repository: OrdemRepository })
}
async findAll(query: unknown) {
return this.repository.findAll(query)
}
}SequelizeRepository / MongooseRepository
import { Repository, SequelizeRepository } from "@onroad/core"
@Repository()
class OrdemRepository extends SequelizeRepository {
constructor() {
super({ modelName: "Ordem" })
}
}Entity System
Decorate model classes with metadata for auto-registration:
import { Entity, Column, DataType, HasMany } from "@onroad/core/entity"
@Entity({ tableName: "ordens", timestamps: true })
class Ordem {
@Column({ type: DataType.UUID, primaryKey: true })
id!: string
@Column({ type: DataType.STRING, allowNull: false })
descricao!: string
@Column({ type: DataType.ENUM("aberta", "fechada") })
status!: string
@HasMany(() => Tarefa, { foreignKey: "ordemId" })
tarefas!: Tarefa[]
}The EntityRegistry collects all decorated entities for model initialization.
Filter Chain
Filters are ordered middleware executed before route handlers. TypeRoad includes 5 built-in filters:
| Filter | Order | Purpose |
|---|---|---|
CorsFilter |
10 | CORS headers |
JwtFilter |
20 | JWT token validation |
TenantFilter |
30 | Multi-tenant resolution |
RoleFilter |
40 | Role-based access |
RequestContextFilter |
50 | Attach logger/context to request |
Registration (3 forms)
// 1. Bare instance
app.useFilters([new CorsFilter({ origins: ["*"] })])
// 2. Class reference (uses @Filter decorator config)
app.useFilters([CorsFilter])
// 3. Object form (manual config overrides decorator)
app.useFilters([{
filter: new JwtFilter({ secret: "..." }),
order: 15,
exclude: ["/health", "/public"],
}])Custom Filter
import { OnRoadFilter, Filter, FilterChain } from "@onroad/core/filters"
@Filter({ order: 25 })
class AuditFilter extends OnRoadFilter {
async execute(req: Request, res: Response, chain: FilterChain) {
console.log(`[${req.method}] ${req.path}`)
await chain.next(req, res) // pass to next filter
}
}
// Auto-discovered when passed to app.register()
app.register([AuditFilter])Filters support exclude (skip specific paths) and condition (dynamic enable/disable).
Security (Roles & Public)
@Roles
Restrict access at class or method level:
import { Roles, Public } from "@onroad/core/security"
@Roles("admin", "manager")
@Controller("/config")
class ConfigController extends AbstractController<ConfigService> {
// Inherits class-level roles: admin, manager
@Roles("admin") // Method-level overrides class-level
async deleteAll(req: Request, res: Response) { /* ... */ }
@Public() // No role check — open access
async getVersion(req: Request, res: Response) { /* ... */ }
}- Method-level
@Rolesoverrides class-level (not additive). @Public()sets roles to[](empty array = no restriction).getRoles(instance, methodName)returnsundefinedif no decorator is present (no enforcement).
OnRoadExpress enforces roles automatically in buildServer() by reading req.decoded.roles or req.user.roles.
Sentinel & EventBus
The Sentinel manages the request lifecycle — transactions, errors, and events.
async create(req: Request, res: Response) {
const sentinel = new Sentinel(req, res)
// Start database transaction
sentinel.transaction = await sequelize.transaction()
try {
const result = await this.service.create(req.body, sentinel)
// Queue side-effects
sentinel.appendMo({ type: "ordemCriada", data: result })
sentinel.appendNotification({ userId: "abc", title: "Nova OS", body: "..." })
// Commit + fire afterCommit events + send response
await sentinel.finishRequest(result)
} catch (err) {
sentinel.appendError(err as Error)
await sentinel.finishRequest(null) // triggers rollback + 500
}
}EventBus Handlers
sentinel.eventBus.on("afterCommit", async (payload) => {
// Publish matching objects to RTDB
// Send notifications
// Propagate to other APIs
})
sentinel.eventBus.on("matchingObject", (mo) => {
// Process individual matching object
})Providers
TypeRoad defines 4 abstract provider interfaces. Each has an isReady() method and lifecycle hooks.
| Provider | Purpose | Example Implementation |
|---|---|---|
MessagingProvider |
Inter-API async messaging | RabbitMQ (CloudAMQP) |
RealtimeProvider |
Push updates to frontends | Firebase RTDB |
TaskSchedulerProvider |
Delayed/scheduled tasks | Google Cloud Tasks |
SocketProvider |
WebSocket communication | Socket.io |
Registration
const app = new OnRoadExpress()
app.setMessagingProvider(new RabbitMQMessagingProvider(process.env.AMQP_URL!))
app.setRealtimeProvider(new FirebaseRealtimeProvider(firebaseApp))
app.setTaskSchedulerProvider(new CloudTasksProvider(config))
app.setSocketProvider(new SocketIOProvider(httpServer))All providers fail gracefully during buildServer() — if one fails to initialize, the app continues without it.
Implementing a Provider
import { MessagingProvider, type MessagingMessage, type MessageWorker } from "@onroad/core"
class RabbitMQMessagingProvider extends MessagingProvider {
isReady(): boolean { return this.connected }
async initialize(config: Record<string, unknown>): Promise<void> {
// Connect to RabbitMQ...
}
async publish(message: MessagingMessage): Promise<void> {
// Publish to exchange...
}
async subscribe(route: string, worker: MessageWorker): Promise<void> {
// Bind queue and consume...
}
async shutdown(): Promise<void> {
// Close connection...
}
}Inter-Service Transport
Transport classes enable API-to-API communication with zero boilerplate.
HttpTransport
// Register API endpoints
app.registerApiClients({
Manutencao: "http://api-manutencao:3001",
Processo: "http://api-processo:3002",
})
// Inside a service — just call it
const transport = app.transportFactory.createHttp("Manutencao")
const result = await transport.send("/ordem/sync", { data: payload })MessagingTransport
const transport = app.transportFactory.createMessaging()
await transport.send("ordem.created", { ordemId: "123" })No Sentinel or tenant needs to be passed — the transport reads them automatically from the active RequestContext via AsyncLocalStorage.
Request Context (AsyncLocalStorage)
Every route handler is wrapped in requestContext.run() by OnRoadExpress, providing request-scoped data without parameter threading:
import { getRequestContext } from "@onroad/core"
// Anywhere in the call stack during a request:
const ctx = getRequestContext()
console.log(ctx.tenant) // current tenant
console.log(ctx.appToken) // x-app-token header
console.log(ctx.logger) // request-scoped logger (if configured)This is what enables InterServiceTransport, HttpTransport, and MessagingTransport to automatically include tenant and x-app-token headers without receiving them as constructor arguments.
If called outside a request handler, getRequestContext() throws a descriptive error.
Logging
TypeRoad uses pino via the PinoLogger class implementing the OnRoadLogger interface.
import { PinoLogger } from "@onroad/core/logging"
const app = new OnRoadExpress({
logger: new PinoLogger({ level: "debug" }),
})
// Anywhere:
app.logger.info("Server started", { port: 3001 })
app.logger.warn("Slow query", { duration: 2500 })
app.logger.error("Connection failed", { host: "db" })OnRoadLogger Interface
interface OnRoadLogger {
info(message: string, meta?: Record<string, unknown>): void
warn(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
debug(message: string, meta?: Record<string, unknown>): void
child(bindings: Record<string, unknown>): OnRoadLogger
}Swap PinoLogger for any custom implementation of OnRoadLogger.
Plugins
Plugins extend OnRoadExpress capabilities. A plugin receives the full app reference during installation.
import type { OnRoadPlugin } from "@onroad/core"
class MetricsPlugin implements OnRoadPlugin {
name = "metrics"
async install(app: { app: Express; eventBus: RequestEventBus }): Promise<void> {
app.eventBus.on("afterCommit", (payload) => {
// Track metrics...
})
}
}
await app.use(new MetricsPlugin())@onroad/plugin-notification
The notification plugin is a separate package: @onroad/plugin-notification.
import { NotificationPlugin } from "@onroad/plugin-notification"
await app.use(new NotificationPlugin({
apiUrl: process.env.NOTIFICATION_API_URL!,
timeout: 5000,
}))It listens to afterCommit events and sends notification payloads to the notification API using native fetch with Promise.allSettled for resilience.
Storage
Abstract StorageProvider for file/attachment operations (e.g., GCS, S3):
import { StorageProvider } from "@onroad/core/storage"
class GCSStorageProvider extends StorageProvider {
async upload(buffer: Buffer, opts: UploadOptions): Promise<string> { /* ... */ }
async getSignedUrl(key: string): Promise<string> { /* ... */ }
async delete(key: string): Promise<void> { /* ... */ }
}Health Endpoint
GET /health is automatically registered by buildServer():
{
"status": "ok",
"timestamp": "2025-01-15T10:30:00.000Z",
"providers": {
"messaging": true,
"realtime": true,
"taskScheduler": null,
"socket": null
}
}true= provider registered and readyfalse= provider registered but not readynull= provider not registered
Graceful Shutdown
await app.shutdown()shutdown() disconnects all providers sequentially (messaging, realtime, taskScheduler, socket), closes the HTTP server, and logs each step. Each provider failure is isolated — one failing provider does not block the others.
Testing
TypeRoad uses Vitest with 242 tests across 10 test files:
npm test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage report| Test File | Tests | Covers |
|---|---|---|
container.test.ts |
19 | DI Container, decorators, scopes |
entity.test.ts |
14 | Entity, Column, associations |
database.test.ts |
13 | ConnectionManagers |
filters.test.ts |
33 | FilterChain, all 5 built-in filters |
security.test.ts |
25 | @Roles, @Public, enforcement |
sentinel.test.ts |
36 | Sentinel, EventBus, transactions |
logging.test.ts |
20 | PinoLogger, OnRoadLogger |
providers.test.ts |
41 | All 4 providers, graceful-fail, shutdown |
transport.test.ts |
25 | HttpTransport, MessagingTransport, TransportFactory, RequestContext |
dev.test.ts |
16 | DevServer, MigrationCLI |
Project Structure
src/
├── index.ts # Public API exports
├── OnRoadExpress.ts # Main orchestrator
├── container/ # DI Container + decorators
├── context/ # RequestContext (AsyncLocalStorage)
├── core/ # AbstractController/Service/Repository, Sentinel, EventBus
├── database/ # ConnectionManager abstract + Sequelize/Mongo implementations
│ └── migrations/ # MigrationRunner (Umzug)
├── dev/ # DevServer, MigrationCLI — local dev tools
├── entity/ # @Entity, @Column, @Field, associations
├── filters/ # FilterChain, @Filter, built-in filters (Cors, JWT, Tenant, Role, RequestContext)
├── logging/ # OnRoadLogger interface + PinoLogger
├── messaging/ # MatchingObject
├── plugins/ # OnRoadPlugin interface
├── providers/ # MessagingProvider, RealtimeProvider, TaskSchedulerProvider, SocketProvider
├── security/ # @Roles, @Public
├── storage/ # StorageProvider abstract
├── transport/ # InterServiceTransport, HttpTransport, MessagingTransport, TransportFactory
└── types/ # Express Request augmentationLocal Development (DevServer)
TypeRoad includes a DevServer that makes local development self-sufficient — it starts database containers, runs migrations, and logs all environment variables your frontend needs.
Quick Start
import "reflect-metadata"
import { OnRoadExpress } from "@onroad/core"
import { SequelizeConnectionManager } from "@onroad/core/database"
import { DevServer } from "@onroad/core/dev"
const app = new OnRoadExpress({
connections: [
new SequelizeConnectionManager({
dialect: "postgres",
host: "localhost",
port: 5432,
user: "typeroad",
password: "typeroad_dev",
database: "typeroad_dev",
migrations: {
path: "./src/migrations",
runOnConnect: true,
},
}),
],
})
// Register your controllers, services, repositories...
app.register([/* ... */])
const devServer = new DevServer({
app,
databases: [{ engine: "postgres", port: 5432 }],
migrationsPath: "./src/migrations",
tenants: ["default"],
frontendVars: {
REACT_APP_TENANT: "default",
REACT_APP_WS_URL: "ws://localhost:3000",
},
})
await devServer.start({ port: 3000 })
// Press Ctrl+C to stopWhat DevServer Does
- Generates
docker-compose.dev.yml— PostgreSQL (or MySQL) container with healthcheck, volumes, and sensible defaults - Starts the container —
docker compose up -dautomatically - Waits for readiness — polls
pg_isready/mysqladmin pinguntil the DB accepts connections - Runs migrations — applies all pending Umzug migrations from your configured path
- Starts the API server — calls
app.buildServer()as usual - Logs frontend env vars — prints a copy-pasteable block and writes
.env.dev:
┌──────────────────────────────────────────────────────────────┐
│ FRONTEND ENVIRONMENT VARIABLES │
├──────────────────────────────────────────────────────────────┤
│ REACT_APP_API_URL=http://localhost:3000
│ NEXT_PUBLIC_API_URL=http://localhost:3000
│ VITE_API_URL=http://localhost:3000
│ DATABASE_URL=postgresql://typeroad:typeroad_dev@localhost:5432/typeroad_dev
│ REACT_APP_TENANT=default
└──────────────────────────────────────────────────────────────┘DevServer Config
| Option | Type | Default | Description |
|---|---|---|---|
app |
OnRoadExpress |
required | The app instance |
databases |
DevDatabaseConfig[] |
[{ engine: "postgres" }] |
Database containers to start |
frontendVars |
Record<string, string> |
{} |
Extra vars to log for frontend |
migrationsPath |
string |
— | Path to migration files |
autoMigrate |
boolean |
true |
Run migrations on start |
tenants |
string[] |
["default"] |
Tenants to run migrations for |
composeFile |
string |
"docker-compose.dev.yml" |
Docker compose filename |
Database Defaults
| Engine | Image | Port | User | Password | Database |
|---|---|---|---|---|---|
postgres |
postgres:16-alpine |
5432 |
typeroad |
typeroad_dev |
typeroad_dev |
mysql |
mysql:8.0 |
3306 |
typeroad |
typeroad_dev |
typeroad_dev |
Migration CLI
The MigrationCLI class provides a programmatic API for managing database migrations.
Usage
import { MigrationCLI } from "@onroad/core/dev"
import { SequelizeConnectionManager } from "@onroad/core/database"
const connection = new SequelizeConnectionManager({
dialect: "postgres",
host: "localhost",
user: "typeroad",
password: "typeroad_dev",
database: "typeroad_dev",
migrations: { path: "./src/migrations" },
})
const cli = new MigrationCLI({
migrationsPath: "./src/migrations",
connection,
tenant: "default",
})
// Run from CLI args
await cli.run() // reads process.argv
// Or call directly
cli.create("add-users-table") // creates timestamped migration file
await cli.up() // apply all pending
await cli.up(1) // apply 1 step
await cli.down() // revert last migration
await cli.status() // show status tableCLI Commands
| Command | Description | Example |
|---|---|---|
create <name> |
Create a new migration file | cli.run(["create", "add-orders"]) |
up [steps] |
Apply pending migrations | cli.run(["up"]) or cli.run(["up", "3"]) |
down [steps] |
Revert migrations (default: 1) | cli.run(["down", "2"]) |
status |
Show applied/pending status | cli.run(["status"]) |
Migration File Format
Generated .ts files follow the Umzug/Sequelize pattern:
import type { QueryInterface, Sequelize } from "sequelize"
export default {
async up(queryInterface: QueryInterface, sequelize: Sequelize): Promise<void> {
await queryInterface.createTable("ordens", {
id: { type: sequelize.constructor["DataTypes"].UUID, primaryKey: true },
descricao: { type: sequelize.constructor["DataTypes"].STRING, allowNull: false },
createdAt: sequelize.constructor["DataTypes"].DATE,
updatedAt: sequelize.constructor["DataTypes"].DATE,
})
},
async down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable("ordens")
},
}Migration state is tracked in the __typeroad_migrations table (configurable via tableName in MigrationConfig).
Subpath Exports
Import only what you need:
import { OnRoadExpress } from "@onroad/core" // Main
import { AbstractController, Sentinel } from "@onroad/core/core" // Core classes
import { Entity, Column, DataType } from "@onroad/core/entity" // Entity decorators
import { FilterChain, CorsFilter } from "@onroad/core/filters" // Filters
import { Roles, Public } from "@onroad/core/security" // Security
import { SequelizeConnectionManager } from "@onroad/core/database" // Database
import { HttpTransport, TransportFactory } from "@onroad/core/transport" // Transport
import { MatchingObject } from "@onroad/core/messaging" // Messaging
import { PinoLogger } from "@onroad/core/logging" // Logging
import { StorageProvider } from "@onroad/core/storage" // Storage
import { DevServer, MigrationCLI } from "@onroad/core/dev" // Dev ToolsKnown Pitfalls
Controller base path must not be empty
Always pass / or a non-empty path to @Controller(). An empty string is falsy and previously caused the route guard to silently drop all routes of that controller.
// ❌ Wrong — all routes silently skipped
@Controller("")
class UserController extends AbstractController<UserService> { ... }
// ✅ Correct — root-level routes
@Controller("/")
class UserController extends AbstractController<UserService> { ... }
// ✅ Correct — prefixed routes
@Controller("/users")
class UserController extends AbstractController<UserService> { ... }Since v4.0.0-alpha.1 the path guard was tightened (
basePath === undefined || basePath === null) and paths are normalized (//auth→/auth), so@Controller("/")+ route"/auth"resolves correctly to/authinstead of//auth.
Circular service dependencies
When two services reference each other (e.g. UserService ↔ CompanyService), eager instantiation inside configDependencies() causes a RangeError: Maximum call stack size exceeded at startup because the constructors recurse infinitely.
// ❌ Wrong — infinite recursion on startup
@Service()
class UserService extends AbstractService<UserRepository> {
companySVC!: CompanyService
configDependencies() {
this.companySVC = new CompanyService() // → CompanyService() → new UserService() → ...
}
}Fix: replace the eager field with a lazy getter. The getter body only runs on first access, never during construction, which breaks the cycle:
// ✅ Correct — lazy getter breaks the circular chain
@Service()
class UserService extends AbstractService<UserRepository> {
private _companySVC?: CompanyService
protected get companySVC(): CompanyService {
if (!this._companySVC) this._companySVC = new CompanyService()
return this._companySVC
}
}
@Service()
class CompanyService extends AbstractService<CompanyRepository> {
private _userSVC?: UserService
protected get userSVC(): UserService {
if (!this._userSVC) this._userSVC = new UserService()
return this._userSVC
}
}Why it was masked before: the
if (!basePath)bug meant controllers (and therefore services) were never instantiated duringbuildServer(). The circular dep crash only surfaced after fixing the route guard.
Overriding fullAssociation in Repositories
When using @onroad/core, the @Repository decorator automatically builds relations and injects the fullAssociation property populated with the appropriate Sequelize Models. Manually overriding this property in your repository class using raw ES classes will cause crashes during runtime (e.g., TypeError: include.model.getTableName is not a function) because Sequelize expects internal model objects, not raw classes.
// ❌ Wrong — overriding fullAssociation with raw ES classes crashes Sequelize
@Repository({ entity: User })
export class UserRepository extends BaseRepository<User> {
readonly fullAssociation = [{ model: FilterPref, as: "filters" }]
}Fix: Remove the hardcoded override. If you are extending a custom BaseRepository and need TypeScript to recognize the property without emitting an overriding value in the compiled JavaScript, use the declare modifier:
// ✅ Correct — Let the decorator inject the value and use `declare` for typings
export abstract class BaseRepository<TEntity = unknown> extends SequelizeRepository<TEntity> {
declare readonly fullAssociation: any[] // Tells TS it exists without overriding it
}
@Repository({ entity: User })
export class UserRepository extends BaseRepository<User> {
// No fullAssociation override here!
}import type with relationship decorators
TypeScript's import type is erased at compile time. If you use import type { Foo } and then reference Foo as a runtime value inside a decorator like @HasMany(() => Foo, ...), the compiled JavaScript will throw ReferenceError: Foo is not defined because the import was stripped.
// ❌ Wrong — import type is erased, decorator callback fails at runtime
import type { CampoDeVerificacao } from "./CampoDeVerificacao.js"
@Entity("caderno", { engine: "sequelize" })
export class CadernoDeVerificacao {
@HasMany(() => CampoDeVerificacao, "cadernoId") // 💥 ReferenceError
campos!: CampoDeVerificacao[]
}Fix: use a regular import for any class referenced inside a decorator callback:
// ✅ Correct — regular import keeps the binding at runtime
import { CampoDeVerificacao } from "./CampoDeVerificacao.js"
@Entity("caderno", { engine: "sequelize" })
export class CadernoDeVerificacao {
@HasMany(() => CampoDeVerificacao, "cadernoId")
campos!: CampoDeVerificacao[]
}Tip:
import typeis safe for type annotations and generics (extends AbstractService<Foo>), but never for decorator arguments,instanceof, or any expression evaluated at runtime.
emitDecoratorMetadata causes TDZ errors with circular entity imports
When emitDecoratorMetadata: true is set in tsconfig.json, TypeScript emits __metadata("design:type", CampoDeVerificacao) for decorated properties. This accesses the imported class at class definition time. If two entities import each other (circular dependency), ESM module evaluation order causes a Temporal Dead Zone (TDZ) error:
ReferenceError: Cannot access 'CampoDeVerificacao' before initializationThe @HasMany(() => Foo, ...) arrow function is lazy (only called later by wireSequelizeAssociations), but __metadata("design:type", Foo) is eager — it runs during class definition.
// ❌ Wrong — causes TDZ crash in circular ESM imports
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true // 💥
}
}Fix: disable emitDecoratorMetadata. @onroad/core uses only custom metadata keys (METADATA_KEY.ENTITY, ENTITY_COLUMNS, etc.) and does not rely on design:type, design:paramtypes, or design:returntype.
// ✅ Correct — no TDZ, decorators still work
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false
}
}Custom route service methods must extract params from req — not expect primitive arguments
AbstractController always calls custom service methods with the raw (req, res) objects. It only extracts params for the five default CRUD methods (create, read, readAll, update, delete). Every method registered under routes: in the controller config receives (req, res) forwarded directly.
If your service method signature expects primitive parameters (id: number, dataInicio: string, etc.), it will receive the req object instead — causing silent failures (empty queries, NaN ids, wrong SQL filters) because the framework does not throw, it just passes the wrong value.
// ❌ Wrong — service expects primitives; controller passes (req, res)
// Controller calls: this.service.findByBetweenDates(req, res)
async findByBetweenDates(dataInicio: string, dataFim: string) {
// dataInicio === req object → Sequelize WHERE clause receives an object → returns []
return this.repository.findAll({ where: { dataDeAbertura: { [Op.gte]: dataInicio } } })
}// ✅ Correct — service extracts its own params from req
async findByBetweenDates(req: any, res?: any) {
const dataInicio: string = req.params.dataInicio
const dataFim: string = req.params.dataFim
return this.repository.findAll({ where: { dataDeAbertura: { [Op.gte]: dataInicio, [Op.lte]: dataFim } } })
}Affected params by HTTP position:
| Source | How to extract |
|---|---|
Route param (:id) |
req.params.id |
| Query string | req.query.field |
| Request body | req.body.field |
| Auth / identity | req.decoded.userId |
Rule of thumb: In a TypeRoad service, if the method is registered as a custom route, the first argument is always
req. Never writeasync myMethod(id: number)for a method that is called through a route config key — always writeasync myMethod(req: any, res?: any)and extract fromreqinternally.
Scope of impact in teraprox-api-manutencao (audit — 2025-04-05):
The following service methods were found with the wrong signature (primitive params instead of req). All calls through their controller routes were silently broken:
| Service | Method | Broken params |
|---|---|---|
OrdemDeServicoService |
findByBetweenDates |
dataInicio, dataFim — fixed |
OrdemDeServicoService |
findByRecursoBetweenDates |
recursoId, dataInicio, dataFim |
OrdemDeServicoService |
findByBranchIdBetweenDates |
branchId, dataInicio, dataFim |
OrdemDeServicoService |
findByRecursoId |
recursoId |
OrdemDeServicoService |
findMonitoramentoByCriticidade |
criticidade |
OrdemDeServicoService |
findByModoDeFalhaId |
modoDeFalhaId |
OrdemDeServicoService |
encerraOS |
id, form |
OrdemDeServicoService |
setStatusOs |
id, form |
OrdemDeServicoService |
setStatusOsBulk |
osIds, status, extraData |
OrdemDeServicoService |
iniciarOsBulk |
osIds, status |
OrdemDeServicoService |
addTarefaToOrdem |
osId, tarefa |
OrdemDeServicoService |
updateDescricaoOs |
osId, descricao |
OrdemDeServicoService |
shiftRecursoOs |
osId, recId |
OrdemDeServicoService |
findInspecoesBetweenDates |
recursoId, dataInicio, dataFim |
OrdemDeServicoService |
putAnexos |
id, anexos |
OrdemDeServicoService |
findOrdensInBatch |
ids |
OrdemDeServicoService |
loadOsForOcpMigration |
osIds |
RecursoService |
findRecursoFatherByRecursoId |
recursoId |
RecursoService |
findByTagDescription |
tag |
RecursoService |
findRecursoByTagId |
tagId |
RecursoService |
findParadasByRecursoId |
recursoId |
RecursoService |
findSemParadasByRecursoId |
recursoId |
TarefaService |
encerrarTarefa |
id |
TarefaService |
addUnidadeMaterialTarefa |
tarefaId, form |
TarefaService |
addObservacaoTarefa |
tarefaId, form |
TarefaService |
deleteObservacaoTarefa |
justificativaId |
TarefaService |
loadMultipleIds |
tarefaIds |
TarefaService |
removeAnexo |
id, anexoKey |
SolicitacaoDeServicoService |
shiftRecursoSs |
ssId, recId |
SolicitacaoDeServicoService |
aprovaSolicitacao |
id, form |
SolicitacaoDeServicoService |
reprovaSolicitacao |
id, form |
SolicitacaoDeServicoService |
updateDescricaoSs |
ssId, descricao |
SolicitacaoDeServicoService |
findByRecursoId |
recursoId |
SolicitacaoDeServicoService |
findBetweenDates |
start, end |
SolicitacaoDeServicoService |
putAnexos |
id, anexos |
Custom route key name must exactly match the service method name
When you register a custom route in the controller config, the key name is used verbatim as the service method name to call. If the service was later renamed (e.g. during a refactor), the controller silently returns null because the typeof service[methodName] === 'function' guard evaluates to false.
// ❌ Wrong — controller key "addModoDeFalhaOS" but service method is "addModoDeFalha"
routes: { addModoDeFalhaOS: { method: "post", path: "/..." } }
// → service.addModoDeFalhaOS is undefined → always returns null (no error thrown)Known mismatches in teraprox-api-manutencao (audit — 2025-04-05):
| Controller route key | Expected service method (by key) | Actual service method |
|---|---|---|
addModoDeFalhaOS |
addModoDeFalhaOS |
addModoDeFalha |
updateTipoDeOrdem |
updateTipoDeOrdem |
setTipoOrdem |
updateTipoDeOrdemBulk |
updateTipoDeOrdemBulk |
setTipoOrdemBulk |
removeAnexo |
removeAnexo |
removeAnexoOs |
readConcluidasCount |
readConcluidasCount |
countTarefasConcluidas |
findPlanned |
findPlanned |
findPlannedOrdensBetweenDatesV2 |
findPlannedV3 |
findPlannedV3 |
findPlannedOrdensBetweenDatesV3 |
findPlannedAgregador |
findPlannedAgregador |
findPlannedOrdensWithAgregadores |
findRecorrenciaPlanned |
findRecorrenciaPlanned |
findPlannedOrdensFromRecorrenciaBetweenDates |
findAgregadorPlanned |
findAgregadorPlanned |
findPlannedOrdensFromAgregadorBetweenDates |
createInBulk |
createInBulk |
createInBulkOs |
findDashByOs |
findDashByOs |
findWithDashboardAssociation |
Fix: either rename the service method to match the route key, or rename the route key to match the service method. Both sides must be in sync.
BelongsToMany association returns null (not []) when no records exist
Sequelize's BelongsToMany association populates the association property with null (not an empty array) when no join-table records exist for a given entity. Any code that iterates over that property with for...of or .forEach() without a guard will throw TypeError: x is not iterable.
// ❌ Wrong — crashes with "TypeError: o.tarefas is not iterable" when OS has no tarefas
for (const tarefa of o.tarefas) { ... }
o.tarefas.forEach(...)
o.tarefas.flatMap(...)// ✅ Correct — guard with nullish coalescing
for (const tarefa of (o.tarefas ?? [])) { ... }
;(o.tarefas ?? []).forEach(...)
ordens.flatMap((o) => (o.tarefas ?? []).map(...))This was the direct cause of
TypeError: o.tarefas is not iterableatOrdemDeServicoService.buildTarefa(line 235) andfindByBetweenDates. Apply the same?? []guard to allBelongsToManyassociations before iterating.
Migration Guide — Lessons Learned (v3 → v4)
This section documents the key issues found while migrating teraprox-api-user from onRoad v3 to TypeRoad v4. Use this as a checklist when migrating other APIs (api-manutencao, api-processo, etc.).
1. Entity Associations — Use Decorators, Not buildAssociations()
v3 used buildAssociations() inside repositories. v4 uses decorators on entity classes. Every association must be declared on the entity itself.
// ❌ v3 — Repository-based associations (REMOVE)
class UserRepository extends BaseRepository {
buildAssociations() {
this.hasMany(UserRole, "userRole")
this.belongsTo(Company, "company")
}
}
// ✅ v4 — Entity decorator associations
@Entity("user", { engine: "sequelize", timestamps: true })
class User {
@HasMany(() => UserRole, "userId")
userRole!: UserRole[]
@BelongsToMany(() => Company, () => UserCompany, "userId", "companyId")
companies!: Company[]
}Checklist per entity:
- Join table entities (e.g.
UserRole,UserSetor,UserCompany) must declare their FK columns with@ColumnAND the@BelongsToback-reference.
@Entity("userRole", { engine: "sequelize", timestamps: true })
class UserRole {
@Column({ type: DataType.STRING })
userId!: string
@Column({ type: DataType.STRING })
roleId!: string
@BelongsTo(() => Role, "roleId")
role!: Role
}2. Includes — Use String-Based Associations, Not Model References
v3 used this.repository.fullAssociation (injected array of { model: SequelizeModel }).
v4 uses string-based association names that match the decorator property name.
// ❌ v3 — model-based includes (CRASH: model is a raw class, not Sequelize model)
const user = await this.repository.findOne({
where: { email },
include: this.repository.fullAssociation
})
// ✅ v4 — string-based association includes
const USER_FULL_INCLUDE = [
{ association: "filters" },
{ association: "tickets" },
{ association: "userRole" },
{ association: "userSetor" }
]
const user = await this.repository.findOne({
where: { email },
include: USER_FULL_INCLUDE
})Nested includes follow the same pattern:
include: [
{
association: "userRole",
include: [{ association: "role", where: { companyId }, required: false }]
},
{
association: "userSetor",
include: [{ association: "setor", where: { companyId }, required: false }]
}
]3. Raw Queries — Use getSequelizeConnection()
v3 accessed (this.repository as any).connection.query(...).
v4 exposes this through BaseRepository.getSequelizeConnection().
// ❌ v3
const [results] = await (this.repository as any).connection.query(sql)
// ✅ v4 — add this method to your BaseRepository
getSequelizeConnection() { return this.getConnection() }
// Usage in service:
const [results] = await this.repository.getSequelizeConnection().query(sql)4. Remove fullAssociation from BaseRepository
v3 APIs declare fullAssociation on BaseRepository. Remove it entirely — v4 does not use it. Includes are now inline at the query site.
// ❌ v3
export abstract class BaseRepository<T> extends SequelizeRepository<T> {
declare readonly fullAssociation: any[]
}
// ✅ v4
export abstract class BaseRepository<T> extends SequelizeRepository<T> {
// No fullAssociation — define includes where you query
}5. Controller Return Values Are Auto-Serialized
v4's OnRoadExpress automatically calls res.json(result) when a controller method returns a value and res.headersSent is false. Do not manually call res.json() in controllers if you return a value — it will cause "headers already sent" errors.
// ✅ Just return the value — framework serializes it
async login(form: any, req: Request, res: Response) {
const userData = await this.service.login(form, req, res)
return userData // Framework calls res.json(userData)
}6. BelongsToMany Includes — Beware of Query Hangs
Using BelongsToMany associations (like User ↔ Company through UserCompany) in Sequelize include can cause queries to hang with large datasets or complex joins. Avoid including BelongsToMany in default/frequent queries. Use it only in specific methods that need it, and consider raw SQL queries for performance-critical paths.
7. Common Migration Search Patterns
When migrating an API, search for these patterns and replace them:
| Search Pattern | Replace With |
|---|---|
this.repository.fullAssociation |
Inline [{ association: "..." }] array |
(this.xxxRepo as any).model |
{ association: "aliasName" } |
(this.xxxRepo as any).xxxRepo |
{ association: "aliasName" } |
(this.repository as any).connection |
this.repository.getSequelizeConnection() |
buildAssociations() |
@HasMany, @BelongsTo, @HasOne, @BelongsToMany on entity |
{ model: SomeClass, as: "..." } |
{ association: "..." } |
8. ARRAY Column Type
For PostgreSQL varchar[] columns, use the custom arrayType option:
@Column({ type: DataType.ARRAY, arrayType: DataType.STRING })
componentesBloqueados!: string[]9. Env & Local Dev Setup
Each API needs a .env at its server root with:
LOCAL_NO_JWT=true— skips JWT validation for local dev- DB connection vars (
DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DBNAME) TOKEN_KEY— any string for local token signingAPI_PORT— local port for the service
Use cloud-sql-proxy for connecting to GCP Cloud SQL locally:
./cloud-sql-proxy <PROJECT>:<REGION>:<INSTANCE> --port=5432License
UNLICENSED — HashCodeTI-Brasil