JSPM

  • Created
  • Published
  • Downloads 4434
  • Score
    100M100P100Q109911F
  • License UNLICENSED

TypeScript backend framework — DI Container, Filter Chain, EventBus, Provider Pattern

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/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

npm install @onroad/core

Peer dependencies (install as needed):

npm install express reflect-metadata pino uuid
# Optional — only if using HttpTransport:
npm install axios

TypeRoad is ESM-only ("type": "module"). Your tsconfig.json must 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

┌──────────────────────────────────────────────────┐
│                  OnRoadExpress                   │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐ │
│  │  Container  │  │ FilterChain│  │  EventBus  │ │
│  │  (DI + IoC) │  │ (Middleware)│  │ (Sentinel) │ │
│  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘ │
│        │               │               │        │
│  ┌─────▼───────────────▼───────────────▼──────┐ │
│  │         Request Handler (route)             │ │
│  │  requestContext.run({ tenant, appToken })   │ │
│  │  ┌──────────┐ ┌─────────┐ ┌────────────┐   │ │
│  │  │Controller│→│ Service │→│ Repository │   │ │
│  │  └──────────┘ └─────────┘ └────────────┘   │ │
│  └─────────────────────────────────────────────┘ │
│                                                  │
│  ┌──────────────────────────────────────────┐   │
│  │              Providers                    │   │
│  │  Messaging │ Realtime │ TaskSched │ Socket│   │
│  └──────────────────────────────────────────┘   │
│                                                  │
│  ┌──────────────────────────────────────────┐   │
│  │           TransportFactory                │   │
│  │     HttpTransport │ MessagingTransport    │   │
│  └──────────────────────────────────────────┘   │
└──────────────────────────────────────────────────┘

DI Container & Decorators

TypeRoad uses a decorator-based DI container with automatic class scanning.

Decorators

Decorator Scope Purpose
@Injectable() Transient (default) Generic injectable class
@Controller("/path") Request Express route handler
@Service() Request Business logic layer
@Repository() Request Data access layer

Scopes

Scope Behavior
SINGLETON One instance for the entire application
REQUEST One instance per request scope
TRANSIENT New instance on every resolve

Usage

import { Injectable, Scope } from "@onroad/core"

@Injectable({ scope: Scope.SINGLETON })
class CacheManager {
  private store = new Map<string, unknown>()
  get(key: string) { return this.store.get(key) }
  set(key: string, value: unknown) { this.store.set(key, value) }
}

Register all classes via app.register([...]) — the container auto-scans metadata.


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 @Roles overrides class-level (not additive).
  • @Public() sets roles to [] (empty array = no restriction).
  • getRoles(instance, methodName) returns undefined if 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 ready
  • false = provider registered but not ready
  • null = 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 augmentation

Local 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 stop

What DevServer Does

  1. Generates docker-compose.dev.yml — PostgreSQL (or MySQL) container with healthcheck, volumes, and sensible defaults
  2. Starts the containerdocker compose up -d automatically
  3. Waits for readiness — polls pg_isready / mysqladmin ping until the DB accepts connections
  4. Runs migrations — applies all pending Umzug migrations from your configured path
  5. Starts the API server — calls app.buildServer() as usual
  6. 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 table

CLI 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 Tools

Known 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 /auth instead 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 during buildServer(). 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!
}


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 @Column AND the @BelongsTo back-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 signing
  • API_PORT — local port for the service

Use cloud-sql-proxy for connecting to GCP Cloud SQL locally:

./cloud-sql-proxy <PROJECT>:<REGION>:<INSTANCE> --port=5432

License

UNLICENSED — HashCodeTI-Brasil