@onroad/core 4.0.0-alpha.3 → 4.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +604 -54
  2. package/dist/OnRoadExpress.d.ts.map +1 -1
  3. package/dist/OnRoadExpress.js +18 -5
  4. package/dist/OnRoadExpress.js.map +1 -1
  5. package/dist/core/AbstractController.d.ts +10 -0
  6. package/dist/core/AbstractController.d.ts.map +1 -1
  7. package/dist/core/AbstractController.js +48 -1
  8. package/dist/core/AbstractController.js.map +1 -1
  9. package/dist/core/AbstractService.d.ts +5 -0
  10. package/dist/core/AbstractService.d.ts.map +1 -1
  11. package/dist/core/AbstractService.js +36 -0
  12. package/dist/core/AbstractService.js.map +1 -1
  13. package/dist/core/SequelizeRepository.d.ts +4 -1
  14. package/dist/core/SequelizeRepository.d.ts.map +1 -1
  15. package/dist/core/SequelizeRepository.js +22 -1
  16. package/dist/core/SequelizeRepository.js.map +1 -1
  17. package/dist/database/ConnectionManager.d.ts +2 -0
  18. package/dist/database/ConnectionManager.d.ts.map +1 -1
  19. package/dist/database/ConnectionManager.js +12 -1
  20. package/dist/database/ConnectionManager.js.map +1 -1
  21. package/dist/dev/DevServer.d.ts +67 -0
  22. package/dist/dev/DevServer.d.ts.map +1 -0
  23. package/dist/dev/DevServer.js +496 -0
  24. package/dist/dev/DevServer.js.map +1 -0
  25. package/dist/dev/DevToolsController.d.ts +45 -0
  26. package/dist/dev/DevToolsController.d.ts.map +1 -0
  27. package/dist/dev/DevToolsController.js +426 -0
  28. package/dist/dev/DevToolsController.js.map +1 -0
  29. package/dist/dev/DevToolsPlugin.d.ts +10 -0
  30. package/dist/dev/DevToolsPlugin.d.ts.map +1 -0
  31. package/dist/dev/DevToolsPlugin.js +23 -0
  32. package/dist/dev/DevToolsPlugin.js.map +1 -0
  33. package/dist/dev/MigrationCLI.d.ts +19 -0
  34. package/dist/dev/MigrationCLI.d.ts.map +1 -0
  35. package/dist/dev/MigrationCLI.js +140 -0
  36. package/dist/dev/MigrationCLI.js.map +1 -0
  37. package/dist/dev/index.d.ts +8 -0
  38. package/dist/dev/index.d.ts.map +1 -0
  39. package/dist/dev/index.js +5 -0
  40. package/dist/dev/index.js.map +1 -0
  41. package/dist/entity/EntityRegistry.d.ts +26 -0
  42. package/dist/entity/EntityRegistry.d.ts.map +1 -1
  43. package/dist/entity/EntityRegistry.js +140 -4
  44. package/dist/entity/EntityRegistry.js.map +1 -1
  45. package/dist/entity/decorators.d.ts +1 -0
  46. package/dist/entity/decorators.d.ts.map +1 -1
  47. package/dist/entity/decorators.js.map +1 -1
  48. package/dist/filters/builtins/JwtFilter.d.ts +6 -3
  49. package/dist/filters/builtins/JwtFilter.d.ts.map +1 -1
  50. package/dist/filters/builtins/JwtFilter.js +29 -4
  51. package/dist/filters/builtins/JwtFilter.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/storage/GCSStorageProvider.d.ts +15 -0
  57. package/dist/storage/GCSStorageProvider.d.ts.map +1 -0
  58. package/dist/storage/GCSStorageProvider.js +58 -0
  59. package/dist/storage/GCSStorageProvider.js.map +1 -0
  60. package/dist/storage/StorageProvider.d.ts +28 -0
  61. package/dist/storage/StorageProvider.d.ts.map +1 -1
  62. package/dist/storage/StorageProvider.js.map +1 -1
  63. package/dist/storage/index.d.ts +2 -1
  64. package/dist/storage/index.d.ts.map +1 -1
  65. package/dist/storage/index.js +1 -0
  66. package/dist/storage/index.js.map +1 -1
  67. package/package.json +6 -1
package/README.md CHANGED
@@ -25,6 +25,8 @@
25
25
  - [Storage](#storage)
26
26
  - [Health Endpoint](#health-endpoint)
27
27
  - [Graceful Shutdown](#graceful-shutdown)
28
+ - [Local Development (DevServer)](#local-development-devserver)
29
+ - [Migration CLI](#migration-cli)
28
30
  - [Testing](#testing)
29
31
  - [Subpath Exports](#subpath-exports)
30
32
  - [Known Pitfalls](#known-pitfalls)
@@ -95,72 +97,88 @@ await app.buildServer({ port: 3001 })
95
97
 
96
98
  ## Architecture Overview
97
99
 
98
- ```
99
- ┌──────────────────────────────────────────────────┐
100
- │ OnRoadExpress │
101
- │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
102
- │ │ Container │ │ FilterChain│ │ EventBus │
103
- │ │ (DI + IoC) │ │ (Middleware)│ │ (Sentinel) │ │
104
- └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
105
- │ │ │ │ │
106
- │ ┌─────▼───────────────▼───────────────▼──────┐
107
- │ │ Request Handler (route)
108
- │ │ requestContext.run({ tenant, appToken }) │ │
109
- │ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
110
- │ │ │Controller│→│ Service │→│ Repository │ │
111
- │ │ └──────────┘ └─────────┘ └────────────┘ │
112
- │ └─────────────────────────────────────────────┘
113
- │ │
114
- ┌──────────────────────────────────────────┐ │
115
- │ │ Providers │ │
116
- │ │ Messaging Realtime TaskSched Socket│ │
117
- │ └──────────────────────────────────────────┘ │
118
- │ │
119
- │ ┌──────────────────────────────────────────┐ │
120
- │ │ TransportFactory │ │
121
- │ │ HttpTransport MessagingTransport │ │
122
- │ └──────────────────────────────────────────┘ │
123
- └──────────────────────────────────────────────────┘
100
+ 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.
101
+
102
+ ### The "Matryoshka" Isolation Pattern
103
+
104
+ Isolation in runtime is achieved through a nested hierarchy that ensures one tenant's request never sees another's data or instances.
105
+
106
+ 1. **Nível 1: RequestContext (AsyncLocalStorage)**
107
+ - O `OnRoadExpress` envolve cada execução de rota em um bloco `requestContext.run()`.
108
+ - Atua como um "envelope" de memória global para a thread lógica atual, armazenando metadados como `tenantId`, `logger` e `appToken`.
109
+ - Permite que qualquer parte do sistema acesse o estado da requisição via `getRequestContext()` sem precisar de injeção manual.
110
+
111
+ 2. **Nível 2: OnRoadContainer (RequestScope)**
112
+ - Dentro do contexto da requisição, o Container cria um objeto `RequestScope`.
113
+ - Este escopo é um mapa de instâncias isolado por um `UUID` único para aquela chamada HTTP.
114
+ - 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.
115
+
116
+ 3. **Nível 3: Repository & Connection Isolation**
117
+ - Repositórios são instanciados pelo Container e recebem automaticamente o `tenantId` do escopo atual.
118
+ - Ao executar uma query (ex: `find()`), o repositório usa seu atributo interno `this.tenant` para solicitar ao `ConnectionManager` o pool de conexões específico daquele banco de dados.
119
+ - Isso garante que o isolamento chegue até o nível físico do banco de dados (schema ou DB separado).
120
+
121
+ ```mermaid
122
+ graph TD
123
+ subgraph "Nível 1: RequestContext (AsyncLocalStorage)"
124
+ direction TB
125
+ Metadata["{ tenant: 'cliente_a', logger, token }"]
126
+
127
+ subgraph "Nível 2: OnRoadContainer (RequestScope)"
128
+ direction TB
129
+ ScopeID["Scope: UUID-123 (Tenant: 'cliente_a')"]
130
+ Instances["Map { Controller, Service, Repository }"]
131
+
132
+ subgraph "Nível 3: Instância do Repository"
133
+ direction TB
134
+ Atributo["this.tenant = 'cliente_a'"]
135
+ Metodo["getConnection() -> Pede ao ConnectionManager o banco 'onroad_cliente_a'"]
136
+ end
137
+ end
138
+ end
124
139
  ```
125
140
 
126
141
  ---
127
142
 
128
143
  ## DI Container & Decorators
129
144
 
130
- TypeRoad uses a decorator-based DI container with automatic class scanning.
145
+ TypeRoad uses a decorator-based DI container with automatic class scanning. It manages the lifecycle of your components through defined scopes.
131
146
 
132
147
  ### Decorators
133
148
 
134
- | Decorator | Scope | Purpose |
135
- |-----------|-------|---------|
136
- | `@Injectable()` | Transient (default) | Generic injectable class |
137
- | `@Controller("/path")` | Request | Express route handler |
138
- | `@Service()` | Request | Business logic layer |
139
- | `@Repository()` | Request | Data access layer |
149
+ | Decorator | Scope | Relationship | Purpose |
150
+ |-----------|-------|:---:|---------|
151
+ | `@Controller("/path")` | `REQUEST` | entry-point | Injetado com Services; lida com req/res Express. |
152
+ | `@Service()` | `REQUEST` | logic | Camada de orquestração e regras de negócio. |
153
+ | `@Repository()` | `REQUEST` | data | Camada de acesso ao banco (Sequelize/Mongoose). |
154
+ | `@Injectable()` | `TRANSIENT` | utility | Classes utilitárias instanciadas a cada uso. |
140
155
 
141
156
  ### Scopes
142
157
 
143
- | Scope | Behavior |
144
- |-------|----------|
145
- | `SINGLETON` | One instance for the entire application |
146
- | `REQUEST` | One instance per request scope |
147
- | `TRANSIENT` | New instance on every resolve |
158
+ - **`SINGLETON`**: Uma única instância para toda a aplicação (ex: `EventBus`, `ConnectionManager`).
159
+ - **`REQUEST`**: Uma instância por requisição HTTP. É o escopo padrão para Controllers e Services, garantindo que o estado do tenant esteja isolado.
160
+ - **`TRANSIENT`**: Uma nova instância é criada toda vez que a classe é injetada ou resolvida.
148
161
 
149
- ### Usage
162
+ ### Controller, Service and Repository linkage
163
+
164
+ The container manages a strict hierarchy: `Controller` -> `Service` -> `Repository`.
165
+
166
+ - **Automatic Wiring**: When you register a module via `app.register([MyController, MyService, MyRepository])`, the container scans the metadata and prepares the injection tree.
167
+ - **Stateful Injection**: A Repository inheriting from `SequelizeRepository` is automatically configured by the container with the current `tenant` and `connectionManager` during its instantiation in the `RequestScope`.
150
168
 
151
169
  ```ts
152
- import { Injectable, Scope } from "@onroad/core"
170
+ import { Service, AbstractService } from "@onroad/core"
153
171
 
154
- @Injectable({ scope: Scope.SINGLETON })
155
- class CacheManager {
156
- private store = new Map<string, unknown>()
157
- get(key: string) { return this.store.get(key) }
158
- set(key: string, value: unknown) { this.store.set(key, value) }
172
+ @Service()
173
+ class OrdemService extends AbstractService<OrdemRepository> {
174
+ constructor() {
175
+ // Shared state: the container ensures OrdemRepository
176
+ // is instantiated within the same RequestScope as this service.
177
+ super({ repository: OrdemRepository })
178
+ }
159
179
  }
160
180
  ```
161
181
 
162
- Register all classes via `app.register([...])` — the container auto-scans metadata.
163
-
164
182
  ---
165
183
 
166
184
  ## Controllers, Services & Repositories
@@ -601,7 +619,7 @@ await app.shutdown()
601
619
 
602
620
  ## Testing
603
621
 
604
- TypeRoad uses [Vitest](https://vitest.dev/) with **223 tests** across 9 test files:
622
+ TypeRoad uses [Vitest](https://vitest.dev/) with **242 tests** across 10 test files:
605
623
 
606
624
  ```bash
607
625
  npm test # Run all tests
@@ -611,7 +629,7 @@ npm run test:coverage # Coverage report
611
629
 
612
630
  | Test File | Tests | Covers |
613
631
  |-----------|-------|--------|
614
- | `container.test.ts` | 16 | DI Container, decorators, scopes |
632
+ | `container.test.ts` | 19 | DI Container, decorators, scopes |
615
633
  | `entity.test.ts` | 14 | Entity, Column, associations |
616
634
  | `database.test.ts` | 13 | ConnectionManagers |
617
635
  | `filters.test.ts` | 33 | FilterChain, all 5 built-in filters |
@@ -620,6 +638,7 @@ npm run test:coverage # Coverage report
620
638
  | `logging.test.ts` | 20 | PinoLogger, OnRoadLogger |
621
639
  | `providers.test.ts` | 41 | All 4 providers, graceful-fail, shutdown |
622
640
  | `transport.test.ts` | 25 | HttpTransport, MessagingTransport, TransportFactory, RequestContext |
641
+ | `dev.test.ts` | 16 | DevServer, MigrationCLI |
623
642
 
624
643
  ---
625
644
 
@@ -632,21 +651,184 @@ src/
632
651
  ├── container/ # DI Container + decorators
633
652
  ├── context/ # RequestContext (AsyncLocalStorage)
634
653
  ├── core/ # AbstractController/Service/Repository, Sentinel, EventBus
654
+ ├── database/ # ConnectionManager abstract + Sequelize/Mongo implementations
655
+ │ └── migrations/ # MigrationRunner (Umzug)
656
+ ├── dev/ # DevServer, MigrationCLI — local dev tools
635
657
  ├── entity/ # @Entity, @Column, @Field, associations
636
658
  ├── filters/ # FilterChain, @Filter, built-in filters (Cors, JWT, Tenant, Role, RequestContext)
637
- ├── security/ # @Roles, @Public
638
- ├── database/ # ConnectionManager abstract + Sequelize/Mongo implementations
639
- ├── transport/ # InterServiceTransport, HttpTransport, MessagingTransport, TransportFactory
640
- ├── messaging/ # MatchingObject
641
- ├── providers/ # MessagingProvider, RealtimeProvider, TaskSchedulerProvider, SocketProvider
642
659
  ├── logging/ # OnRoadLogger interface + PinoLogger
660
+ ├── messaging/ # MatchingObject
643
661
  ├── plugins/ # OnRoadPlugin interface
662
+ ├── providers/ # MessagingProvider, RealtimeProvider, TaskSchedulerProvider, SocketProvider
663
+ ├── security/ # @Roles, @Public
644
664
  ├── storage/ # StorageProvider abstract
665
+ ├── transport/ # InterServiceTransport, HttpTransport, MessagingTransport, TransportFactory
645
666
  └── types/ # Express Request augmentation
646
667
  ```
647
668
 
648
669
  ---
649
670
 
671
+ ## Local Development (DevServer)
672
+
673
+ TypeRoad includes a `DevServer` that makes local development self-sufficient — it starts database containers, runs migrations, and logs all environment variables your frontend needs.
674
+
675
+ ### Quick Start
676
+
677
+ ```ts
678
+ import "reflect-metadata"
679
+ import { OnRoadExpress } from "@onroad/core"
680
+ import { SequelizeConnectionManager } from "@onroad/core/database"
681
+ import { DevServer } from "@onroad/core/dev"
682
+
683
+ const app = new OnRoadExpress({
684
+ connections: [
685
+ new SequelizeConnectionManager({
686
+ dialect: "postgres",
687
+ host: "localhost",
688
+ port: 5432,
689
+ user: "typeroad",
690
+ password: "typeroad_dev",
691
+ database: "typeroad_dev",
692
+ migrations: {
693
+ path: "./src/migrations",
694
+ runOnConnect: true,
695
+ },
696
+ }),
697
+ ],
698
+ })
699
+
700
+ // Register your controllers, services, repositories...
701
+ app.register([/* ... */])
702
+
703
+ const devServer = new DevServer({
704
+ app,
705
+ databases: [{ engine: "postgres", port: 5432 }],
706
+ migrationsPath: "./src/migrations",
707
+ tenants: ["default"],
708
+ frontendVars: {
709
+ REACT_APP_TENANT: "default",
710
+ REACT_APP_WS_URL: "ws://localhost:3000",
711
+ },
712
+ })
713
+
714
+ await devServer.start({ port: 3000 })
715
+ // Press Ctrl+C to stop
716
+ ```
717
+
718
+ ### What DevServer Does
719
+
720
+ 1. **Generates `docker-compose.dev.yml`** — PostgreSQL (or MySQL) container with healthcheck, volumes, and sensible defaults
721
+ 2. **Starts the container** — `docker compose up -d` automatically
722
+ 3. **Waits for readiness** — polls `pg_isready` / `mysqladmin ping` until the DB accepts connections
723
+ 4. **Runs migrations** — applies all pending Umzug migrations from your configured path
724
+ 5. **Starts the API server** — calls `app.buildServer()` as usual
725
+ 6. **Logs frontend env vars** — prints a copy-pasteable block and writes `.env.dev`:
726
+
727
+ ```
728
+ ┌──────────────────────────────────────────────────────────────┐
729
+ │ FRONTEND ENVIRONMENT VARIABLES │
730
+ ├──────────────────────────────────────────────────────────────┤
731
+ │ REACT_APP_API_URL=http://localhost:3000
732
+ │ NEXT_PUBLIC_API_URL=http://localhost:3000
733
+ │ VITE_API_URL=http://localhost:3000
734
+ │ DATABASE_URL=postgresql://typeroad:typeroad_dev@localhost:5432/typeroad_dev
735
+ │ REACT_APP_TENANT=default
736
+ └──────────────────────────────────────────────────────────────┘
737
+ ```
738
+
739
+ ### DevServer Config
740
+
741
+ | Option | Type | Default | Description |
742
+ |--------|------|---------|-------------|
743
+ | `app` | `OnRoadExpress` | required | The app instance |
744
+ | `databases` | `DevDatabaseConfig[]` | `[{ engine: "postgres" }]` | Database containers to start |
745
+ | `frontendVars` | `Record<string, string>` | `{}` | Extra vars to log for frontend |
746
+ | `migrationsPath` | `string` | — | Path to migration files |
747
+ | `autoMigrate` | `boolean` | `true` | Run migrations on start |
748
+ | `tenants` | `string[]` | `["default"]` | Tenants to run migrations for |
749
+ | `composeFile` | `string` | `"docker-compose.dev.yml"` | Docker compose filename |
750
+
751
+ ### Database Defaults
752
+
753
+ | Engine | Image | Port | User | Password | Database |
754
+ |--------|-------|------|------|----------|----------|
755
+ | `postgres` | `postgres:16-alpine` | `5432` | `typeroad` | `typeroad_dev` | `typeroad_dev` |
756
+ | `mysql` | `mysql:8.0` | `3306` | `typeroad` | `typeroad_dev` | `typeroad_dev` |
757
+
758
+ ---
759
+
760
+ ## Migration CLI
761
+
762
+ The `MigrationCLI` class provides a programmatic API for managing database migrations.
763
+
764
+ ### Usage
765
+
766
+ ```ts
767
+ import { MigrationCLI } from "@onroad/core/dev"
768
+ import { SequelizeConnectionManager } from "@onroad/core/database"
769
+
770
+ const connection = new SequelizeConnectionManager({
771
+ dialect: "postgres",
772
+ host: "localhost",
773
+ user: "typeroad",
774
+ password: "typeroad_dev",
775
+ database: "typeroad_dev",
776
+ migrations: { path: "./src/migrations" },
777
+ })
778
+
779
+ const cli = new MigrationCLI({
780
+ migrationsPath: "./src/migrations",
781
+ connection,
782
+ tenant: "default",
783
+ })
784
+
785
+ // Run from CLI args
786
+ await cli.run() // reads process.argv
787
+
788
+ // Or call directly
789
+ cli.create("add-users-table") // creates timestamped migration file
790
+ await cli.up() // apply all pending
791
+ await cli.up(1) // apply 1 step
792
+ await cli.down() // revert last migration
793
+ await cli.status() // show status table
794
+ ```
795
+
796
+ ### CLI Commands
797
+
798
+ | Command | Description | Example |
799
+ |---------|-------------|---------|
800
+ | `create <name>` | Create a new migration file | `cli.run(["create", "add-orders"])` |
801
+ | `up [steps]` | Apply pending migrations | `cli.run(["up"])` or `cli.run(["up", "3"])` |
802
+ | `down [steps]` | Revert migrations (default: 1) | `cli.run(["down", "2"])` |
803
+ | `status` | Show applied/pending status | `cli.run(["status"])` |
804
+
805
+ ### Migration File Format
806
+
807
+ Generated `.ts` files follow the Umzug/Sequelize pattern:
808
+
809
+ ```ts
810
+ import type { QueryInterface, Sequelize } from "sequelize"
811
+
812
+ export default {
813
+ async up(queryInterface: QueryInterface, sequelize: Sequelize): Promise<void> {
814
+ await queryInterface.createTable("ordens", {
815
+ id: { type: sequelize.constructor["DataTypes"].UUID, primaryKey: true },
816
+ descricao: { type: sequelize.constructor["DataTypes"].STRING, allowNull: false },
817
+ createdAt: sequelize.constructor["DataTypes"].DATE,
818
+ updatedAt: sequelize.constructor["DataTypes"].DATE,
819
+ })
820
+ },
821
+
822
+ async down(queryInterface: QueryInterface): Promise<void> {
823
+ await queryInterface.dropTable("ordens")
824
+ },
825
+ }
826
+ ```
827
+
828
+ Migration state is tracked in the `__typeroad_migrations` table (configurable via `tableName` in `MigrationConfig`).
829
+
830
+ ---
831
+
650
832
  ## Subpath Exports
651
833
 
652
834
  Import only what you need:
@@ -662,6 +844,7 @@ import { HttpTransport, TransportFactory } from "@onroad/core/transport" // Tran
662
844
  import { MatchingObject } from "@onroad/core/messaging" // Messaging
663
845
  import { PinoLogger } from "@onroad/core/logging" // Logging
664
846
  import { StorageProvider } from "@onroad/core/storage" // Storage
847
+ import { DevServer, MigrationCLI } from "@onroad/core/dev" // Dev Tools
665
848
  ```
666
849
 
667
850
  ---
@@ -763,6 +946,373 @@ export class UserRepository extends BaseRepository<User> {
763
946
 
764
947
  ---
765
948
 
949
+ ### `import type` with relationship decorators
950
+
951
+ 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.
952
+
953
+ ```ts
954
+ // ❌ Wrong — import type is erased, decorator callback fails at runtime
955
+ import type { CampoDeVerificacao } from "./CampoDeVerificacao.js"
956
+
957
+ @Entity("caderno", { engine: "sequelize" })
958
+ export class CadernoDeVerificacao {
959
+ @HasMany(() => CampoDeVerificacao, "cadernoId") // 💥 ReferenceError
960
+ campos!: CampoDeVerificacao[]
961
+ }
962
+ ```
963
+
964
+ Fix: use a regular `import` for any class referenced inside a decorator callback:
965
+
966
+ ```ts
967
+ // ✅ Correct — regular import keeps the binding at runtime
968
+ import { CampoDeVerificacao } from "./CampoDeVerificacao.js"
969
+
970
+ @Entity("caderno", { engine: "sequelize" })
971
+ export class CadernoDeVerificacao {
972
+ @HasMany(() => CampoDeVerificacao, "cadernoId")
973
+ campos!: CampoDeVerificacao[]
974
+ }
975
+ ```
976
+
977
+ > **Tip:** `import type` is safe for type annotations and generics (`extends AbstractService<Foo>`), but never for decorator arguments, `instanceof`, or any expression evaluated at runtime.
978
+
979
+ ---
980
+
981
+ ### `emitDecoratorMetadata` causes TDZ errors with circular entity imports
982
+
983
+ 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:
984
+
985
+ ```
986
+ ReferenceError: Cannot access 'CampoDeVerificacao' before initialization
987
+ ```
988
+
989
+ The `@HasMany(() => Foo, ...)` arrow function is lazy (only called later by `wireSequelizeAssociations`), but `__metadata("design:type", Foo)` is **eager** — it runs during class definition.
990
+
991
+ ```jsonc
992
+ // ❌ Wrong — causes TDZ crash in circular ESM imports
993
+ {
994
+ "compilerOptions": {
995
+ "experimentalDecorators": true,
996
+ "emitDecoratorMetadata": true // 💥
997
+ }
998
+ }
999
+ ```
1000
+
1001
+ 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`.
1002
+
1003
+ ```jsonc
1004
+ // ✅ Correct — no TDZ, decorators still work
1005
+ {
1006
+ "compilerOptions": {
1007
+ "experimentalDecorators": true,
1008
+ "emitDecoratorMetadata": false
1009
+ }
1010
+ }
1011
+ ```
1012
+
1013
+ ---
1014
+
1015
+ ### Custom route service methods must extract params from `req` — not expect primitive arguments
1016
+
1017
+ **`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.
1018
+
1019
+ 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.
1020
+
1021
+ ```ts
1022
+ // ❌ Wrong — service expects primitives; controller passes (req, res)
1023
+ // Controller calls: this.service.findByBetweenDates(req, res)
1024
+ async findByBetweenDates(dataInicio: string, dataFim: string) {
1025
+ // dataInicio === req object → Sequelize WHERE clause receives an object → returns []
1026
+ return this.repository.findAll({ where: { dataDeAbertura: { [Op.gte]: dataInicio } } })
1027
+ }
1028
+ ```
1029
+
1030
+ ```ts
1031
+ // ✅ Correct — service extracts its own params from req
1032
+ async findByBetweenDates(req: any, res?: any) {
1033
+ const dataInicio: string = req.params.dataInicio
1034
+ const dataFim: string = req.params.dataFim
1035
+ return this.repository.findAll({ where: { dataDeAbertura: { [Op.gte]: dataInicio, [Op.lte]: dataFim } } })
1036
+ }
1037
+ ```
1038
+
1039
+ **Affected params by HTTP position:**
1040
+
1041
+ | Source | How to extract |
1042
+ |------------------|-----------------------------|
1043
+ | Route param (`:id`) | `req.params.id` |
1044
+ | Query string | `req.query.field` |
1045
+ | Request body | `req.body.field` |
1046
+ | Auth / identity | `req.decoded.userId` |
1047
+
1048
+ > **Rule of thumb:** In a TypeRoad service, if the method is registered as a custom route, **the first argument is always `req`**. Never write `async myMethod(id: number)` for a method that is called through a route config key — always write `async myMethod(req: any, res?: any)` and extract from `req` internally.
1049
+
1050
+ **Scope of impact in `teraprox-api-manutencao` (audit — 2025-04-05):**
1051
+
1052
+ The following service methods were found with the wrong signature (primitive params instead of `req`). All calls through their controller routes were silently broken:
1053
+
1054
+ | Service | Method | Broken params |
1055
+ |---------|--------|---------------|
1056
+ | `OrdemDeServicoService` | `findByBetweenDates` | `dataInicio`, `dataFim` — **fixed** |
1057
+ | `OrdemDeServicoService` | `findByRecursoBetweenDates` | `recursoId`, `dataInicio`, `dataFim` |
1058
+ | `OrdemDeServicoService` | `findByBranchIdBetweenDates` | `branchId`, `dataInicio`, `dataFim` |
1059
+ | `OrdemDeServicoService` | `findByRecursoId` | `recursoId` |
1060
+ | `OrdemDeServicoService` | `findMonitoramentoByCriticidade` | `criticidade` |
1061
+ | `OrdemDeServicoService` | `findByModoDeFalhaId` | `modoDeFalhaId` |
1062
+ | `OrdemDeServicoService` | `encerraOS` | `id`, `form` |
1063
+ | `OrdemDeServicoService` | `setStatusOs` | `id`, `form` |
1064
+ | `OrdemDeServicoService` | `setStatusOsBulk` | `osIds`, `status`, `extraData` |
1065
+ | `OrdemDeServicoService` | `iniciarOsBulk` | `osIds`, `status` |
1066
+ | `OrdemDeServicoService` | `addTarefaToOrdem` | `osId`, `tarefa` |
1067
+ | `OrdemDeServicoService` | `updateDescricaoOs` | `osId`, `descricao` |
1068
+ | `OrdemDeServicoService` | `shiftRecursoOs` | `osId`, `recId` |
1069
+ | `OrdemDeServicoService` | `findInspecoesBetweenDates` | `recursoId`, `dataInicio`, `dataFim` |
1070
+ | `OrdemDeServicoService` | `putAnexos` | `id`, `anexos` |
1071
+ | `OrdemDeServicoService` | `findOrdensInBatch` | `ids` |
1072
+ | `OrdemDeServicoService` | `loadOsForOcpMigration` | `osIds` |
1073
+ | `RecursoService` | `findRecursoFatherByRecursoId` | `recursoId` |
1074
+ | `RecursoService` | `findByTagDescription` | `tag` |
1075
+ | `RecursoService` | `findRecursoByTagId` | `tagId` |
1076
+ | `RecursoService` | `findParadasByRecursoId` | `recursoId` |
1077
+ | `RecursoService` | `findSemParadasByRecursoId` | `recursoId` |
1078
+ | `TarefaService` | `encerrarTarefa` | `id` |
1079
+ | `TarefaService` | `addUnidadeMaterialTarefa` | `tarefaId`, `form` |
1080
+ | `TarefaService` | `addObservacaoTarefa` | `tarefaId`, `form` |
1081
+ | `TarefaService` | `deleteObservacaoTarefa` | `justificativaId` |
1082
+ | `TarefaService` | `loadMultipleIds` | `tarefaIds` |
1083
+ | `TarefaService` | `removeAnexo` | `id`, `anexoKey` |
1084
+ | `SolicitacaoDeServicoService` | `shiftRecursoSs` | `ssId`, `recId` |
1085
+ | `SolicitacaoDeServicoService` | `aprovaSolicitacao` | `id`, `form` |
1086
+ | `SolicitacaoDeServicoService` | `reprovaSolicitacao` | `id`, `form` |
1087
+ | `SolicitacaoDeServicoService` | `updateDescricaoSs` | `ssId`, `descricao` |
1088
+ | `SolicitacaoDeServicoService` | `findByRecursoId` | `recursoId` |
1089
+ | `SolicitacaoDeServicoService` | `findBetweenDates` | `start`, `end` |
1090
+ | `SolicitacaoDeServicoService` | `putAnexos` | `id`, `anexos` |
1091
+
1092
+ ---
1093
+
1094
+ ### Custom route key name must exactly match the service method name
1095
+
1096
+ 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`.
1097
+
1098
+ ```ts
1099
+ // ❌ Wrong — controller key "addModoDeFalhaOS" but service method is "addModoDeFalha"
1100
+ routes: { addModoDeFalhaOS: { method: "post", path: "/..." } }
1101
+ // → service.addModoDeFalhaOS is undefined → always returns null (no error thrown)
1102
+ ```
1103
+
1104
+ **Known mismatches in `teraprox-api-manutencao` (audit — 2025-04-05):**
1105
+
1106
+ | Controller route key | Expected service method (by key) | Actual service method |
1107
+ |----------------------|-----------------------------------|-----------------------|
1108
+ | `addModoDeFalhaOS` | `addModoDeFalhaOS` | `addModoDeFalha` |
1109
+ | `updateTipoDeOrdem` | `updateTipoDeOrdem` | `setTipoOrdem` |
1110
+ | `updateTipoDeOrdemBulk` | `updateTipoDeOrdemBulk` | `setTipoOrdemBulk` |
1111
+ | `removeAnexo` | `removeAnexo` | `removeAnexoOs` |
1112
+ | `readConcluidasCount` | `readConcluidasCount` | `countTarefasConcluidas` |
1113
+ | `findPlanned` | `findPlanned` | `findPlannedOrdensBetweenDatesV2` |
1114
+ | `findPlannedV3` | `findPlannedV3` | `findPlannedOrdensBetweenDatesV3` |
1115
+ | `findPlannedAgregador` | `findPlannedAgregador` | `findPlannedOrdensWithAgregadores` |
1116
+ | `findRecorrenciaPlanned` | `findRecorrenciaPlanned` | `findPlannedOrdensFromRecorrenciaBetweenDates` |
1117
+ | `findAgregadorPlanned` | `findAgregadorPlanned` | `findPlannedOrdensFromAgregadorBetweenDates` |
1118
+ | `createInBulk` | `createInBulk` | `createInBulkOs` |
1119
+ | `findDashByOs` | `findDashByOs` | `findWithDashboardAssociation` |
1120
+
1121
+ > 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.
1122
+
1123
+ ---
1124
+
1125
+ ### `BelongsToMany` association returns `null` (not `[]`) when no records exist
1126
+
1127
+ 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`.
1128
+
1129
+ ```ts
1130
+ // ❌ Wrong — crashes with "TypeError: o.tarefas is not iterable" when OS has no tarefas
1131
+ for (const tarefa of o.tarefas) { ... }
1132
+ o.tarefas.forEach(...)
1133
+ o.tarefas.flatMap(...)
1134
+ ```
1135
+
1136
+ ```ts
1137
+ // ✅ Correct — guard with nullish coalescing
1138
+ for (const tarefa of (o.tarefas ?? [])) { ... }
1139
+ ;(o.tarefas ?? []).forEach(...)
1140
+ ordens.flatMap((o) => (o.tarefas ?? []).map(...))
1141
+ ```
1142
+
1143
+ > This was the direct cause of `TypeError: o.tarefas is not iterable` at `OrdemDeServicoService.buildTarefa` (line 235) and `findByBetweenDates`. Apply the same `?? []` guard to **all** `BelongsToMany` associations before iterating.
1144
+
1145
+ ---
1146
+
1147
+ ## Migration Guide — Lessons Learned (v3 → v4)
1148
+
1149
+ 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.).
1150
+
1151
+ ### 1. Entity Associations — Use Decorators, Not `buildAssociations()`
1152
+
1153
+ v3 used `buildAssociations()` inside repositories. v4 uses decorators on entity classes. **Every association must be declared on the entity itself.**
1154
+
1155
+ ```ts
1156
+ // ❌ v3 — Repository-based associations (REMOVE)
1157
+ class UserRepository extends BaseRepository {
1158
+ buildAssociations() {
1159
+ this.hasMany(UserRole, "userRole")
1160
+ this.belongsTo(Company, "company")
1161
+ }
1162
+ }
1163
+
1164
+ // ✅ v4 — Entity decorator associations
1165
+ @Entity("user", { engine: "sequelize", timestamps: true })
1166
+ class User {
1167
+ @HasMany(() => UserRole, "userId")
1168
+ userRole!: UserRole[]
1169
+
1170
+ @BelongsToMany(() => Company, () => UserCompany, "userId", "companyId")
1171
+ companies!: Company[]
1172
+ }
1173
+ ```
1174
+
1175
+ **Checklist per entity:**
1176
+ - Join table entities (e.g. `UserRole`, `UserSetor`, `UserCompany`) **must** declare their FK columns with `@Column` AND the `@BelongsTo` back-reference.
1177
+
1178
+ ```ts
1179
+ @Entity("userRole", { engine: "sequelize", timestamps: true })
1180
+ class UserRole {
1181
+ @Column({ type: DataType.STRING })
1182
+ userId!: string
1183
+
1184
+ @Column({ type: DataType.STRING })
1185
+ roleId!: string
1186
+
1187
+ @BelongsTo(() => Role, "roleId")
1188
+ role!: Role
1189
+ }
1190
+ ```
1191
+
1192
+ ### 2. Includes — Use String-Based Associations, Not Model References
1193
+
1194
+ v3 used `this.repository.fullAssociation` (injected array of `{ model: SequelizeModel }`).
1195
+ v4 uses **string-based `association` names** that match the decorator property name.
1196
+
1197
+ ```ts
1198
+ // ❌ v3 — model-based includes (CRASH: model is a raw class, not Sequelize model)
1199
+ const user = await this.repository.findOne({
1200
+ where: { email },
1201
+ include: this.repository.fullAssociation
1202
+ })
1203
+
1204
+ // ✅ v4 — string-based association includes
1205
+ const USER_FULL_INCLUDE = [
1206
+ { association: "filters" },
1207
+ { association: "tickets" },
1208
+ { association: "userRole" },
1209
+ { association: "userSetor" }
1210
+ ]
1211
+ const user = await this.repository.findOne({
1212
+ where: { email },
1213
+ include: USER_FULL_INCLUDE
1214
+ })
1215
+ ```
1216
+
1217
+ **Nested includes follow the same pattern:**
1218
+ ```ts
1219
+ include: [
1220
+ {
1221
+ association: "userRole",
1222
+ include: [{ association: "role", where: { companyId }, required: false }]
1223
+ },
1224
+ {
1225
+ association: "userSetor",
1226
+ include: [{ association: "setor", where: { companyId }, required: false }]
1227
+ }
1228
+ ]
1229
+ ```
1230
+
1231
+ ### 3. Raw Queries — Use `getSequelizeConnection()`
1232
+
1233
+ v3 accessed `(this.repository as any).connection.query(...)`.
1234
+ v4 exposes this through `BaseRepository.getSequelizeConnection()`.
1235
+
1236
+ ```ts
1237
+ // ❌ v3
1238
+ const [results] = await (this.repository as any).connection.query(sql)
1239
+
1240
+ // ✅ v4 — add this method to your BaseRepository
1241
+ getSequelizeConnection() { return this.getConnection() }
1242
+
1243
+ // Usage in service:
1244
+ const [results] = await this.repository.getSequelizeConnection().query(sql)
1245
+ ```
1246
+
1247
+ ### 4. Remove `fullAssociation` from BaseRepository
1248
+
1249
+ v3 APIs declare `fullAssociation` on `BaseRepository`. **Remove it entirely** — v4 does not use it. Includes are now inline at the query site.
1250
+
1251
+ ```ts
1252
+ // ❌ v3
1253
+ export abstract class BaseRepository<T> extends SequelizeRepository<T> {
1254
+ declare readonly fullAssociation: any[]
1255
+ }
1256
+
1257
+ // ✅ v4
1258
+ export abstract class BaseRepository<T> extends SequelizeRepository<T> {
1259
+ // No fullAssociation — define includes where you query
1260
+ }
1261
+ ```
1262
+
1263
+ ### 5. Controller Return Values Are Auto-Serialized
1264
+
1265
+ 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.
1266
+
1267
+ ```ts
1268
+ // ✅ Just return the value — framework serializes it
1269
+ async login(form: any, req: Request, res: Response) {
1270
+ const userData = await this.service.login(form, req, res)
1271
+ return userData // Framework calls res.json(userData)
1272
+ }
1273
+ ```
1274
+
1275
+ ### 6. `BelongsToMany` Includes — Beware of Query Hangs
1276
+
1277
+ 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.
1278
+
1279
+ ### 7. Common Migration Search Patterns
1280
+
1281
+ When migrating an API, search for these patterns and replace them:
1282
+
1283
+ | Search Pattern | Replace With |
1284
+ |---|---|
1285
+ | `this.repository.fullAssociation` | Inline `[{ association: "..." }]` array |
1286
+ | `(this.xxxRepo as any).model` | `{ association: "aliasName" }` |
1287
+ | `(this.xxxRepo as any).xxxRepo` | `{ association: "aliasName" }` |
1288
+ | `(this.repository as any).connection` | `this.repository.getSequelizeConnection()` |
1289
+ | `buildAssociations()` | `@HasMany`, `@BelongsTo`, `@HasOne`, `@BelongsToMany` on entity |
1290
+ | `{ model: SomeClass, as: "..." }` | `{ association: "..." }` |
1291
+
1292
+ ### 8. `ARRAY` Column Type
1293
+
1294
+ For PostgreSQL `varchar[]` columns, use the custom `arrayType` option:
1295
+
1296
+ ```ts
1297
+ @Column({ type: DataType.ARRAY, arrayType: DataType.STRING })
1298
+ componentesBloqueados!: string[]
1299
+ ```
1300
+
1301
+ ### 9. Env & Local Dev Setup
1302
+
1303
+ Each API needs a `.env` at its server root with:
1304
+ - `LOCAL_NO_JWT=true` — skips JWT validation for local dev
1305
+ - DB connection vars (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DBNAME`)
1306
+ - `TOKEN_KEY` — any string for local token signing
1307
+ - `API_PORT` — local port for the service
1308
+
1309
+ Use `cloud-sql-proxy` for connecting to GCP Cloud SQL locally:
1310
+ ```bash
1311
+ ./cloud-sql-proxy <PROJECT>:<REGION>:<INSTANCE> --port=5432
1312
+ ```
1313
+
1314
+ ---
1315
+
766
1316
  ## License
767
1317
 
768
1318
  UNLICENSED — HashCodeTI-Brasil