@venizia/ignis-boot 0.0.3-3 → 0.0.4-0
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.
- package/README.md +2586 -37
- package/dist/cjs/boot.mixin.d.ts +9 -0
- package/dist/cjs/boot.mixin.d.ts.map +1 -1
- package/dist/esm/boot.mixin.d.ts +9 -0
- package/dist/esm/boot.mixin.d.ts.map +1 -1
- package/package.json +58 -37
package/README.md
CHANGED
|
@@ -2,71 +2,2620 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@venizia/ignis-boot)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
Convention-based **auto-discovery and bootstrapping** system for the [Ignis Framework](https://github.com/VENIZIA-AI/ignis). Discovers artifact files (controllers, services, repositories, datasources) by glob patterns and registers them into the IoC container during application startup.
|
|
8
|
+
|
|
9
|
+
Inspired by [LoopBack 4's boot system](https://loopback.io/doc/en/lb4/Booting-an-Application.html), this package provides a structured, three-phase lifecycle for discovering and loading application artifacts -- giving you convention over configuration without sacrificing control.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick Start](#quick-start)
|
|
17
|
+
- [Core Concepts](#core-concepts)
|
|
18
|
+
- [Three-Phase Lifecycle](#three-phase-lifecycle)
|
|
19
|
+
- [BaseArtifactBooter](#baseartifactbooter)
|
|
20
|
+
- [Built-in Booters](#built-in-booters)
|
|
21
|
+
- [Bootstrapper](#bootstrapper)
|
|
22
|
+
- [BootMixin](#bootmixin)
|
|
23
|
+
- [Configuration](#configuration)
|
|
24
|
+
- [IBootOptions](#ibootoptions)
|
|
25
|
+
- [IArtifactOptions](#iartifactoptions)
|
|
26
|
+
- [Pattern Generation](#pattern-generation)
|
|
27
|
+
- [Complete Lifecycle Walkthrough](#complete-lifecycle-walkthrough)
|
|
28
|
+
- [BaseArtifactBooter Internals](#baseartifactbooter-internals)
|
|
29
|
+
- [Protected Properties](#protected-properties)
|
|
30
|
+
- [Constructor](#baseartifactbooter-constructor)
|
|
31
|
+
- [configure() -- Phase 1 Internals](#configure----phase-1-internals)
|
|
32
|
+
- [discover() -- Phase 2 Internals](#discover----phase-2-internals)
|
|
33
|
+
- [load() -- Phase 3 Internals](#load----phase-3-internals)
|
|
34
|
+
- [getPattern() -- Pattern Builder Logic](#getpattern----pattern-builder-logic)
|
|
35
|
+
- [Pattern Generation Deep Dive](#pattern-generation-deep-dive)
|
|
36
|
+
- [Single Dir + Single Extension](#single-dir--single-extension)
|
|
37
|
+
- [Multiple Dirs + Multiple Extensions](#multiple-dirs--multiple-extensions)
|
|
38
|
+
- [Nested vs Non-Nested](#nested-vs-non-nested)
|
|
39
|
+
- [Custom Glob Override](#custom-glob-override)
|
|
40
|
+
- [Dot Stripping from Extensions](#dot-stripping-from-extensions)
|
|
41
|
+
- [Pattern Summary Table](#pattern-summary-table)
|
|
42
|
+
- [Built-in Booters Deep Dive](#built-in-booters-deep-dive)
|
|
43
|
+
- [ControllerBooter](#controllerbooter)
|
|
44
|
+
- [ServiceBooter](#servicebooter)
|
|
45
|
+
- [RepositoryBooter](#repositorybooter)
|
|
46
|
+
- [DatasourceBooter](#datasourcebooter)
|
|
47
|
+
- [Why Are Datasources Singletons?](#why-are-datasources-singletons)
|
|
48
|
+
- [BootMixin Deep Dive](#bootmixin-deep-dive)
|
|
49
|
+
- [Constructor Internals](#constructor-internals)
|
|
50
|
+
- [boot() Method](#boot-method)
|
|
51
|
+
- [Bootstrapper Internals](#bootstrapper-internals)
|
|
52
|
+
- [discoverBooters()](#discoverbooters)
|
|
53
|
+
- [runPhase()](#runphase)
|
|
54
|
+
- [Error Wrapping](#error-wrapping)
|
|
55
|
+
- [Performance Timing](#performance-timing)
|
|
56
|
+
- [IBootReport Structure](#ibootreport-structure)
|
|
57
|
+
- [Advanced Usage](#advanced-usage)
|
|
58
|
+
- [Creating Custom Booters](#creating-custom-booters)
|
|
59
|
+
- [Multiple Custom Booter Examples](#multiple-custom-booter-examples)
|
|
60
|
+
- [Custom Glob Patterns](#custom-glob-patterns)
|
|
61
|
+
- [Boot Options Override Examples](#boot-options-override-examples)
|
|
62
|
+
- [Selective Phase Execution](#selective-phase-execution)
|
|
63
|
+
- [Boot Report and Performance Timing](#boot-report-and-performance-timing)
|
|
64
|
+
- [Integration with BaseApplication](#integration-with-baseapplication)
|
|
65
|
+
- [Error Scenarios](#error-scenarios)
|
|
66
|
+
- [Debugging Boot](#debugging-boot)
|
|
67
|
+
- [Performance Tuning](#performance-tuning)
|
|
68
|
+
- [File Naming Conventions](#file-naming-conventions)
|
|
69
|
+
- [Boot Utilities](#boot-utilities)
|
|
70
|
+
- [Complete Type Reference](#complete-type-reference)
|
|
71
|
+
- [Constants Reference](#constants-reference)
|
|
72
|
+
- [Testing Patterns](#testing-patterns)
|
|
73
|
+
- [License](#license)
|
|
74
|
+
|
|
75
|
+
---
|
|
7
76
|
|
|
8
77
|
## Installation
|
|
9
78
|
|
|
10
79
|
```bash
|
|
11
80
|
bun add @venizia/ignis-boot
|
|
12
|
-
# or
|
|
13
|
-
npm install @venizia/ignis-boot
|
|
14
81
|
```
|
|
15
82
|
|
|
16
|
-
|
|
83
|
+
**Peer dependencies:**
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bun add @venizia/ignis-inversion @venizia/ignis-helpers
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Quick Start
|
|
92
|
+
|
|
93
|
+
The fastest way to use `@venizia/ignis-boot` is through `BootMixin`, which enhances any `Container` subclass with auto-discovery capabilities:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { BootMixin, IBootOptions } from '@venizia/ignis-boot';
|
|
97
|
+
import { Container } from '@venizia/ignis-inversion';
|
|
98
|
+
|
|
99
|
+
class MyApplication extends BootMixin(Container) {
|
|
100
|
+
bootOptions: IBootOptions = {
|
|
101
|
+
controllers: { dirs: ['controllers'], isNested: true },
|
|
102
|
+
services: { dirs: ['services'], isNested: true },
|
|
103
|
+
repositories: { dirs: ['repositories'] },
|
|
104
|
+
datasources: { dirs: ['datasources'] },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Bootstrap and start
|
|
109
|
+
const app = new MyApplication();
|
|
110
|
+
const report = await app.boot();
|
|
111
|
+
// All controllers, services, repositories, and datasources
|
|
112
|
+
// are now discovered and registered in the IoC container.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
When used with `@venizia/ignis` (the core framework), `BaseApplication` already applies the `BootMixin` internally, so you only need to pass `bootOptions` through your application configs:
|
|
17
116
|
|
|
18
117
|
```typescript
|
|
19
|
-
import { BaseApplication, IApplicationConfigs } from
|
|
20
|
-
import { IBootOptions } from "@venizia/ignis-boot";
|
|
118
|
+
import { BaseApplication, IApplicationConfigs } from '@venizia/ignis';
|
|
21
119
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
name: "MyApp",
|
|
120
|
+
const appConfigs: IApplicationConfigs = {
|
|
121
|
+
name: 'MyApp',
|
|
25
122
|
bootOptions: {
|
|
26
|
-
controllers: { dirs: [
|
|
27
|
-
services: { dirs: [
|
|
28
|
-
repositories: { dirs: [
|
|
29
|
-
datasources: { dirs: [
|
|
123
|
+
controllers: { dirs: ['controllers'], isNested: true },
|
|
124
|
+
services: { dirs: ['services'], isNested: true },
|
|
125
|
+
repositories: { dirs: ['repositories'] },
|
|
126
|
+
datasources: { dirs: ['datasources'] },
|
|
30
127
|
},
|
|
31
128
|
};
|
|
32
129
|
|
|
33
|
-
|
|
130
|
+
class Application extends BaseApplication {
|
|
34
131
|
constructor() {
|
|
35
132
|
super(appConfigs);
|
|
36
|
-
// That's it! All artifacts are auto-discovered and registered
|
|
37
133
|
}
|
|
38
134
|
}
|
|
39
135
|
```
|
|
40
136
|
|
|
41
|
-
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Core Concepts
|
|
140
|
+
|
|
141
|
+
### Three-Phase Lifecycle
|
|
142
|
+
|
|
143
|
+
Every booter follows a strict three-phase execution order. Phases run sequentially, and all registered booters execute within each phase before the next phase begins.
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Phase 1: CONFIGURE Phase 2: DISCOVER Phase 3: LOAD
|
|
147
|
+
+-----------------+ +-----------------+ +-----------------+
|
|
148
|
+
| Merge user opts | | Build glob | | Dynamic import |
|
|
149
|
+
| with defaults |-->| pattern from |-->| discovered |
|
|
150
|
+
| (dirs, exts, | | configured opts | | files, filter |
|
|
151
|
+
| isNested, glob)| | and scan FS | | class exports, |
|
|
152
|
+
+-----------------+ +-----------------+ | bind to DI |
|
|
153
|
+
+-----------------+
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Phase 1 -- Configure:** Merges user-provided options (`dirs`, `extensions`, `isNested`, `glob`) with the booter's defaults. If you provide `dirs: ['custom-controllers']`, it replaces the default `['controllers']`. If you provide nothing, defaults are used.
|
|
157
|
+
|
|
158
|
+
**Phase 2 -- Discover:** Builds a glob pattern from the configured options and scans the filesystem relative to the project root. The result is an array of absolute file paths stored in `discoveredFiles`.
|
|
159
|
+
|
|
160
|
+
**Phase 3 -- Load:** Dynamically imports each discovered file, filters all exports to keep only class constructors (using `isClass`), and then calls `bind()` to register each class in the IoC container with the appropriate namespace and scope.
|
|
161
|
+
|
|
162
|
+
### BaseArtifactBooter
|
|
163
|
+
|
|
164
|
+
The `BaseArtifactBooter` implements the [Template Method pattern](https://refactoring.guru/design-patterns/template-method). It defines the algorithm skeleton (configure, discover, load) while deferring booter-specific behavior to subclasses through abstract methods.
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
BaseArtifactBooter (extends BaseHelper, implements IBooter)
|
|
168
|
+
|
|
|
169
|
+
|-- configure() # Phase 1: merge options with defaults
|
|
170
|
+
|-- discover() # Phase 2: glob filesystem for files
|
|
171
|
+
|-- load() # Phase 3: import files + call bind()
|
|
172
|
+
|
|
|
173
|
+
|-- abstract getDefaultDirs() # Subclass: default directories
|
|
174
|
+
|-- abstract getDefaultExtensions() # Subclass: default file extensions
|
|
175
|
+
|-- abstract bind() # Subclass: register classes in container
|
|
176
|
+
|
|
|
177
|
+
|-- getPattern() # Build glob pattern from options
|
|
178
|
+
|
|
|
179
|
+
|-- (protected) root # Project root directory
|
|
180
|
+
|-- (protected) artifactOptions # Merged configuration
|
|
181
|
+
|-- (protected) discoveredFiles # Result of discover phase
|
|
182
|
+
|-- (protected) loadedClasses # Result of load phase
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The base class handles all the common logic (option merging, glob execution, file importing, class filtering), so subclasses only need to declare their conventions and binding strategy.
|
|
186
|
+
|
|
187
|
+
### Built-in Booters
|
|
188
|
+
|
|
189
|
+
The package ships with four built-in booters covering the most common artifact types:
|
|
190
|
+
|
|
191
|
+
| Booter | Default Directory | Default Extension | Namespace | Scope | Binding Key Example |
|
|
192
|
+
|---|---|---|---|---|---|
|
|
193
|
+
| `ControllerBooter` | `controllers/` | `.controller.js` | `controllers` | transient | `controllers.UserController` |
|
|
194
|
+
| `ServiceBooter` | `services/` | `.service.js` | `services` | transient | `services.AuthService` |
|
|
195
|
+
| `RepositoryBooter` | `repositories/` | `.repository.js` | `repositories` | transient | `repositories.UserRepository` |
|
|
196
|
+
| `DatasourceBooter` | `datasources/` | `.datasource.js` | `datasources` | **singleton** | `datasources.PostgresDataSource` |
|
|
197
|
+
|
|
198
|
+
**Why are datasources singletons?** Datasources manage connection pools and shared resources (database connections, Redis clients, etc.). Creating new instances per injection would leak connections and defeat pool sharing. All other artifact types default to transient scope.
|
|
199
|
+
|
|
200
|
+
Each built-in booter receives its configuration via constructor injection:
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
export class ControllerBooter extends BaseArtifactBooter {
|
|
204
|
+
constructor(
|
|
205
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
206
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
207
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
208
|
+
) {
|
|
209
|
+
super({
|
|
210
|
+
scope: ControllerBooter.name,
|
|
211
|
+
root,
|
|
212
|
+
artifactOptions: bootOptions.controllers ?? {},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
protected override getDefaultDirs(): string[] {
|
|
217
|
+
return ['controllers'];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
protected override getDefaultExtensions(): string[] {
|
|
221
|
+
return ['.controller.js'];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
protected override async bind(): Promise<void> {
|
|
225
|
+
for (const cls of this.loadedClasses) {
|
|
226
|
+
const key = BindingKeys.build({ namespace: 'controllers', key: cls.name });
|
|
227
|
+
this.application.bind({ key }).toClass(cls).setTags('controllers');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Bootstrapper
|
|
234
|
+
|
|
235
|
+
The `Bootstrapper` is the orchestrator that coordinates all booters through all phases. It is responsible for:
|
|
236
|
+
|
|
237
|
+
1. **Discovering booters** -- finds all bindings tagged with `'booter'` in the container
|
|
238
|
+
2. **Running phases** -- executes each phase sequentially on all discovered booters
|
|
239
|
+
3. **Error handling** -- wraps errors with context (phase name + booter class name)
|
|
240
|
+
4. **Performance timing** -- tracks `performance.now()` timestamps per phase
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
export class Bootstrapper extends BaseHelper implements IBootstrapper {
|
|
244
|
+
constructor(
|
|
245
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
246
|
+
) {
|
|
247
|
+
super({ scope: Bootstrapper.name });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async boot(opts: IBootExecutionOptions): Promise<IBootReport> {
|
|
251
|
+
const { phases = BOOT_PHASES, booters } = opts;
|
|
252
|
+
|
|
253
|
+
await this.discoverBooters();
|
|
254
|
+
for (const phase of phases) {
|
|
255
|
+
await this.runPhase({ phase, booterNames: booters });
|
|
256
|
+
}
|
|
257
|
+
return this.generateReport();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**Execution flow:**
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
boot()
|
|
266
|
+
|-> discoverBooters() # Find all 'booter'-tagged bindings
|
|
267
|
+
|-> runPhase('configure') # All booters: configure()
|
|
268
|
+
|-> runPhase('discover') # All booters: discover()
|
|
269
|
+
|-> runPhase('load') # All booters: load()
|
|
270
|
+
|-> generateReport() # Return performance data
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Within each phase, booters are executed sequentially in registration order. This is important because the registration order in `BootMixin` is:
|
|
274
|
+
|
|
275
|
+
1. `DatasourceBooter` -- datasources first (others may depend on them)
|
|
276
|
+
2. `RepositoryBooter` -- repositories second (depend on datasources)
|
|
277
|
+
3. `ServiceBooter` -- services third (depend on repositories)
|
|
278
|
+
4. `ControllerBooter` -- controllers last (depend on services)
|
|
279
|
+
|
|
280
|
+
### BootMixin
|
|
281
|
+
|
|
282
|
+
`BootMixin` is a mixin function that enhances any `Container` subclass with boot capabilities. It handles all the wiring automatically:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import { BootMixin } from '@venizia/ignis-boot';
|
|
286
|
+
import { Container } from '@venizia/ignis-inversion';
|
|
287
|
+
|
|
288
|
+
class MyApp extends BootMixin(Container) {
|
|
289
|
+
bootOptions = { /* ... */ };
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**What it registers in the constructor:**
|
|
294
|
+
|
|
295
|
+
| Binding Key | Value | Tags | Scope |
|
|
296
|
+
|---|---|---|---|
|
|
297
|
+
| `@app/boot-options` | User's `bootOptions` object | -- | -- |
|
|
298
|
+
| `booter.DatasourceBooter` | `DatasourceBooter` class | `booter` | transient |
|
|
299
|
+
| `booter.RepositoryBooter` | `RepositoryBooter` class | `booter` | transient |
|
|
300
|
+
| `booter.ServiceBooter` | `ServiceBooter` class | `booter` | transient |
|
|
301
|
+
| `booter.ControllerBooter` | `ControllerBooter` class | `booter` | transient |
|
|
302
|
+
| `bootstrapper` | `Bootstrapper` class | -- | singleton |
|
|
303
|
+
|
|
304
|
+
**What it adds to the class:**
|
|
305
|
+
|
|
306
|
+
- `bootOptions?: IBootOptions` -- property for user configuration
|
|
307
|
+
- `boot(): Promise<IBootReport>` -- method that resolves the `Bootstrapper` singleton and calls `boot({})`
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Configuration
|
|
312
|
+
|
|
313
|
+
### IBootOptions
|
|
314
|
+
|
|
315
|
+
The top-level configuration object maps artifact type names to their discovery options. It includes four built-in keys and supports arbitrary extension:
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
interface IBootOptions {
|
|
319
|
+
controllers?: IArtifactOptions;
|
|
320
|
+
services?: IArtifactOptions;
|
|
321
|
+
repositories?: IArtifactOptions;
|
|
322
|
+
datasources?: IArtifactOptions;
|
|
323
|
+
[artifactType: string]: IArtifactOptions | undefined; // Extensible for custom booters
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Example:**
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
const bootOptions: IBootOptions = {
|
|
331
|
+
controllers: {
|
|
332
|
+
dirs: ['controllers'],
|
|
333
|
+
isNested: true,
|
|
334
|
+
},
|
|
335
|
+
services: {
|
|
336
|
+
dirs: ['services'],
|
|
337
|
+
extensions: ['.service.js'],
|
|
338
|
+
},
|
|
339
|
+
repositories: {
|
|
340
|
+
dirs: ['repositories'],
|
|
341
|
+
},
|
|
342
|
+
datasources: {
|
|
343
|
+
dirs: ['datasources'],
|
|
344
|
+
},
|
|
345
|
+
// Custom booter options
|
|
346
|
+
handlers: {
|
|
347
|
+
dirs: ['handlers'],
|
|
348
|
+
extensions: ['.handler.js'],
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### IArtifactOptions
|
|
354
|
+
|
|
355
|
+
Configuration for a single artifact type's discovery behavior:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
interface IArtifactOptions {
|
|
359
|
+
dirs?: string[]; // Directories to scan (relative to project root)
|
|
360
|
+
extensions?: string[]; // File extensions to match (e.g., '.controller.js')
|
|
361
|
+
isNested?: boolean; // Scan subdirectories? Default: true
|
|
362
|
+
glob?: string; // Custom glob pattern (overrides dirs/extensions entirely)
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
| Option | Type | Default | Description |
|
|
367
|
+
|---|---|---|---|
|
|
368
|
+
| `dirs` | `string[]` | Booter-specific | Directories to scan, relative to project root |
|
|
369
|
+
| `extensions` | `string[]` | Booter-specific | File extension patterns to match |
|
|
370
|
+
| `isNested` | `boolean` | `true` | Whether to recurse into subdirectories |
|
|
371
|
+
| `glob` | `string` | `undefined` | Custom glob pattern; if set, `dirs` and `extensions` are ignored |
|
|
372
|
+
|
|
373
|
+
### Pattern Generation
|
|
374
|
+
|
|
375
|
+
When no custom `glob` is provided, `BaseArtifactBooter.getPattern()` builds a glob pattern from `dirs`, `extensions`, and `isNested`:
|
|
376
|
+
|
|
377
|
+
**Single directory, single extension:**
|
|
378
|
+
```
|
|
379
|
+
dirs: ['repositories']
|
|
380
|
+
extensions: ['.repository.js']
|
|
381
|
+
isNested: true
|
|
382
|
+
|
|
383
|
+
Result: repositories/{**/*,*}.repository.js
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Multiple directories or extensions:**
|
|
387
|
+
```
|
|
388
|
+
dirs: ['dir1', 'dir2']
|
|
389
|
+
extensions: ['.ext1.js', '.ext2.js']
|
|
390
|
+
isNested: true
|
|
391
|
+
|
|
392
|
+
Result: {dir1,dir2}/{**/*,*}.{ext1.js,ext2.js}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Non-nested (single level only):**
|
|
396
|
+
```
|
|
397
|
+
dirs: ['controllers']
|
|
398
|
+
extensions: ['.controller.js']
|
|
399
|
+
isNested: false
|
|
400
|
+
|
|
401
|
+
Result: controllers/*.controller.js
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Custom glob (overrides everything):**
|
|
405
|
+
```
|
|
406
|
+
glob: 'custom/glob/pattern/**/*.js'
|
|
407
|
+
|
|
408
|
+
Result: custom/glob/pattern/**/*.js
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The leading dot in extensions is automatically stripped during pattern generation. For example, `.controller.js` becomes `controller.js` in the glob pattern.
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
## Complete Lifecycle Walkthrough
|
|
416
|
+
|
|
417
|
+
This section walks through what happens step-by-step when you call `app.boot()` on an application with the following configuration:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
const bootOptions: IBootOptions = {
|
|
421
|
+
controllers: { dirs: ['controllers'], isNested: true },
|
|
422
|
+
services: { dirs: ['services'] },
|
|
423
|
+
repositories: { dirs: ['repositories'] },
|
|
424
|
+
datasources: { dirs: ['datasources'] },
|
|
425
|
+
};
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Assume the project's `dist/` directory has this structure:
|
|
429
|
+
|
|
430
|
+
```
|
|
431
|
+
dist/
|
|
432
|
+
controllers/
|
|
433
|
+
user.controller.js (exports UserController)
|
|
434
|
+
admin/
|
|
435
|
+
admin.controller.js (exports AdminController)
|
|
436
|
+
services/
|
|
437
|
+
auth.service.js (exports AuthService)
|
|
438
|
+
repositories/
|
|
439
|
+
user.repository.js (exports UserRepository)
|
|
440
|
+
datasources/
|
|
441
|
+
postgres.datasource.js (exports PostgresDataSource)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Step 1: boot() is called
|
|
445
|
+
|
|
446
|
+
The `boot()` method (from `BootMixin` or `BaseApplication`) resolves the `Bootstrapper` singleton from the container:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
boot(): Promise<IBootReport> {
|
|
450
|
+
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
|
|
451
|
+
return bootstrapper.boot({});
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Step 2: Bootstrapper.boot() begins
|
|
456
|
+
|
|
457
|
+
The `Bootstrapper` is instantiated (or retrieved from singleton cache) and runs:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
[Bootstrapper][boot] Starting boot | Number of booters: 4
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Step 3: discoverBooters()
|
|
464
|
+
|
|
465
|
+
The `Bootstrapper` calls `this.application.findByTag({ tag: 'booter' })` to find all bindings tagged `'booter'`. It resolves each binding (instantiating the booter class via the IoC container), which triggers constructor injection of `@app/project_root`, `@app/instance`, and `@app/boot-options`.
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.DatasourceBooter
|
|
469
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.RepositoryBooter
|
|
470
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.ServiceBooter
|
|
471
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.ControllerBooter
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
The booters are stored in an array in registration order. This order matters -- datasources are discovered and loaded before repositories, which are discovered before services, etc.
|
|
475
|
+
|
|
476
|
+
### Step 4: Phase CONFIGURE
|
|
477
|
+
|
|
478
|
+
The `Bootstrapper` calls `configure()` on each booter sequentially.
|
|
479
|
+
|
|
480
|
+
Each booter merges user-provided options with its defaults. If the user specified `dirs: ['controllers']`, that is used. If the user did not specify `extensions`, the booter falls back to its `getDefaultExtensions()` return value.
|
|
481
|
+
|
|
482
|
+
```
|
|
483
|
+
[Bootstrapper][runPhase] Starting phase: CONFIGURE
|
|
484
|
+
|
|
485
|
+
[DatasourceBooter][configure] Configured: {"dirs":["datasources"],"extensions":[".datasource.js"],"isNested":true}
|
|
486
|
+
[RepositoryBooter][configure] Configured: {"dirs":["repositories"],"extensions":[".repository.js"],"isNested":true}
|
|
487
|
+
[ServiceBooter][configure] Configured: {"dirs":["services"],"extensions":[".service.js"],"isNested":true}
|
|
488
|
+
[ControllerBooter][configure] Configured: {"dirs":["controllers"],"extensions":[".controller.js"],"isNested":true}
|
|
489
|
+
|
|
490
|
+
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Step 5: Phase DISCOVER
|
|
494
|
+
|
|
495
|
+
Each booter calls `getPattern()` to build a glob string, then uses the `glob` library to scan the filesystem from the project root.
|
|
496
|
+
|
|
497
|
+
```
|
|
498
|
+
[Bootstrapper][runPhase] Starting phase: DISCOVER
|
|
499
|
+
|
|
500
|
+
[DatasourceBooter][discover] Root: /app/dist | Using pattern: datasources/{**/*,*}.datasource.js | Discovered file: ["/app/dist/datasources/postgres.datasource.js"]
|
|
501
|
+
[RepositoryBooter][discover] Root: /app/dist | Using pattern: repositories/{**/*,*}.repository.js | Discovered file: ["/app/dist/repositories/user.repository.js"]
|
|
502
|
+
[ServiceBooter][discover] Root: /app/dist | Using pattern: services/{**/*,*}.service.js | Discovered file: ["/app/dist/services/auth.service.js"]
|
|
503
|
+
[ControllerBooter][discover] Root: /app/dist | Using pattern: controllers/{**/*,*}.controller.js | Discovered file: ["/app/dist/controllers/user.controller.js","/app/dist/controllers/admin/admin.controller.js"]
|
|
504
|
+
|
|
505
|
+
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Note how `controllers/admin/admin.controller.js` was discovered because `isNested: true` generates the pattern `{**/*,*}` which matches both the root level and subdirectories.
|
|
509
|
+
|
|
510
|
+
### Step 6: Phase LOAD
|
|
511
|
+
|
|
512
|
+
Each booter dynamically imports its discovered files using `await import(file)`, iterates all named exports, filters for class constructors using `isClass()`, and then calls its `bind()` method to register classes in the IoC container.
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
[Bootstrapper][runPhase] Starting phase: LOAD
|
|
516
|
+
|
|
517
|
+
[DatasourceBooter][bind] Bound key: datasources.PostgresDataSource (scope: singleton)
|
|
518
|
+
[RepositoryBooter][bind] Bound key: repositories.UserRepository (scope: transient)
|
|
519
|
+
[ServiceBooter][bind] Bound key: services.AuthService (scope: transient)
|
|
520
|
+
[ControllerBooter][bind] Bound key: controllers.UserController (scope: transient)
|
|
521
|
+
[ControllerBooter][bind] Bound key: controllers.AdminController (scope: transient)
|
|
522
|
+
|
|
523
|
+
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Step 7: generateReport()
|
|
527
|
+
|
|
528
|
+
The `Bootstrapper` returns an `IBootReport` object. The current implementation returns an empty object (`{}`), but the infrastructure for phase timing is in place for future extension.
|
|
529
|
+
|
|
530
|
+
```
|
|
531
|
+
[Bootstrapper][generateReport] Boot report: {}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Final State
|
|
535
|
+
|
|
536
|
+
After boot completes, the IoC container holds:
|
|
537
|
+
|
|
538
|
+
| Binding Key | Class | Scope | Tags |
|
|
539
|
+
|---|---|---|---|
|
|
540
|
+
| `datasources.PostgresDataSource` | `PostgresDataSource` | singleton | `datasources` |
|
|
541
|
+
| `repositories.UserRepository` | `UserRepository` | transient | `repositories` |
|
|
542
|
+
| `services.AuthService` | `AuthService` | transient | `services` |
|
|
543
|
+
| `controllers.UserController` | `UserController` | transient | `controllers` |
|
|
544
|
+
| `controllers.AdminController` | `AdminController` | transient | `controllers` |
|
|
545
|
+
|
|
546
|
+
These can now be resolved anywhere via `@inject({ key: 'services.AuthService' })` or `app.get({ key: 'controllers.UserController' })`.
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## BaseArtifactBooter Internals
|
|
551
|
+
|
|
552
|
+
This section documents every protected property, method, and internal behavior of `BaseArtifactBooter`.
|
|
553
|
+
|
|
554
|
+
### Protected Properties
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
export abstract class BaseArtifactBooter extends BaseHelper implements IBooter {
|
|
558
|
+
protected root: string = '';
|
|
559
|
+
protected artifactOptions: IArtifactOptions = {};
|
|
560
|
+
protected discoveredFiles: string[] = [];
|
|
561
|
+
protected loadedClasses: TClass<any>[] = [];
|
|
562
|
+
|
|
563
|
+
// Inherited from BaseHelper:
|
|
564
|
+
// scope: string
|
|
565
|
+
// identifier: string
|
|
566
|
+
// logger: Logger
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
| Property | Type | Initial Value | Description |
|
|
571
|
+
|---|---|---|---|
|
|
572
|
+
| `root` | `string` | `''` | Absolute path to the project root directory. Set from constructor `opts.root`. Glob patterns are resolved relative to this path. |
|
|
573
|
+
| `artifactOptions` | `IArtifactOptions` | `{}` | The merged configuration after `configure()` runs. Before `configure()`, holds whatever the user passed (or `{}`). After `configure()`, has all fields populated with defaults. |
|
|
574
|
+
| `discoveredFiles` | `string[]` | `[]` | Array of absolute file paths populated by `discover()`. Reset to `[]` at the start of each `discover()` call. |
|
|
575
|
+
| `loadedClasses` | `TClass<any>[]` | `[]` | Array of class constructors extracted from discovered files by `load()`. Reset to `[]` at the start of each `load()` call. |
|
|
576
|
+
|
|
577
|
+
### BaseArtifactBooter Constructor
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
constructor(opts: IBooterOptions) {
|
|
581
|
+
super({ scope: opts.scope });
|
|
582
|
+
|
|
583
|
+
this.artifactOptions = opts.artifactOptions;
|
|
584
|
+
this.root = opts.root;
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
The constructor receives an `IBooterOptions` object with three fields:
|
|
589
|
+
|
|
590
|
+
| Field | Type | Description |
|
|
591
|
+
|---|---|---|
|
|
592
|
+
| `scope` | `string` | Logger scope name, typically `BooterClassName.name` (e.g., `"ControllerBooter"`) |
|
|
593
|
+
| `root` | `string` | Absolute path to project root (injected as `@app/project_root`) |
|
|
594
|
+
| `artifactOptions` | `IArtifactOptions` | User-provided options from `bootOptions.controllers` (or whichever artifact key) |
|
|
595
|
+
|
|
596
|
+
The `super({ scope })` call initializes the `BaseHelper` which sets up scoped logging via `LoggerFactory.getLogger([scope])`.
|
|
597
|
+
|
|
598
|
+
### configure() -- Phase 1 Internals
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
async configure(): Promise<void> {
|
|
602
|
+
this.artifactOptions = {
|
|
603
|
+
dirs: this.artifactOptions?.dirs ?? this.getDefaultDirs(),
|
|
604
|
+
extensions: this.artifactOptions?.extensions ?? this.getDefaultExtensions(),
|
|
605
|
+
isNested: this.artifactOptions?.isNested ?? true,
|
|
606
|
+
glob: this.artifactOptions?.glob,
|
|
607
|
+
...this.artifactOptions,
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
this.logger.for(this.configure.name).debug(`Configured: %j`, this.artifactOptions);
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Merge behavior in detail:**
|
|
615
|
+
|
|
616
|
+
1. A new object is created with defaults computed from `getDefaultDirs()` and `getDefaultExtensions()`
|
|
617
|
+
2. The spread `...this.artifactOptions` at the end means any user-provided values override the defaults
|
|
618
|
+
3. If the user provided `dirs: ['custom']`, the default is computed as `['controllers']` but then overwritten by the spread
|
|
619
|
+
4. If the user provided nothing for `dirs`, `this.artifactOptions?.dirs` is `undefined`, so `getDefaultDirs()` is used
|
|
620
|
+
5. `isNested` defaults to `true` if not provided
|
|
621
|
+
6. `glob` is passed through as-is (no default)
|
|
622
|
+
|
|
623
|
+
**Important:** Because of the spread at the end, user options always win. This means if you pass `{ dirs: ['custom-controllers'], extensions: ['.ctrl.js'] }`, both fields override defaults.
|
|
624
|
+
|
|
625
|
+
### discover() -- Phase 2 Internals
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
async discover(): Promise<void> {
|
|
629
|
+
const pattern = this.getPattern();
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
this.discoveredFiles = []; // Reset discovered files
|
|
633
|
+
this.discoveredFiles = await discoverFiles({ root: this.root, pattern });
|
|
634
|
+
this.logger
|
|
635
|
+
.for(this.discover.name)
|
|
636
|
+
.debug(
|
|
637
|
+
`Root: %s | Using pattern: %s | Discovered file: %j`,
|
|
638
|
+
this.root,
|
|
639
|
+
pattern,
|
|
640
|
+
this.discoveredFiles,
|
|
641
|
+
);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
throw getError({
|
|
644
|
+
message: `[discover] Failed to discover files using pattern: ${pattern} | Error: ${(error as Error)?.message}`,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
**Key behaviors:**
|
|
651
|
+
|
|
652
|
+
1. `discoveredFiles` is reset to `[]` before each discovery run -- this means calling `discover()` multiple times replaces previous results
|
|
653
|
+
2. `getPattern()` is called to build the glob string (see next section)
|
|
654
|
+
3. `discoverFiles()` uses the `glob` npm package with `{ cwd: root, absolute: true }` options
|
|
655
|
+
4. Results are absolute file paths (e.g., `/app/dist/controllers/user.controller.js`)
|
|
656
|
+
5. If the glob pattern matches zero files, `discoveredFiles` is simply an empty array -- this is not an error
|
|
657
|
+
6. If the `glob` library itself throws (e.g., invalid pattern syntax), the error is caught and re-thrown with context
|
|
658
|
+
|
|
659
|
+
### load() -- Phase 3 Internals
|
|
660
|
+
|
|
661
|
+
```typescript
|
|
662
|
+
async load(): Promise<void> {
|
|
663
|
+
if (!this.discoveredFiles.length) {
|
|
664
|
+
this.logger.for(this.load.name).debug(`No files discovered to load.`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
this.loadedClasses = []; // Reset loaded classes
|
|
670
|
+
this.loadedClasses = await loadClasses({ files: this.discoveredFiles, root: this.root });
|
|
671
|
+
await this.bind();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
throw getError({
|
|
674
|
+
message: `[load] Failed to load classes from discovered files | Error: ${(error as Error)?.message}`,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
**Key behaviors:**
|
|
681
|
+
|
|
682
|
+
1. If `discoveredFiles` is empty, `load()` returns immediately with a debug log -- no error thrown
|
|
683
|
+
2. `loadedClasses` is reset to `[]` before each load
|
|
684
|
+
3. `loadClasses()` iterates each file, calls `await import(file)`, and checks every named export with `isClass()`
|
|
685
|
+
4. `isClass()` checks: `typeof target === 'function' && target.prototype !== undefined`
|
|
686
|
+
5. Arrow functions fail the `isClass` check because they have no `prototype` property
|
|
687
|
+
6. After loading classes, the abstract `bind()` method is called -- this is where subclasses register classes in the container
|
|
688
|
+
7. If any file import fails or `bind()` throws, the error is caught and re-thrown with context
|
|
689
|
+
|
|
690
|
+
### getPattern() -- Pattern Builder Logic
|
|
691
|
+
|
|
692
|
+
```typescript
|
|
693
|
+
protected getPattern(): string {
|
|
694
|
+
// Use custom glob if provided
|
|
695
|
+
if (this.artifactOptions.glob) {
|
|
696
|
+
return this.artifactOptions.glob;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!this.artifactOptions.dirs?.length) {
|
|
700
|
+
throw getError({
|
|
701
|
+
message: `[getPattern] No directories specified for artifact discovery`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!this.artifactOptions.extensions?.length) {
|
|
706
|
+
throw getError({
|
|
707
|
+
message: `[${this.scope}][getPattern] No file extensions specified for artifact discovery`,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const dirs = this.artifactOptions.dirs.join(',');
|
|
712
|
+
const exts = this.artifactOptions.extensions
|
|
713
|
+
.map(e => (e.startsWith('.') ? e.slice(1) : e))
|
|
714
|
+
.join(',');
|
|
715
|
+
|
|
716
|
+
const nested = this.artifactOptions.isNested ? '{**/*,*}' : '*';
|
|
717
|
+
|
|
718
|
+
if (this.artifactOptions.dirs.length > 1 || this.artifactOptions.extensions.length > 1) {
|
|
719
|
+
return `{${dirs}}/${nested}.{${exts}}`;
|
|
720
|
+
} else {
|
|
721
|
+
return `${dirs}/${nested}.${exts}`;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**Decision tree:**
|
|
727
|
+
|
|
728
|
+
1. If `glob` is set, return it immediately (no validation of dirs/extensions)
|
|
729
|
+
2. If no `dirs` provided, throw an error
|
|
730
|
+
3. If no `extensions` provided, throw an error
|
|
731
|
+
4. Strip leading dots from extensions: `.controller.js` becomes `controller.js`
|
|
732
|
+
5. If there is exactly one dir AND exactly one extension, use the simple format: `dir/nested.ext`
|
|
733
|
+
6. If there are multiple dirs OR multiple extensions, use brace expansion: `{dirs}/nested.{exts}`
|
|
734
|
+
|
|
735
|
+
**Why `{**/*,*}` for nested?** This glob alternation matches files in both subdirectories (`**/file`) and the root directory (`file`). Without the `,*` part, files directly in the target directory (not in a subdirectory) would not be matched.
|
|
736
|
+
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
## Pattern Generation Deep Dive
|
|
740
|
+
|
|
741
|
+
### Single Dir + Single Extension
|
|
742
|
+
|
|
743
|
+
**Input:**
|
|
744
|
+
```typescript
|
|
745
|
+
{ dirs: ['controllers'], extensions: ['.controller.js'], isNested: true }
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
**Processing:**
|
|
749
|
+
- `dirs.join(',')` => `"controllers"`
|
|
750
|
+
- Extensions: `.controller.js` => strip dot => `"controller.js"`
|
|
751
|
+
- `dirs.length === 1 && extensions.length === 1` => simple format
|
|
752
|
+
- `nested = '{**/*,*}'`
|
|
42
753
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
| **Three-Phase Boot** | Configure → Discover → Load lifecycle |
|
|
48
|
-
| **Customizable** | Configure directories, extensions, and glob patterns |
|
|
49
|
-
| **Extensible** | Create custom booters for new artifact types |
|
|
754
|
+
**Output:**
|
|
755
|
+
```
|
|
756
|
+
controllers/{**/*,*}.controller.js
|
|
757
|
+
```
|
|
50
758
|
|
|
51
|
-
|
|
759
|
+
**Matches:** `controllers/user.controller.js`, `controllers/admin/user.controller.js`, `controllers/a/b/c/deep.controller.js`
|
|
52
760
|
|
|
53
|
-
|
|
54
|
-
|--------|-------------------|-------------------|
|
|
55
|
-
| **ControllerBooter** | `controllers/` | `.controller.js` |
|
|
56
|
-
| **ServiceBooter** | `services/` | `.service.js` |
|
|
57
|
-
| **RepositoryBooter** | `repositories/` | `.repository.js` |
|
|
58
|
-
| **DatasourceBooter** | `datasources/` | `.datasource.js` |
|
|
761
|
+
**Does NOT match:** `controllers/user.service.js`, `other-dir/user.controller.js`
|
|
59
762
|
|
|
60
|
-
|
|
763
|
+
### Multiple Dirs + Multiple Extensions
|
|
764
|
+
|
|
765
|
+
**Input:**
|
|
766
|
+
```typescript
|
|
767
|
+
{ dirs: ['private-controllers', 'public-controllers'], extensions: ['.controller.js', '.ctrl.js'], isNested: true }
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**Processing:**
|
|
771
|
+
- `dirs.join(',')` => `"private-controllers,public-controllers"`
|
|
772
|
+
- Extensions: `.controller.js` => `controller.js`, `.ctrl.js` => `ctrl.js` => `"controller.js,ctrl.js"`
|
|
773
|
+
- `dirs.length === 2 || extensions.length === 2` => brace expansion format
|
|
774
|
+
|
|
775
|
+
**Output:**
|
|
776
|
+
```
|
|
777
|
+
{private-controllers,public-controllers}/{**/*,*}.{controller.js,ctrl.js}
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
**Matches:** `private-controllers/user.controller.js`, `public-controllers/admin/settings.ctrl.js`
|
|
781
|
+
|
|
782
|
+
### Multiple Dirs + Single Extension
|
|
783
|
+
|
|
784
|
+
**Input:**
|
|
785
|
+
```typescript
|
|
786
|
+
{ dirs: ['api', 'admin'], extensions: ['.controller.js'], isNested: true }
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
**Processing:**
|
|
790
|
+
- `dirs.length === 2` => brace expansion triggered
|
|
791
|
+
|
|
792
|
+
**Output:**
|
|
793
|
+
```
|
|
794
|
+
{api,admin}/{**/*,*}.controller.js
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### Single Dir + Multiple Extensions
|
|
798
|
+
|
|
799
|
+
**Input:**
|
|
800
|
+
```typescript
|
|
801
|
+
{ dirs: ['services'], extensions: ['.service.js', '.svc.js'], isNested: true }
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
**Processing:**
|
|
805
|
+
- `extensions.length === 2` => brace expansion triggered
|
|
806
|
+
|
|
807
|
+
**Output:**
|
|
808
|
+
```
|
|
809
|
+
{services}/{**/*,*}.{service.js,svc.js}
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
### Nested vs Non-Nested
|
|
813
|
+
|
|
814
|
+
**Nested (default, `isNested: true`):**
|
|
815
|
+
```typescript
|
|
816
|
+
{ dirs: ['repositories'], extensions: ['.repository.js'], isNested: true }
|
|
817
|
+
```
|
|
818
|
+
**Output:**
|
|
819
|
+
```
|
|
820
|
+
repositories/{**/*,*}.repository.js
|
|
821
|
+
```
|
|
822
|
+
The `{**/*,*}` alternation ensures files are matched at any depth, including the root of the directory.
|
|
823
|
+
|
|
824
|
+
**Non-nested (`isNested: false`):**
|
|
825
|
+
```typescript
|
|
826
|
+
{ dirs: ['repositories'], extensions: ['.repository.js'], isNested: false }
|
|
827
|
+
```
|
|
828
|
+
**Output:**
|
|
829
|
+
```
|
|
830
|
+
repositories/*.repository.js
|
|
831
|
+
```
|
|
832
|
+
Only matches files directly inside `repositories/` -- no subdirectory scanning.
|
|
833
|
+
|
|
834
|
+
### Custom Glob Override
|
|
835
|
+
|
|
836
|
+
**Input:**
|
|
837
|
+
```typescript
|
|
838
|
+
{ dirs: ['ignored'], extensions: ['.ignored.js'], glob: 'modules/**/handlers/*.handler.js' }
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
**Output:**
|
|
842
|
+
```
|
|
843
|
+
modules/**/handlers/*.handler.js
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
When `glob` is set, `dirs`, `extensions`, and `isNested` are all completely ignored. The custom glob is returned as-is.
|
|
847
|
+
|
|
848
|
+
### Dot Stripping from Extensions
|
|
849
|
+
|
|
850
|
+
The `getPattern()` method strips the leading dot from each extension before building the pattern:
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
const exts = this.artifactOptions.extensions
|
|
854
|
+
.map(e => (e.startsWith('.') ? e.slice(1) : e))
|
|
855
|
+
.join(',');
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
| Input Extension | After Stripping | In Pattern |
|
|
859
|
+
|---|---|---|
|
|
860
|
+
| `.controller.js` | `controller.js` | `*.controller.js` |
|
|
861
|
+
| `.service.js` | `service.js` | `*.service.js` |
|
|
862
|
+
| `handler.js` | `handler.js` (no dot to strip) | `*.handler.js` |
|
|
863
|
+
| `.my.custom.ext.js` | `my.custom.ext.js` | `*.my.custom.ext.js` |
|
|
864
|
+
|
|
865
|
+
This means files need a dot before the extension in their filename. For example, the pattern `*.controller.js` matches `user.controller.js` but does NOT match `usercontrollerjs` or `user-controller.js`.
|
|
866
|
+
|
|
867
|
+
### Pattern Summary Table
|
|
868
|
+
|
|
869
|
+
| dirs | extensions | isNested | glob | Generated Pattern |
|
|
870
|
+
|---|---|---|---|---|
|
|
871
|
+
| `['controllers']` | `['.controller.js']` | `true` | -- | `controllers/{**/*,*}.controller.js` |
|
|
872
|
+
| `['controllers']` | `['.controller.js']` | `false` | -- | `controllers/*.controller.js` |
|
|
873
|
+
| `['api', 'admin']` | `['.controller.js']` | `true` | -- | `{api,admin}/{**/*,*}.controller.js` |
|
|
874
|
+
| `['services']` | `['.service.js', '.svc.js']` | `true` | -- | `{services}/{**/*,*}.{service.js,svc.js}` |
|
|
875
|
+
| `['a', 'b']` | `['.x.js', '.y.js']` | `true` | -- | `{a,b}/{**/*,*}.{x.js,y.js}` |
|
|
876
|
+
| `['a', 'b']` | `['.x.js', '.y.js']` | `false` | -- | `{a,b}/*.{x.js,y.js}` |
|
|
877
|
+
| any | any | any | `'custom/**/*.js'` | `custom/**/*.js` |
|
|
878
|
+
|
|
879
|
+
---
|
|
880
|
+
|
|
881
|
+
## Built-in Booters Deep Dive
|
|
882
|
+
|
|
883
|
+
All four built-in booters share the same structure. They differ only in their default directories, default extensions, binding namespace, and binding scope. Each one receives three constructor-injected values:
|
|
884
|
+
|
|
885
|
+
| Injection Key | Type | Description |
|
|
886
|
+
|---|---|---|
|
|
887
|
+
| `@app/project_root` | `string` | Absolute path to the project's build output directory |
|
|
888
|
+
| `@app/instance` | `IApplication` | The application container instance (for binding classes) |
|
|
889
|
+
| `@app/boot-options` | `IBootOptions` | The user's boot options object |
|
|
890
|
+
|
|
891
|
+
### ControllerBooter
|
|
892
|
+
|
|
893
|
+
**Source:** `src/booters/controller.booter.ts`
|
|
894
|
+
|
|
895
|
+
```typescript
|
|
896
|
+
export class ControllerBooter extends BaseArtifactBooter {
|
|
897
|
+
constructor(
|
|
898
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
899
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
900
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
901
|
+
) {
|
|
902
|
+
super({ scope: ControllerBooter.name, root, artifactOptions: bootOptions.controllers ?? {} });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
protected override getDefaultDirs(): string[] {
|
|
906
|
+
return ['controllers'];
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
protected override getDefaultExtensions(): string[] {
|
|
910
|
+
return ['.controller.js'];
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
protected override async bind(): Promise<void> {
|
|
914
|
+
for (const cls of this.loadedClasses) {
|
|
915
|
+
const key = BindingKeys.build({ namespace: 'controllers', key: cls.name });
|
|
916
|
+
this.application.bind({ key }).toClass(cls).setTags('controllers');
|
|
917
|
+
this.logger.for(this.bind.name).debug('Bound key: %s', key);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
| Property | Value |
|
|
924
|
+
|---|---|
|
|
925
|
+
| Default dirs | `['controllers']` |
|
|
926
|
+
| Default extensions | `['.controller.js']` |
|
|
927
|
+
| Namespace | `controllers` |
|
|
928
|
+
| Binding key format | `controllers.{ClassName}` (e.g., `controllers.UserController`) |
|
|
929
|
+
| Scope | transient (default) |
|
|
930
|
+
| Tags | `controllers` |
|
|
931
|
+
|
|
932
|
+
### ServiceBooter
|
|
933
|
+
|
|
934
|
+
**Source:** `src/booters/service.booter.ts`
|
|
935
|
+
|
|
936
|
+
```typescript
|
|
937
|
+
export class ServiceBooter extends BaseArtifactBooter {
|
|
938
|
+
constructor(
|
|
939
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
940
|
+
@inject({ key: '@app/instance' }) protected application: IApplication,
|
|
941
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
942
|
+
) {
|
|
943
|
+
super({ scope: ServiceBooter.name, root, artifactOptions: bootOptions.services ?? {} });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
protected override getDefaultDirs(): string[] {
|
|
947
|
+
return ['services'];
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
protected override getDefaultExtensions(): string[] {
|
|
951
|
+
return ['.service.js'];
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
protected override async bind(): Promise<void> {
|
|
955
|
+
for (const cls of this.loadedClasses) {
|
|
956
|
+
const key = BindingKeys.build({ namespace: 'services', key: cls.name });
|
|
957
|
+
this.application.bind({ key }).toClass(cls).setTags('services');
|
|
958
|
+
this.logger.for(this.bind.name).debug('Bound key: %s', key);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
| Property | Value |
|
|
965
|
+
|---|---|
|
|
966
|
+
| Default dirs | `['services']` |
|
|
967
|
+
| Default extensions | `['.service.js']` |
|
|
968
|
+
| Namespace | `services` |
|
|
969
|
+
| Binding key format | `services.{ClassName}` (e.g., `services.AuthService`) |
|
|
970
|
+
| Scope | transient (default) |
|
|
971
|
+
| Tags | `services` |
|
|
972
|
+
|
|
973
|
+
### RepositoryBooter
|
|
974
|
+
|
|
975
|
+
**Source:** `src/booters/repository.booter.ts`
|
|
976
|
+
|
|
977
|
+
```typescript
|
|
978
|
+
export class RepositoryBooter extends BaseArtifactBooter {
|
|
979
|
+
constructor(
|
|
980
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
981
|
+
@inject({ key: '@app/instance' }) protected application: IApplication,
|
|
982
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
983
|
+
) {
|
|
984
|
+
super({ scope: RepositoryBooter.name, root, artifactOptions: bootOptions.repositories ?? {} });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
protected override getDefaultDirs(): string[] {
|
|
988
|
+
return ['repositories'];
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
protected override getDefaultExtensions(): string[] {
|
|
992
|
+
return ['.repository.js'];
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
protected override async bind(): Promise<void> {
|
|
996
|
+
for (const cls of this.loadedClasses) {
|
|
997
|
+
const key = BindingKeys.build({ namespace: 'repositories', key: cls.name });
|
|
998
|
+
this.application.bind({ key }).toClass(cls).setTags('repositories');
|
|
999
|
+
this.logger.for(this.bind.name).debug('Bound key: %s', key);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
| Property | Value |
|
|
1006
|
+
|---|---|
|
|
1007
|
+
| Default dirs | `['repositories']` |
|
|
1008
|
+
| Default extensions | `['.repository.js']` |
|
|
1009
|
+
| Namespace | `repositories` |
|
|
1010
|
+
| Binding key format | `repositories.{ClassName}` (e.g., `repositories.UserRepository`) |
|
|
1011
|
+
| Scope | transient (default) |
|
|
1012
|
+
| Tags | `repositories` |
|
|
1013
|
+
|
|
1014
|
+
### DatasourceBooter
|
|
1015
|
+
|
|
1016
|
+
**Source:** `src/booters/datasource.booter.ts`
|
|
1017
|
+
|
|
1018
|
+
```typescript
|
|
1019
|
+
export class DatasourceBooter extends BaseArtifactBooter {
|
|
1020
|
+
constructor(
|
|
1021
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
1022
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
1023
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
1024
|
+
) {
|
|
1025
|
+
super({ scope: DatasourceBooter.name, root, artifactOptions: bootOptions.datasources ?? {} });
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
protected override getDefaultDirs(): string[] {
|
|
1029
|
+
return ['datasources'];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
protected override getDefaultExtensions(): string[] {
|
|
1033
|
+
return ['.datasource.js'];
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
protected override async bind(): Promise<void> {
|
|
1037
|
+
for (const cls of this.loadedClasses) {
|
|
1038
|
+
const key = BindingKeys.build({ namespace: 'datasources', key: cls.name });
|
|
1039
|
+
this.application.bind({ key }).toClass(cls).setTags('datasources').setScope('singleton');
|
|
1040
|
+
this.logger.for(this.bind.name).debug('Bound key: %s', key);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
| Property | Value |
|
|
1047
|
+
|---|---|
|
|
1048
|
+
| Default dirs | `['datasources']` |
|
|
1049
|
+
| Default extensions | `['.datasource.js']` |
|
|
1050
|
+
| Namespace | `datasources` |
|
|
1051
|
+
| Binding key format | `datasources.{ClassName}` (e.g., `datasources.PostgresDataSource`) |
|
|
1052
|
+
| Scope | **singleton** |
|
|
1053
|
+
| Tags | `datasources` |
|
|
1054
|
+
|
|
1055
|
+
### Why Are Datasources Singletons?
|
|
1056
|
+
|
|
1057
|
+
The `DatasourceBooter` is the only built-in booter that sets `.setScope('singleton')` on its bindings. This is a critical design decision:
|
|
1058
|
+
|
|
1059
|
+
1. **Connection pooling.** A datasource typically creates a connection pool to the database (e.g., a `pg.Pool` with 10-20 connections). Creating a new pool per injection would exhaust database connections under load.
|
|
1060
|
+
|
|
1061
|
+
2. **Resource sharing.** Multiple repositories share the same datasource instance. If `UserRepository` and `OrderRepository` both reference `PostgresDataSource`, they share one connection pool rather than each managing their own.
|
|
1062
|
+
|
|
1063
|
+
3. **Schema discovery.** Datasources use `discoverSchema()` to collect all table schemas from their associated repositories. This discovery runs once during startup and caches the result. Multiple instances would repeat this work needlessly.
|
|
1064
|
+
|
|
1065
|
+
4. **Transaction coordination.** When two repositories participate in the same transaction, they must share the same underlying connection. Singleton datasources make this possible.
|
|
1066
|
+
|
|
1067
|
+
All other booters use transient scope because controllers, services, and repositories are lightweight wrappers with no expensive resources to share. They are instantiated fresh for each container resolution.
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
1071
|
+
## BootMixin Deep Dive
|
|
1072
|
+
|
|
1073
|
+
**Source:** `src/boot.mixin.ts`
|
|
1074
|
+
|
|
1075
|
+
The full source of `BootMixin`:
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
import {
|
|
1079
|
+
Bootstrapper,
|
|
1080
|
+
ControllerBooter,
|
|
1081
|
+
DatasourceBooter,
|
|
1082
|
+
IBootableApplication,
|
|
1083
|
+
IBootOptions,
|
|
1084
|
+
IBootReport,
|
|
1085
|
+
RepositoryBooter,
|
|
1086
|
+
ServiceBooter,
|
|
1087
|
+
} from '@venizia/ignis-boot';
|
|
1088
|
+
import { TMixinTarget } from '@venizia/ignis-helpers';
|
|
1089
|
+
import { BindingScopes, Container } from '@venizia/ignis-inversion';
|
|
1090
|
+
|
|
1091
|
+
export const BootMixin = <T extends TMixinTarget<Container>>(baseClass: T) => {
|
|
1092
|
+
class Mixed extends baseClass implements IBootableApplication {
|
|
1093
|
+
constructor(...args: any[]) {
|
|
1094
|
+
super(...args);
|
|
1095
|
+
|
|
1096
|
+
this.bind({ key: `@app/boot-options` }).toValue(this.bootOptions ?? {});
|
|
1097
|
+
|
|
1098
|
+
this.bind({ key: 'booter.DatasourceBooter' }).toClass(DatasourceBooter).setTags('booter');
|
|
1099
|
+
this.bind({ key: 'booter.RepositoryBooter' }).toClass(RepositoryBooter).setTags('booter');
|
|
1100
|
+
this.bind({ key: 'booter.ServiceBooter' }).toClass(ServiceBooter).setTags('booter');
|
|
1101
|
+
this.bind({ key: 'booter.ControllerBooter' }).toClass(ControllerBooter).setTags('booter');
|
|
1102
|
+
|
|
1103
|
+
this.bind({ key: 'bootstrapper' }).toClass(Bootstrapper).setScope(BindingScopes.SINGLETON);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
bootOptions?: IBootOptions | undefined;
|
|
1107
|
+
|
|
1108
|
+
boot(): Promise<IBootReport> {
|
|
1109
|
+
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
|
|
1110
|
+
return bootstrapper.boot({});
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return Mixed;
|
|
1115
|
+
};
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
### Constructor Internals
|
|
1119
|
+
|
|
1120
|
+
When the `BootMixin` constructor runs, it performs exactly 6 binding registrations in this order:
|
|
1121
|
+
|
|
1122
|
+
**1. Boot options binding:**
|
|
1123
|
+
```typescript
|
|
1124
|
+
this.bind({ key: '@app/boot-options' }).toValue(this.bootOptions ?? {});
|
|
1125
|
+
```
|
|
1126
|
+
Binds the user's `bootOptions` object (or `{}` if undefined) as a plain value. This is injected into every booter's constructor.
|
|
1127
|
+
|
|
1128
|
+
**2-5. Booter class registrations (in dependency order):**
|
|
1129
|
+
```typescript
|
|
1130
|
+
this.bind({ key: 'booter.DatasourceBooter' }).toClass(DatasourceBooter).setTags('booter');
|
|
1131
|
+
this.bind({ key: 'booter.RepositoryBooter' }).toClass(RepositoryBooter).setTags('booter');
|
|
1132
|
+
this.bind({ key: 'booter.ServiceBooter' }).toClass(ServiceBooter).setTags('booter');
|
|
1133
|
+
this.bind({ key: 'booter.ControllerBooter' }).toClass(ControllerBooter).setTags('booter');
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
Each booter is:
|
|
1137
|
+
- Bound as a **class** (not an instance) -- the container instantiates it on resolution with constructor injection
|
|
1138
|
+
- Tagged with `'booter'` -- this is how the `Bootstrapper` discovers them via `findByTag({ tag: 'booter' })`
|
|
1139
|
+
- Registered in **dependency order** -- datasources before repositories before services before controllers
|
|
1140
|
+
|
|
1141
|
+
The registration order determines execution order within each phase. This ensures that during the LOAD phase, datasources are bound to the container before repositories try to resolve them.
|
|
1142
|
+
|
|
1143
|
+
**6. Bootstrapper singleton:**
|
|
1144
|
+
```typescript
|
|
1145
|
+
this.bind({ key: 'bootstrapper' }).toClass(Bootstrapper).setScope(BindingScopes.SINGLETON);
|
|
1146
|
+
```
|
|
1147
|
+
The `Bootstrapper` is a singleton because it maintains internal state (the booter list, phase timings). Multiple `boot()` calls resolve the same `Bootstrapper` instance.
|
|
1148
|
+
|
|
1149
|
+
### boot() Method
|
|
1150
|
+
|
|
1151
|
+
```typescript
|
|
1152
|
+
boot(): Promise<IBootReport> {
|
|
1153
|
+
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
|
|
1154
|
+
return bootstrapper.boot({});
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
The `boot()` method:
|
|
1159
|
+
1. Resolves the `Bootstrapper` singleton from the container (which triggers `@inject({ key: '@app/instance' })` constructor injection)
|
|
1160
|
+
2. Calls `bootstrapper.boot({})` with an empty options object, which means all phases run on all booters
|
|
1161
|
+
3. Returns the `IBootReport` promise
|
|
1162
|
+
|
|
1163
|
+
---
|
|
1164
|
+
|
|
1165
|
+
## Bootstrapper Internals
|
|
1166
|
+
|
|
1167
|
+
**Source:** `src/bootstrapper.ts`
|
|
1168
|
+
|
|
1169
|
+
### Full Source
|
|
1170
|
+
|
|
1171
|
+
```typescript
|
|
1172
|
+
export class Bootstrapper extends BaseHelper implements IBootstrapper {
|
|
1173
|
+
private booters: IBooter[] = [];
|
|
1174
|
+
private phaseStartTimings: Map<string, number> = new Map();
|
|
1175
|
+
private phaseEndTimings: Map<string, number> = new Map();
|
|
1176
|
+
|
|
1177
|
+
constructor(@inject({ key: '@app/instance' }) private readonly application: IApplication) {
|
|
1178
|
+
super({ scope: Bootstrapper.name });
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async boot(opts: IBootExecutionOptions): Promise<IBootReport> {
|
|
1182
|
+
const { phases = BOOT_PHASES, booters } = opts;
|
|
1183
|
+
|
|
1184
|
+
await this.discoverBooters();
|
|
1185
|
+
this.logger
|
|
1186
|
+
.for(this.boot.name)
|
|
1187
|
+
.debug(`Starting boot | Number of booters: %d`, this.booters.length);
|
|
1188
|
+
for (const phase of phases) {
|
|
1189
|
+
await this.runPhase({ phase, booterNames: booters });
|
|
1190
|
+
}
|
|
1191
|
+
return this.generateReport();
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ...
|
|
1195
|
+
}
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
### discoverBooters()
|
|
1199
|
+
|
|
1200
|
+
```typescript
|
|
1201
|
+
private async discoverBooters(): Promise<void> {
|
|
1202
|
+
const booterBindings = this.application.findByTag<IBooter>({ tag: 'booter' });
|
|
1203
|
+
|
|
1204
|
+
for (const binding of booterBindings) {
|
|
1205
|
+
this.booters.push(binding.getValue(this.application));
|
|
1206
|
+
this.logger.for(this.discoverBooters.name).debug(`Discovered booter: %s`, binding.key);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
This method:
|
|
1212
|
+
1. Calls `this.application.findByTag({ tag: 'booter' })` which returns all `Binding` objects that have the `'booter'` tag
|
|
1213
|
+
2. For each binding, calls `binding.getValue(this.application)` which:
|
|
1214
|
+
- Instantiates the booter class (since booters are bound via `.toClass()`)
|
|
1215
|
+
- Performs constructor injection, resolving `@app/project_root`, `@app/instance`, and `@app/boot-options` from the container
|
|
1216
|
+
3. Pushes each instantiated booter into the `this.booters` array
|
|
1217
|
+
4. Logs the binding key for each discovered booter
|
|
1218
|
+
|
|
1219
|
+
The order of `booterBindings` matches the order they were registered in the container. Since `BootMixin` registers them in the order Datasource -> Repository -> Service -> Controller, that is the execution order.
|
|
1220
|
+
|
|
1221
|
+
### runPhase()
|
|
1222
|
+
|
|
1223
|
+
```typescript
|
|
1224
|
+
private async runPhase(opts: { phase: TBootPhase; booterNames?: string[] }): Promise<void> {
|
|
1225
|
+
const { phase } = opts;
|
|
1226
|
+
this.phaseStartTimings.set(phase, performance.now());
|
|
1227
|
+
this.logger.for(this.runPhase.name).debug(`Starting phase: %s`, phase.toUpperCase());
|
|
1228
|
+
|
|
1229
|
+
for (const booter of this.booters) {
|
|
1230
|
+
const phaseMethod = booter[phase];
|
|
1231
|
+
if (!phaseMethod) {
|
|
1232
|
+
this.logger
|
|
1233
|
+
.for(this.runPhase.name)
|
|
1234
|
+
.debug(
|
|
1235
|
+
`SKIP not implemented booter | Phase: %s | Booter: %s`,
|
|
1236
|
+
phase,
|
|
1237
|
+
booter.constructor.name,
|
|
1238
|
+
);
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (typeof phaseMethod !== 'function') {
|
|
1242
|
+
this.logger
|
|
1243
|
+
.for(this.runPhase.name)
|
|
1244
|
+
.debug(
|
|
1245
|
+
`SKIP not a function booter | Phase: %s | Booter: %s`,
|
|
1246
|
+
phase,
|
|
1247
|
+
booter.constructor.name,
|
|
1248
|
+
);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
this.logger
|
|
1254
|
+
.for(this.runPhase.name)
|
|
1255
|
+
.debug(`Running | Phase: %s | Booter: %s`, phase, booter.constructor.name);
|
|
1256
|
+
await phaseMethod.call(booter);
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
const errorMessage = (error as Error)?.message || String(error);
|
|
1259
|
+
|
|
1260
|
+
throw getError({
|
|
1261
|
+
message: `[Bootstrapper][runPhase] Error during phase '${phase}' on booter '${booter.constructor.name}': ${errorMessage}`,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
this.phaseEndTimings.set(phase, performance.now());
|
|
1267
|
+
const start = this.phaseStartTimings.get(phase) ?? 0;
|
|
1268
|
+
const end = this.phaseEndTimings.get(phase) ?? 0;
|
|
1269
|
+
const duration = end - start;
|
|
1270
|
+
|
|
1271
|
+
this.logger
|
|
1272
|
+
.for(this.runPhase.name)
|
|
1273
|
+
.debug(`Completed phase: %s | Took: %d ms`, phase.toUpperCase(), duration);
|
|
1274
|
+
}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
**Key behaviors:**
|
|
1278
|
+
|
|
1279
|
+
1. **Phase method lookup:** For each booter, accesses `booter[phase]` (e.g., `booter['configure']`). This is a dynamic property lookup using the phase string as a key.
|
|
1280
|
+
|
|
1281
|
+
2. **Guard checks:** Two guards skip booters that do not implement the phase:
|
|
1282
|
+
- If `phaseMethod` is falsy (undefined/null), the booter is skipped with a debug log
|
|
1283
|
+
- If `phaseMethod` is not a function, the booter is skipped with a debug log
|
|
1284
|
+
- This allows custom booters to omit phases they do not need
|
|
1285
|
+
|
|
1286
|
+
3. **Method invocation:** Uses `phaseMethod.call(booter)` to ensure the correct `this` context when calling the method.
|
|
1287
|
+
|
|
1288
|
+
4. **Sequential execution:** Booters within a phase are executed sequentially (not in parallel). This guarantees ordering -- datasource bindings are available before repository loading begins.
|
|
1289
|
+
|
|
1290
|
+
5. **booterNames filtering:** The `opts.booterNames` parameter is accepted but not yet implemented (marked with a TODO comment). Future versions will allow running specific booters by name.
|
|
1291
|
+
|
|
1292
|
+
### Error Wrapping
|
|
1293
|
+
|
|
1294
|
+
When a phase method throws an error, the `Bootstrapper` catches it and wraps it with context:
|
|
1295
|
+
|
|
1296
|
+
```typescript
|
|
1297
|
+
throw getError({
|
|
1298
|
+
message: `[Bootstrapper][runPhase] Error during phase '${phase}' on booter '${booter.constructor.name}': ${errorMessage}`,
|
|
1299
|
+
});
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
Example error message:
|
|
1303
|
+
```
|
|
1304
|
+
[Bootstrapper][runPhase] Error during phase 'discover' on booter 'ControllerBooter': [discover] Failed to discover files using pattern: controllers/{**/*,*}.controller.js | Error: ENOENT: no such file or directory
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
This multi-layer error wrapping makes it clear:
|
|
1308
|
+
1. Which component threw (`Bootstrapper`)
|
|
1309
|
+
2. Which phase was running (`discover`)
|
|
1310
|
+
3. Which booter failed (`ControllerBooter`)
|
|
1311
|
+
4. What the original error was
|
|
1312
|
+
|
|
1313
|
+
### Performance Timing
|
|
1314
|
+
|
|
1315
|
+
The `Bootstrapper` uses two `Map<string, number>` instances to track timing:
|
|
1316
|
+
|
|
1317
|
+
```typescript
|
|
1318
|
+
private phaseStartTimings: Map<string, number> = new Map();
|
|
1319
|
+
private phaseEndTimings: Map<string, number> = new Map();
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
At the start of each phase, `performance.now()` is recorded. At the end, another timestamp is captured and the duration is logged:
|
|
1323
|
+
|
|
1324
|
+
```
|
|
1325
|
+
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
|
|
1326
|
+
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
|
|
1327
|
+
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
These timings are currently logged but not included in the `IBootReport`. The data is available in the `Bootstrapper` instance for future use.
|
|
1331
|
+
|
|
1332
|
+
### IBootReport Structure
|
|
1333
|
+
|
|
1334
|
+
```typescript
|
|
1335
|
+
export interface IBootReport {}
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
The current `IBootReport` interface is intentionally empty. The `generateReport()` method returns `{}`:
|
|
1339
|
+
|
|
1340
|
+
```typescript
|
|
1341
|
+
private generateReport(): IBootReport {
|
|
1342
|
+
const report: IBootReport = {};
|
|
1343
|
+
this.logger.for(this.generateReport.name).debug(`Boot report: %j`, report);
|
|
1344
|
+
return report;
|
|
1345
|
+
}
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
This is a deliberate extension point. Future versions can add fields like:
|
|
1349
|
+
|
|
1350
|
+
```typescript
|
|
1351
|
+
// Potential future shape (not yet implemented)
|
|
1352
|
+
interface IBootReport {
|
|
1353
|
+
phases?: {
|
|
1354
|
+
configure?: { startTime: number; endTime: number; duration: number };
|
|
1355
|
+
discover?: { startTime: number; endTime: number; duration: number };
|
|
1356
|
+
load?: { startTime: number; endTime: number; duration: number };
|
|
1357
|
+
};
|
|
1358
|
+
booters?: Array<{
|
|
1359
|
+
name: string;
|
|
1360
|
+
discoveredFiles: number;
|
|
1361
|
+
loadedClasses: number;
|
|
1362
|
+
}>;
|
|
1363
|
+
totalDuration?: number;
|
|
1364
|
+
}
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
---
|
|
1368
|
+
|
|
1369
|
+
## Advanced Usage
|
|
1370
|
+
|
|
1371
|
+
### Creating Custom Booters
|
|
1372
|
+
|
|
1373
|
+
To support a new artifact type, extend `BaseArtifactBooter` and implement the three abstract methods:
|
|
1374
|
+
|
|
1375
|
+
```typescript
|
|
1376
|
+
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
|
|
1377
|
+
import { BindingKeys, inject } from '@venizia/ignis-inversion';
|
|
1378
|
+
|
|
1379
|
+
export class HandlerBooter extends BaseArtifactBooter {
|
|
1380
|
+
constructor(
|
|
1381
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
1382
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
1383
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
1384
|
+
) {
|
|
1385
|
+
super({
|
|
1386
|
+
scope: HandlerBooter.name,
|
|
1387
|
+
root,
|
|
1388
|
+
artifactOptions: bootOptions['handlers'] ?? {},
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
protected override getDefaultDirs(): string[] {
|
|
1393
|
+
return ['handlers'];
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
protected override getDefaultExtensions(): string[] {
|
|
1397
|
+
return ['.handler.js'];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
protected override async bind(): Promise<void> {
|
|
1401
|
+
for (const cls of this.loadedClasses) {
|
|
1402
|
+
const key = BindingKeys.build({ namespace: 'handlers', key: cls.name });
|
|
1403
|
+
this.application.bind({ key }).toClass(cls).setTags('handlers');
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
**Register the custom booter** in your application before calling `boot()`:
|
|
1410
|
+
|
|
1411
|
+
```typescript
|
|
1412
|
+
class MyApp extends BootMixin(Container) {
|
|
1413
|
+
bootOptions: IBootOptions = {
|
|
1414
|
+
controllers: { dirs: ['controllers'] },
|
|
1415
|
+
handlers: { dirs: ['handlers'], isNested: true },
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
constructor() {
|
|
1419
|
+
super();
|
|
1420
|
+
|
|
1421
|
+
// Register custom booter with the 'booter' tag so Bootstrapper discovers it
|
|
1422
|
+
this.bind({ key: 'booter.HandlerBooter' })
|
|
1423
|
+
.toClass(HandlerBooter)
|
|
1424
|
+
.setTags('booter');
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
The `'booter'` tag is critical -- the `Bootstrapper` uses `findByTag({ tag: 'booter' })` to discover all booters at runtime.
|
|
1430
|
+
|
|
1431
|
+
### Multiple Custom Booter Examples
|
|
1432
|
+
|
|
1433
|
+
#### Example 1: MiddlewareBooter -- Auto-discover Middleware
|
|
1434
|
+
|
|
1435
|
+
Discover and register Hono middleware files automatically:
|
|
1436
|
+
|
|
1437
|
+
```typescript
|
|
1438
|
+
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
|
|
1439
|
+
import { BindingKeys, inject } from '@venizia/ignis-inversion';
|
|
1440
|
+
|
|
1441
|
+
export class MiddlewareBooter extends BaseArtifactBooter {
|
|
1442
|
+
constructor(
|
|
1443
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
1444
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
1445
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
1446
|
+
) {
|
|
1447
|
+
super({
|
|
1448
|
+
scope: MiddlewareBooter.name,
|
|
1449
|
+
root,
|
|
1450
|
+
artifactOptions: bootOptions['middlewares'] ?? {},
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
protected override getDefaultDirs(): string[] {
|
|
1455
|
+
return ['middlewares'];
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
protected override getDefaultExtensions(): string[] {
|
|
1459
|
+
return ['.middleware.js'];
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
protected override async bind(): Promise<void> {
|
|
1463
|
+
for (const cls of this.loadedClasses) {
|
|
1464
|
+
const key = BindingKeys.build({ namespace: 'middlewares', key: cls.name });
|
|
1465
|
+
// Middlewares are singletons -- they are stateless functions typically
|
|
1466
|
+
this.application.bind({ key }).toClass(cls).setTags('middlewares').setScope('singleton');
|
|
1467
|
+
this.logger.for(this.bind.name).debug('Bound middleware: %s', key);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
```
|
|
1472
|
+
|
|
1473
|
+
**Registration:**
|
|
1474
|
+
```typescript
|
|
1475
|
+
class MyApp extends BootMixin(Container) {
|
|
1476
|
+
bootOptions: IBootOptions = {
|
|
1477
|
+
middlewares: { dirs: ['middlewares'], isNested: false },
|
|
1478
|
+
controllers: { dirs: ['controllers'] },
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
constructor() {
|
|
1482
|
+
super();
|
|
1483
|
+
this.bind({ key: 'booter.MiddlewareBooter' }).toClass(MiddlewareBooter).setTags('booter');
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
#### Example 2: MigrationBooter -- Auto-discover Migration Files
|
|
1489
|
+
|
|
1490
|
+
Discover Drizzle migration files for programmatic migration execution:
|
|
1491
|
+
|
|
1492
|
+
```typescript
|
|
1493
|
+
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
|
|
1494
|
+
import { BindingKeys, inject } from '@venizia/ignis-inversion';
|
|
1495
|
+
|
|
1496
|
+
export class MigrationBooter extends BaseArtifactBooter {
|
|
1497
|
+
constructor(
|
|
1498
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
1499
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
1500
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
1501
|
+
) {
|
|
1502
|
+
super({
|
|
1503
|
+
scope: MigrationBooter.name,
|
|
1504
|
+
root,
|
|
1505
|
+
artifactOptions: bootOptions['migrations'] ?? {},
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
protected override getDefaultDirs(): string[] {
|
|
1510
|
+
return ['migrations'];
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
protected override getDefaultExtensions(): string[] {
|
|
1514
|
+
return ['.migration.js'];
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
protected override async bind(): Promise<void> {
|
|
1518
|
+
for (const cls of this.loadedClasses) {
|
|
1519
|
+
const key = BindingKeys.build({ namespace: 'migrations', key: cls.name });
|
|
1520
|
+
// Migrations are transient -- each resolution creates a fresh instance
|
|
1521
|
+
this.application.bind({ key }).toClass(cls).setTags('migrations');
|
|
1522
|
+
this.logger.for(this.bind.name).debug('Bound migration: %s', key);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
**Registration with custom extensions:**
|
|
1529
|
+
```typescript
|
|
1530
|
+
class MyApp extends BootMixin(Container) {
|
|
1531
|
+
bootOptions: IBootOptions = {
|
|
1532
|
+
controllers: { dirs: ['controllers'] },
|
|
1533
|
+
migrations: {
|
|
1534
|
+
dirs: ['migrations'],
|
|
1535
|
+
extensions: ['.migration.js'],
|
|
1536
|
+
isNested: false, // Migrations are usually flat
|
|
1537
|
+
},
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
constructor() {
|
|
1541
|
+
super();
|
|
1542
|
+
this.bind({ key: 'booter.MigrationBooter' }).toClass(MigrationBooter).setTags('booter');
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
```
|
|
1546
|
+
|
|
1547
|
+
#### Example 3: CronJobBooter -- Auto-discover Scheduled Tasks
|
|
1548
|
+
|
|
1549
|
+
```typescript
|
|
1550
|
+
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
|
|
1551
|
+
import { BindingKeys, inject } from '@venizia/ignis-inversion';
|
|
1552
|
+
|
|
1553
|
+
export class CronJobBooter extends BaseArtifactBooter {
|
|
1554
|
+
constructor(
|
|
1555
|
+
@inject({ key: '@app/project_root' }) root: string,
|
|
1556
|
+
@inject({ key: '@app/instance' }) private readonly application: IApplication,
|
|
1557
|
+
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
|
|
1558
|
+
) {
|
|
1559
|
+
super({
|
|
1560
|
+
scope: CronJobBooter.name,
|
|
1561
|
+
root,
|
|
1562
|
+
artifactOptions: bootOptions['crons'] ?? {},
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
protected override getDefaultDirs(): string[] {
|
|
1567
|
+
return ['crons'];
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
protected override getDefaultExtensions(): string[] {
|
|
1571
|
+
return ['.cron.js'];
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
protected override async bind(): Promise<void> {
|
|
1575
|
+
for (const cls of this.loadedClasses) {
|
|
1576
|
+
const key = BindingKeys.build({ namespace: 'crons', key: cls.name });
|
|
1577
|
+
// Cron jobs are singletons -- they maintain internal timer state
|
|
1578
|
+
this.application.bind({ key }).toClass(cls).setTags('crons').setScope('singleton');
|
|
1579
|
+
this.logger.for(this.bind.name).debug('Bound cron job: %s', key);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
```
|
|
1584
|
+
|
|
1585
|
+
### Custom Glob Patterns
|
|
1586
|
+
|
|
1587
|
+
When the default pattern generation does not fit your project structure, use the `glob` option to take full control:
|
|
1588
|
+
|
|
1589
|
+
```typescript
|
|
1590
|
+
const bootOptions: IBootOptions = {
|
|
1591
|
+
controllers: {
|
|
1592
|
+
// Scan multiple top-level directories with specific depth
|
|
1593
|
+
glob: '{api,admin}/**/controllers/*.controller.js',
|
|
1594
|
+
},
|
|
1595
|
+
services: {
|
|
1596
|
+
// Only scan a specific subdirectory
|
|
1597
|
+
glob: 'modules/**/services/*.service.js',
|
|
1598
|
+
},
|
|
1599
|
+
};
|
|
1600
|
+
```
|
|
1601
|
+
|
|
1602
|
+
When `glob` is set, `dirs`, `extensions`, and `isNested` are all ignored.
|
|
1603
|
+
|
|
1604
|
+
### Boot Options Override Examples
|
|
1605
|
+
|
|
1606
|
+
Each artifact type's options can be customized independently. Here are common override patterns:
|
|
1607
|
+
|
|
1608
|
+
#### Override directories only
|
|
1609
|
+
|
|
1610
|
+
```typescript
|
|
1611
|
+
const bootOptions: IBootOptions = {
|
|
1612
|
+
controllers: {
|
|
1613
|
+
dirs: ['api-controllers', 'admin-controllers'],
|
|
1614
|
+
// extensions and isNested use defaults
|
|
1615
|
+
},
|
|
1616
|
+
};
|
|
1617
|
+
```
|
|
1618
|
+
Generated pattern: `{api-controllers,admin-controllers}/{**/*,*}.controller.js`
|
|
1619
|
+
|
|
1620
|
+
#### Override extensions only
|
|
1621
|
+
|
|
1622
|
+
```typescript
|
|
1623
|
+
const bootOptions: IBootOptions = {
|
|
1624
|
+
services: {
|
|
1625
|
+
extensions: ['.service.js', '.provider.js'],
|
|
1626
|
+
// dirs uses default ['services'], isNested uses default true
|
|
1627
|
+
},
|
|
1628
|
+
};
|
|
1629
|
+
```
|
|
1630
|
+
Generated pattern: `{services}/{**/*,*}.{service.js,provider.js}`
|
|
1631
|
+
|
|
1632
|
+
#### Disable nested scanning
|
|
1633
|
+
|
|
1634
|
+
```typescript
|
|
1635
|
+
const bootOptions: IBootOptions = {
|
|
1636
|
+
repositories: {
|
|
1637
|
+
isNested: false,
|
|
1638
|
+
// Only discover repositories at the root level, no subdirectories
|
|
1639
|
+
},
|
|
1640
|
+
};
|
|
1641
|
+
```
|
|
1642
|
+
Generated pattern: `repositories/*.repository.js`
|
|
1643
|
+
|
|
1644
|
+
#### Full override with custom glob
|
|
1645
|
+
|
|
1646
|
+
```typescript
|
|
1647
|
+
const bootOptions: IBootOptions = {
|
|
1648
|
+
datasources: {
|
|
1649
|
+
glob: 'config/datasources/*.datasource.js',
|
|
1650
|
+
// dirs, extensions, isNested are all ignored when glob is set
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
```
|
|
1654
|
+
Generated pattern: `config/datasources/*.datasource.js`
|
|
1655
|
+
|
|
1656
|
+
#### Mixed overrides across artifact types
|
|
1657
|
+
|
|
1658
|
+
```typescript
|
|
1659
|
+
const bootOptions: IBootOptions = {
|
|
1660
|
+
controllers: {
|
|
1661
|
+
dirs: ['controllers'],
|
|
1662
|
+
isNested: true, // Deep scan for nested module controllers
|
|
1663
|
+
},
|
|
1664
|
+
services: {
|
|
1665
|
+
dirs: ['services', 'providers'],
|
|
1666
|
+
extensions: ['.service.js', '.provider.js'],
|
|
1667
|
+
},
|
|
1668
|
+
repositories: {
|
|
1669
|
+
isNested: false, // Flat repository directory
|
|
1670
|
+
},
|
|
1671
|
+
datasources: {
|
|
1672
|
+
dirs: ['datasources'],
|
|
1673
|
+
// Defaults are fine -- singleton scope is set by the DatasourceBooter
|
|
1674
|
+
},
|
|
1675
|
+
};
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
### Selective Phase Execution
|
|
1679
|
+
|
|
1680
|
+
The `Bootstrapper.boot()` method accepts an `IBootExecutionOptions` object that lets you run only specific phases:
|
|
1681
|
+
|
|
1682
|
+
```typescript
|
|
1683
|
+
const bootstrapper = app.get<Bootstrapper>({ key: 'bootstrapper' });
|
|
1684
|
+
|
|
1685
|
+
// Run only configure and discover (skip loading/binding)
|
|
1686
|
+
const report = await bootstrapper.boot({
|
|
1687
|
+
phases: ['configure', 'discover'],
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
// Run all phases (default)
|
|
1691
|
+
const fullReport = await bootstrapper.boot({});
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
This is useful for testing scenarios where you want to verify discovery results before loading, or for dry-run analysis of what would be discovered.
|
|
1695
|
+
|
|
1696
|
+
### Boot Report and Performance Timing
|
|
1697
|
+
|
|
1698
|
+
The `Bootstrapper` tracks `performance.now()` timestamps for the start and end of each phase. Phase timing is logged at debug level:
|
|
1699
|
+
|
|
1700
|
+
```
|
|
1701
|
+
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
|
|
1702
|
+
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
|
|
1703
|
+
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
|
|
1704
|
+
```
|
|
1705
|
+
|
|
1706
|
+
The `boot()` method returns an `IBootReport` object, which can be extended in future versions to include detailed timing and discovery statistics.
|
|
1707
|
+
|
|
1708
|
+
---
|
|
1709
|
+
|
|
1710
|
+
## Integration with BaseApplication
|
|
1711
|
+
|
|
1712
|
+
When using `@venizia/ignis` (the core framework), `BaseApplication` does not use `BootMixin` directly. Instead, it implements the same logic with additional framework-specific behavior:
|
|
1713
|
+
|
|
1714
|
+
```typescript
|
|
1715
|
+
// In BaseApplication (packages/core/src/base/applications/base.ts)
|
|
1716
|
+
|
|
1717
|
+
booter<Base extends IBooter, Args extends AnyObject = any>(
|
|
1718
|
+
ctor: TClass<Base>,
|
|
1719
|
+
opts?: TMixinOpts<Args>,
|
|
1720
|
+
): Binding<Base> {
|
|
1721
|
+
return this.bind<Base>({
|
|
1722
|
+
key: BindingKeys.build(
|
|
1723
|
+
opts?.binding ?? { namespace: BindingNamespaces.BOOTERS, key: ctor.name },
|
|
1724
|
+
),
|
|
1725
|
+
})
|
|
1726
|
+
.toClass(ctor)
|
|
1727
|
+
.setTags('booter');
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async registerBooters() {
|
|
1731
|
+
await executeWithPerformanceMeasure({
|
|
1732
|
+
logger: this.logger,
|
|
1733
|
+
scope: this.registerDataSources.name,
|
|
1734
|
+
description: 'Register application data sources',
|
|
1735
|
+
task: async () => {
|
|
1736
|
+
this.bind({ key: '@app/boot-options' }).toValue(this.configs.bootOptions ?? {});
|
|
1737
|
+
this.bind({ key: 'bootstrapper' }).toClass(Bootstrapper).setScope(BindingScopes.SINGLETON);
|
|
1738
|
+
|
|
1739
|
+
// Define default booters
|
|
1740
|
+
this.booter(DatasourceBooter);
|
|
1741
|
+
this.booter(RepositoryBooter);
|
|
1742
|
+
this.booter(ServiceBooter);
|
|
1743
|
+
this.booter(ControllerBooter);
|
|
1744
|
+
},
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async boot(): Promise<IBootReport> {
|
|
1749
|
+
await this.registerBooters();
|
|
1750
|
+
|
|
1751
|
+
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
|
|
1752
|
+
|
|
1753
|
+
return bootstrapper.boot({});
|
|
1754
|
+
}
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
**Key differences from BootMixin:**
|
|
1758
|
+
|
|
1759
|
+
1. **Namespace-aware binding keys:** `BaseApplication` uses `BindingNamespaces.BOOTERS` (which resolves to `"booters"`) as the namespace, producing keys like `booters.DatasourceBooter` instead of `booter.DatasourceBooter`. This is consistent with the core framework's namespace convention.
|
|
1760
|
+
|
|
1761
|
+
2. **Helper method:** The `booter()` method provides a shorthand for registering booters with the correct namespace and tag.
|
|
1762
|
+
|
|
1763
|
+
3. **Performance measurement:** `registerBooters()` is wrapped in `executeWithPerformanceMeasure()` which logs execution time.
|
|
1764
|
+
|
|
1765
|
+
4. **Deferred registration:** In `BaseApplication`, booter registration happens during the `initialize()` lifecycle, not in the constructor. This allows the application to complete its own setup before boot runs.
|
|
1766
|
+
|
|
1767
|
+
5. **Custom booter registration:** You can call `this.booter(MyCustomBooter)` in `preConfigure()` to register additional booters before boot runs:
|
|
1768
|
+
|
|
1769
|
+
```typescript
|
|
1770
|
+
class MyApplication extends BaseApplication {
|
|
1771
|
+
async preConfigure() {
|
|
1772
|
+
// Register custom booters alongside the defaults
|
|
1773
|
+
this.booter(HandlerBooter);
|
|
1774
|
+
this.booter(CronJobBooter);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
**Full application lifecycle with boot:**
|
|
1780
|
+
|
|
1781
|
+
```
|
|
1782
|
+
1. constructor() --> Bind app configs, project root, etc.
|
|
1783
|
+
2. initialize() --> Called by start()
|
|
1784
|
+
2a. registerDefaultMiddlewares()
|
|
1785
|
+
2b. staticConfigure()
|
|
1786
|
+
2c. preConfigure() --> User registers controllers/services/components
|
|
1787
|
+
2d. registerBooters() --> Registers booter bindings
|
|
1788
|
+
2e. boot() --> Discovers and loads all artifacts
|
|
1789
|
+
2f. registerDataSources() --> Configures datasources with discovered schemas
|
|
1790
|
+
2g. registerComponents() --> Initializes components
|
|
1791
|
+
2h. registerControllers() --> Mounts controller routes to Hono
|
|
1792
|
+
2i. postConfigure() --> User post-setup hooks
|
|
1793
|
+
2j. setupMiddlewares() --> User middleware registration
|
|
1794
|
+
3. start() --> Start HTTP server (Bun.serve or @hono/node-server)
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
---
|
|
1798
|
+
|
|
1799
|
+
## Error Scenarios
|
|
1800
|
+
|
|
1801
|
+
### No files found (empty discoveredFiles)
|
|
1802
|
+
|
|
1803
|
+
If a booter's glob pattern matches no files, `discoveredFiles` will be an empty array. This is **not an error**. The `load()` method handles it gracefully:
|
|
1804
|
+
|
|
1805
|
+
```typescript
|
|
1806
|
+
async load(): Promise<void> {
|
|
1807
|
+
if (!this.discoveredFiles.length) {
|
|
1808
|
+
this.logger.for(this.load.name).debug(`No files discovered to load.`);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
// ...
|
|
1812
|
+
}
|
|
1813
|
+
```
|
|
1814
|
+
|
|
1815
|
+
**Debug output:**
|
|
1816
|
+
```
|
|
1817
|
+
[ServiceBooter][discover] Root: /app/dist | Using pattern: services/{**/*,*}.service.js | Discovered file: []
|
|
1818
|
+
[ServiceBooter][load] No files discovered to load.
|
|
1819
|
+
```
|
|
1820
|
+
|
|
1821
|
+
This is common during early development when you may not yet have files for every artifact type.
|
|
1822
|
+
|
|
1823
|
+
### File has no class exports
|
|
1824
|
+
|
|
1825
|
+
If a discovered file exports only non-class values (arrow functions, constants, objects, primitives), `loadClasses()` will filter them all out. The `loadedClasses` array will be empty, and `bind()` will execute but do nothing (it iterates an empty array).
|
|
1826
|
+
|
|
1827
|
+
**Example file** (`non.repository.ts`):
|
|
1828
|
+
```typescript
|
|
1829
|
+
// LET THIS FILE BE EMPTY
|
|
1830
|
+
```
|
|
1831
|
+
|
|
1832
|
+
Or a file that exports only constants:
|
|
1833
|
+
```typescript
|
|
1834
|
+
export const CONFIG = { host: 'localhost' };
|
|
1835
|
+
export const helper = () => 'helper';
|
|
1836
|
+
```
|
|
1837
|
+
|
|
1838
|
+
Both result in zero loaded classes -- no error, no binding.
|
|
1839
|
+
|
|
1840
|
+
### Glob pattern matches wrong files
|
|
1841
|
+
|
|
1842
|
+
If your glob pattern is too broad (e.g., `**/*.js`), you may discover files that are not artifact classes. The `isClass()` check prevents non-class exports from being bound, but unexpected classes could still be registered.
|
|
1843
|
+
|
|
1844
|
+
**Prevention strategies:**
|
|
1845
|
+
- Use specific file extensions: `.controller.js`, `.service.js`
|
|
1846
|
+
- Use dedicated directories: `controllers/`, `services/`
|
|
1847
|
+
- Avoid overly broad custom globs
|
|
1848
|
+
- Test discovery results using selective phase execution (see [Selective Phase Execution](#selective-phase-execution))
|
|
1849
|
+
|
|
1850
|
+
### No directories specified
|
|
1851
|
+
|
|
1852
|
+
If `configure()` runs and neither the user options nor the defaults provide `dirs`, `getPattern()` will throw:
|
|
1853
|
+
|
|
1854
|
+
```
|
|
1855
|
+
Error: [getPattern] No directories specified for artifact discovery
|
|
1856
|
+
```
|
|
1857
|
+
|
|
1858
|
+
This only happens if a custom booter's `getDefaultDirs()` returns an empty array AND the user does not provide `dirs`.
|
|
1859
|
+
|
|
1860
|
+
### No extensions specified
|
|
1861
|
+
|
|
1862
|
+
If `configure()` runs and neither the user options nor the defaults provide `extensions`, `getPattern()` will throw:
|
|
1863
|
+
|
|
1864
|
+
```
|
|
1865
|
+
Error: [ControllerBooter][getPattern] No file extensions specified for artifact discovery
|
|
1866
|
+
```
|
|
1867
|
+
|
|
1868
|
+
This only happens if a custom booter's `getDefaultExtensions()` returns an empty array AND the user does not provide `extensions`.
|
|
1869
|
+
|
|
1870
|
+
### Phase execution fails
|
|
1871
|
+
|
|
1872
|
+
If any booter's phase method throws, the `Bootstrapper` catches the error, wraps it with context, and re-throws. This stops the entire boot process:
|
|
1873
|
+
|
|
1874
|
+
```
|
|
1875
|
+
Error: [Bootstrapper][runPhase] Error during phase 'load' on booter 'RepositoryBooter': [load] Failed to load classes from discovered files | Error: Cannot find module '/app/dist/repositories/broken.repository.js'
|
|
1876
|
+
```
|
|
1877
|
+
|
|
1878
|
+
The boot process does NOT continue to the next booter or the next phase after an error. If `RepositoryBooter` fails during LOAD, `ServiceBooter` and `ControllerBooter` will not execute their LOAD phases.
|
|
1879
|
+
|
|
1880
|
+
### File import fails
|
|
1881
|
+
|
|
1882
|
+
If a discovered file cannot be imported (syntax error, missing dependency, runtime error during module evaluation), `loadClasses()` throws:
|
|
1883
|
+
|
|
1884
|
+
```
|
|
1885
|
+
Error: Failed to load file: /app/dist/controllers/broken.controller.js | Error: SyntaxError: Unexpected token
|
|
1886
|
+
```
|
|
1887
|
+
|
|
1888
|
+
This error is then wrapped by `BaseArtifactBooter.load()` and then again by `Bootstrapper.runPhase()`.
|
|
1889
|
+
|
|
1890
|
+
---
|
|
1891
|
+
|
|
1892
|
+
## Debugging Boot
|
|
1893
|
+
|
|
1894
|
+
### Enable debug logging
|
|
1895
|
+
|
|
1896
|
+
The boot system uses `BaseHelper`'s logger (Winston-based) with `debug` level. To see all boot debug output, set the `DEBUG` environment variable or configure the logger level:
|
|
1897
|
+
|
|
1898
|
+
```bash
|
|
1899
|
+
# Enable debug logging
|
|
1900
|
+
DEBUG=* bun run server:dev
|
|
1901
|
+
|
|
1902
|
+
# Or set logger level in your app configuration
|
|
1903
|
+
APP_ENV_LOGGER_FORMAT=text
|
|
1904
|
+
```
|
|
1905
|
+
|
|
1906
|
+
### Debug output reference
|
|
1907
|
+
|
|
1908
|
+
With debug logging enabled, a typical boot produces this output:
|
|
1909
|
+
|
|
1910
|
+
```
|
|
1911
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.DatasourceBooter
|
|
1912
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.RepositoryBooter
|
|
1913
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.ServiceBooter
|
|
1914
|
+
[Bootstrapper][discoverBooters] Discovered booter: booter.ControllerBooter
|
|
1915
|
+
[Bootstrapper][boot] Starting boot | Number of booters: 4
|
|
1916
|
+
[Bootstrapper][runPhase] Starting phase: CONFIGURE
|
|
1917
|
+
[Bootstrapper][runPhase] Running | Phase: configure | Booter: DatasourceBooter
|
|
1918
|
+
[DatasourceBooter][configure] Configured: {"dirs":["datasources"],"extensions":[".datasource.js"],"isNested":true}
|
|
1919
|
+
[Bootstrapper][runPhase] Running | Phase: configure | Booter: RepositoryBooter
|
|
1920
|
+
[RepositoryBooter][configure] Configured: {"dirs":["repositories"],"extensions":[".repository.js"],"isNested":true}
|
|
1921
|
+
[Bootstrapper][runPhase] Running | Phase: configure | Booter: ServiceBooter
|
|
1922
|
+
[ServiceBooter][configure] Configured: {"dirs":["services"],"extensions":[".service.js"],"isNested":true}
|
|
1923
|
+
[Bootstrapper][runPhase] Running | Phase: configure | Booter: ControllerBooter
|
|
1924
|
+
[ControllerBooter][configure] Configured: {"dirs":["controllers"],"extensions":[".controller.js"],"isNested":true}
|
|
1925
|
+
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
|
|
1926
|
+
[Bootstrapper][runPhase] Starting phase: DISCOVER
|
|
1927
|
+
[Bootstrapper][runPhase] Running | Phase: discover | Booter: DatasourceBooter
|
|
1928
|
+
[DatasourceBooter][discover] Root: /app/dist | Using pattern: datasources/{**/*,*}.datasource.js | Discovered file: [...]
|
|
1929
|
+
[Bootstrapper][runPhase] Running | Phase: discover | Booter: RepositoryBooter
|
|
1930
|
+
[RepositoryBooter][discover] Root: /app/dist | Using pattern: repositories/{**/*,*}.repository.js | Discovered file: [...]
|
|
1931
|
+
...
|
|
1932
|
+
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
|
|
1933
|
+
[Bootstrapper][runPhase] Starting phase: LOAD
|
|
1934
|
+
...
|
|
1935
|
+
[DatasourceBooter][bind] Bound key: datasources.PostgresDataSource
|
|
1936
|
+
[RepositoryBooter][bind] Bound key: repositories.UserRepository
|
|
1937
|
+
...
|
|
1938
|
+
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
|
|
1939
|
+
[Bootstrapper][generateReport] Boot report: {}
|
|
1940
|
+
```
|
|
1941
|
+
|
|
1942
|
+
### Inspect boot internals programmatically
|
|
1943
|
+
|
|
1944
|
+
You can access the `Bootstrapper` directly to inspect state:
|
|
1945
|
+
|
|
1946
|
+
```typescript
|
|
1947
|
+
const bootstrapper = app.get<Bootstrapper>({ key: 'bootstrapper' });
|
|
1948
|
+
|
|
1949
|
+
// Run only configure + discover phases
|
|
1950
|
+
await bootstrapper.boot({ phases: ['configure', 'discover'] });
|
|
1951
|
+
|
|
1952
|
+
// Now inspect what was discovered before loading
|
|
1953
|
+
// (requires access to the booter instances via the container)
|
|
1954
|
+
const controllerBooter = app.get<ControllerBooter>({ key: 'booter.ControllerBooter' });
|
|
1955
|
+
console.log('Discovered controller files:', controllerBooter['discoveredFiles']);
|
|
1956
|
+
```
|
|
1957
|
+
|
|
1958
|
+
### Check discovered files and loaded classes
|
|
1959
|
+
|
|
1960
|
+
Using bracket notation (since properties are `protected`), you can inspect the internal state of any booter:
|
|
1961
|
+
|
|
1962
|
+
```typescript
|
|
1963
|
+
const repoBooter = app.get<RepositoryBooter>({ key: 'booter.RepositoryBooter' });
|
|
1964
|
+
|
|
1965
|
+
// After boot
|
|
1966
|
+
console.log('Options:', repoBooter['artifactOptions']);
|
|
1967
|
+
console.log('Discovered:', repoBooter['discoveredFiles']);
|
|
1968
|
+
console.log('Loaded:', repoBooter['loadedClasses'].map(cls => cls.name));
|
|
1969
|
+
```
|
|
1970
|
+
|
|
1971
|
+
### Verify container bindings after boot
|
|
1972
|
+
|
|
1973
|
+
```typescript
|
|
1974
|
+
// Check all registered controllers
|
|
1975
|
+
const controllerBindings = app.findByTag({ tag: 'controllers' });
|
|
1976
|
+
for (const binding of controllerBindings) {
|
|
1977
|
+
console.log('Controller:', binding.key);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Check all registered datasources
|
|
1981
|
+
const datasourceBindings = app.findByTag({ tag: 'datasources' });
|
|
1982
|
+
for (const binding of datasourceBindings) {
|
|
1983
|
+
console.log('Datasource:', binding.key, '| Scope:', binding.scope);
|
|
1984
|
+
}
|
|
1985
|
+
```
|
|
1986
|
+
|
|
1987
|
+
---
|
|
1988
|
+
|
|
1989
|
+
## Performance Tuning
|
|
1990
|
+
|
|
1991
|
+
### Limiting glob scope
|
|
1992
|
+
|
|
1993
|
+
For large codebases with many files, overly broad glob patterns can slow down the DISCOVER phase. Here are strategies to optimize:
|
|
1994
|
+
|
|
1995
|
+
**1. Disable nested scanning when not needed:**
|
|
1996
|
+
```typescript
|
|
1997
|
+
repositories: {
|
|
1998
|
+
isNested: false, // Only scan the top-level directory
|
|
1999
|
+
}
|
|
2000
|
+
```
|
|
2001
|
+
This changes the pattern from `repositories/{**/*,*}.repository.js` to `repositories/*.repository.js`, which is significantly faster for directories with deep structures.
|
|
2002
|
+
|
|
2003
|
+
**2. Use specific directory lists instead of nested scanning:**
|
|
2004
|
+
```typescript
|
|
2005
|
+
controllers: {
|
|
2006
|
+
dirs: ['controllers/api', 'controllers/admin'],
|
|
2007
|
+
isNested: false,
|
|
2008
|
+
// Targets specific subdirectories without recursive scanning
|
|
2009
|
+
}
|
|
2010
|
+
```
|
|
2011
|
+
|
|
2012
|
+
**3. Use custom glob patterns for surgical targeting:**
|
|
2013
|
+
```typescript
|
|
2014
|
+
controllers: {
|
|
2015
|
+
glob: 'controllers/*.controller.js',
|
|
2016
|
+
// Most precise -- only top-level controller files
|
|
2017
|
+
}
|
|
2018
|
+
```
|
|
2019
|
+
|
|
2020
|
+
### Reducing import overhead
|
|
2021
|
+
|
|
2022
|
+
The LOAD phase's cost comes from `await import(file)` calls. Each file import triggers module evaluation, which may cascade to importing dependencies. To reduce this:
|
|
2023
|
+
|
|
2024
|
+
**1. Keep artifact files lean.** Controller/service/repository files should export a single class. Avoid heavy initialization in module scope.
|
|
2025
|
+
|
|
2026
|
+
**2. Use barrel files carefully.** If a repository file re-exports from a model file that re-exports from a schema file, the import chain is longer. Keep imports direct.
|
|
2027
|
+
|
|
2028
|
+
**3. Avoid side effects in module scope.** Database connections, API calls, or file I/O in module-level code will run during `import()` and slow boot.
|
|
2029
|
+
|
|
2030
|
+
### Monitoring boot performance
|
|
2031
|
+
|
|
2032
|
+
Use the debug log output to identify slow phases:
|
|
2033
|
+
|
|
2034
|
+
```
|
|
2035
|
+
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms -- Fast
|
|
2036
|
+
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms -- Normal
|
|
2037
|
+
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 245.3 ms -- Potentially slow
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
If DISCOVER is slow, your glob patterns may be too broad. If LOAD is slow, your imported files may have heavy module-level initialization.
|
|
2041
|
+
|
|
2042
|
+
---
|
|
2043
|
+
|
|
2044
|
+
## File Naming Conventions
|
|
2045
|
+
|
|
2046
|
+
### Why `.controller.js` instead of `.controller.ts`?
|
|
2047
|
+
|
|
2048
|
+
The boot system discovers files in the **built output directory** (e.g., `dist/cjs/` or `dist/esm/`), not in the source directory. TypeScript source files (`.ts`) are compiled to JavaScript (`.js`) by `tsc` before the application runs. Therefore:
|
|
2049
|
+
|
|
2050
|
+
- **Source files:** `src/controllers/user.controller.ts`
|
|
2051
|
+
- **Built files:** `dist/cjs/controllers/user.controller.js`
|
|
2052
|
+
- **Boot discovers:** `dist/cjs/controllers/user.controller.js`
|
|
2053
|
+
|
|
2054
|
+
The default extensions use `.js` because that is what exists at runtime. If you are running TypeScript directly (e.g., via `bun run` which supports direct `.ts` execution), you would need to override the extensions:
|
|
2055
|
+
|
|
2056
|
+
```typescript
|
|
2057
|
+
controllers: {
|
|
2058
|
+
extensions: ['.controller.ts'],
|
|
2059
|
+
}
|
|
2060
|
+
```
|
|
2061
|
+
|
|
2062
|
+
However, the standard Ignis workflow uses compiled output, so `.js` is the correct default.
|
|
2063
|
+
|
|
2064
|
+
### Naming convention for artifact files
|
|
2065
|
+
|
|
2066
|
+
The convention follows the pattern: `{name}.{artifact-type}.{extension}`
|
|
2067
|
+
|
|
2068
|
+
| Artifact Type | Convention | Example |
|
|
2069
|
+
|---|---|---|
|
|
2070
|
+
| Controller | `{name}.controller.ts` | `user.controller.ts` |
|
|
2071
|
+
| Service | `{name}.service.ts` | `auth.service.ts` |
|
|
2072
|
+
| Repository | `{name}.repository.ts` | `user.repository.ts` |
|
|
2073
|
+
| DataSource | `{name}.datasource.ts` | `postgres.datasource.ts` |
|
|
2074
|
+
|
|
2075
|
+
These conventions are not enforced by the framework -- they are what the default extensions expect. You can use any naming scheme by overriding `extensions` or `glob` in your boot options.
|
|
2076
|
+
|
|
2077
|
+
### Directory conventions
|
|
2078
|
+
|
|
2079
|
+
The default directory names match the artifact types:
|
|
2080
|
+
|
|
2081
|
+
| Artifact Type | Default Directory |
|
|
2082
|
+
|---|---|
|
|
2083
|
+
| Controllers | `controllers/` |
|
|
2084
|
+
| Services | `services/` |
|
|
2085
|
+
| Repositories | `repositories/` |
|
|
2086
|
+
| DataSources | `datasources/` |
|
|
2087
|
+
|
|
2088
|
+
These are relative to the project root (the `@app/project_root` binding). In a typical Ignis application built with `tsc`, the project root is the `dist/cjs/` directory.
|
|
2089
|
+
|
|
2090
|
+
---
|
|
2091
|
+
|
|
2092
|
+
## Boot Utilities
|
|
2093
|
+
|
|
2094
|
+
The `@venizia/ignis-boot` package exports three utility functions used internally by the boot system. These are also available for direct use:
|
|
2095
|
+
|
|
2096
|
+
### `discoverFiles`
|
|
2097
|
+
|
|
2098
|
+
Discover files matching a glob pattern relative to a root directory. Returns absolute file paths.
|
|
2099
|
+
|
|
2100
|
+
```typescript
|
|
2101
|
+
import { discoverFiles } from '@venizia/ignis-boot';
|
|
2102
|
+
|
|
2103
|
+
const files = await discoverFiles({
|
|
2104
|
+
root: '/path/to/project/dist',
|
|
2105
|
+
pattern: 'controllers/{**/*,*}.controller.js',
|
|
2106
|
+
});
|
|
2107
|
+
// => ['/path/to/project/dist/controllers/user.controller.js', ...]
|
|
2108
|
+
```
|
|
2109
|
+
|
|
2110
|
+
**Full signature:**
|
|
2111
|
+
|
|
2112
|
+
```typescript
|
|
2113
|
+
const discoverFiles: (opts: {
|
|
2114
|
+
pattern: string;
|
|
2115
|
+
root: string;
|
|
2116
|
+
}) => Promise<string[]>;
|
|
2117
|
+
```
|
|
2118
|
+
|
|
2119
|
+
Internally uses the `glob` npm package with `{ cwd: root, absolute: true }` options. Throws a wrapped error if the glob operation fails.
|
|
2120
|
+
|
|
2121
|
+
### `loadClasses`
|
|
2122
|
+
|
|
2123
|
+
Dynamically import an array of files and extract all class constructor exports. Non-class exports (arrow functions, objects, primitives) are filtered out.
|
|
2124
|
+
|
|
2125
|
+
```typescript
|
|
2126
|
+
import { loadClasses } from '@venizia/ignis-boot';
|
|
2127
|
+
|
|
2128
|
+
const classes = await loadClasses({
|
|
2129
|
+
files: ['/abs/path/to/user.controller.js', '/abs/path/to/auth.controller.js'],
|
|
2130
|
+
root: '/abs/path/to',
|
|
2131
|
+
});
|
|
2132
|
+
// => [class UserController, class AuthController]
|
|
2133
|
+
```
|
|
2134
|
+
|
|
2135
|
+
**Full signature:**
|
|
2136
|
+
|
|
2137
|
+
```typescript
|
|
2138
|
+
const loadClasses: (opts: {
|
|
2139
|
+
files: string[];
|
|
2140
|
+
root: string;
|
|
2141
|
+
}) => Promise<AnyType[]>;
|
|
2142
|
+
```
|
|
2143
|
+
|
|
2144
|
+
For each file, calls `await import(file)` and iterates all named exports. Uses `isClass()` to filter. If any file import fails, throws a wrapped error with the file path.
|
|
2145
|
+
|
|
2146
|
+
### `isClass`
|
|
2147
|
+
|
|
2148
|
+
Type guard that checks whether a value is a class constructor (a function with a `prototype` property). Used internally by `loadClasses` to filter exports.
|
|
2149
|
+
|
|
2150
|
+
```typescript
|
|
2151
|
+
import { isClass } from '@venizia/ignis-boot';
|
|
2152
|
+
|
|
2153
|
+
class MyService {}
|
|
2154
|
+
const arrowFn = () => {};
|
|
2155
|
+
function RegularFunction() {}
|
|
2156
|
+
abstract class AbstractClass {}
|
|
2157
|
+
|
|
2158
|
+
isClass(MyService); // true
|
|
2159
|
+
isClass(RegularFunction); // true (has prototype)
|
|
2160
|
+
isClass(AbstractClass); // true
|
|
2161
|
+
isClass(arrowFn); // false (arrow functions have no prototype)
|
|
2162
|
+
isClass('string'); // false
|
|
2163
|
+
isClass(42); // false
|
|
2164
|
+
isClass(null); // false
|
|
2165
|
+
isClass(undefined); // false
|
|
2166
|
+
isClass({}); // false
|
|
2167
|
+
```
|
|
2168
|
+
|
|
2169
|
+
**Full signature:**
|
|
2170
|
+
|
|
2171
|
+
```typescript
|
|
2172
|
+
const isClass: <T>(target: AnyType) => target is TClass<T>;
|
|
2173
|
+
```
|
|
2174
|
+
|
|
2175
|
+
**Implementation:**
|
|
2176
|
+
```typescript
|
|
2177
|
+
export const isClass = <T>(target: AnyType): target is TClass<T> => {
|
|
2178
|
+
return typeof target === 'function' && target.prototype !== undefined;
|
|
2179
|
+
};
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
Note: Regular function declarations and function expressions return `true` because they have a `prototype` property. Only arrow functions return `false`. This is intentional -- traditional function constructors are valid class-like constructs in JavaScript.
|
|
2183
|
+
|
|
2184
|
+
---
|
|
2185
|
+
|
|
2186
|
+
## Complete Type Reference
|
|
2187
|
+
|
|
2188
|
+
All types are exported from `@venizia/ignis-boot`:
|
|
2189
|
+
|
|
2190
|
+
```typescript
|
|
2191
|
+
// =============================================================================
|
|
2192
|
+
// Artifact Configuration
|
|
2193
|
+
// =============================================================================
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* Configuration for a single artifact type's discovery behavior.
|
|
2197
|
+
*/
|
|
2198
|
+
interface IArtifactOptions {
|
|
2199
|
+
/** Directories to scan, relative to project root */
|
|
2200
|
+
dirs?: string[];
|
|
2201
|
+
/** File extensions to match (e.g., '.controller.js') */
|
|
2202
|
+
extensions?: string[];
|
|
2203
|
+
/** Recurse into subdirectories. Default: true */
|
|
2204
|
+
isNested?: boolean;
|
|
2205
|
+
/** Custom glob pattern. If set, dirs and extensions are ignored */
|
|
2206
|
+
glob?: string;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Top-level boot options mapping artifact type names to their discovery config.
|
|
2211
|
+
* Includes four built-in keys and supports arbitrary extension via index signature.
|
|
2212
|
+
*/
|
|
2213
|
+
interface IBootOptions {
|
|
2214
|
+
controllers?: IArtifactOptions;
|
|
2215
|
+
services?: IArtifactOptions;
|
|
2216
|
+
repositories?: IArtifactOptions;
|
|
2217
|
+
datasources?: IArtifactOptions;
|
|
2218
|
+
/** Extensible for custom booters */
|
|
2219
|
+
[artifactType: string]: IArtifactOptions | undefined;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// =============================================================================
|
|
2223
|
+
// Boot Phases
|
|
2224
|
+
// =============================================================================
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* The three boot phases, derived as const string values from BootPhases class.
|
|
2228
|
+
*/
|
|
2229
|
+
type TBootPhase = 'configure' | 'discover' | 'load';
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* Ordered array of all boot phases.
|
|
2233
|
+
* Used as the default value when no specific phases are requested.
|
|
2234
|
+
*/
|
|
2235
|
+
const BOOT_PHASES: TBootPhase[] = ['configure', 'discover', 'load'];
|
|
2236
|
+
|
|
2237
|
+
// =============================================================================
|
|
2238
|
+
// Booter Interfaces
|
|
2239
|
+
// =============================================================================
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* Interface that all booters must implement.
|
|
2243
|
+
* Each method corresponds to one boot phase.
|
|
2244
|
+
*/
|
|
2245
|
+
interface IBooter {
|
|
2246
|
+
configure(): ValueOrPromise<void>;
|
|
2247
|
+
discover(): ValueOrPromise<void>;
|
|
2248
|
+
load(): ValueOrPromise<void>;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Constructor options for BaseArtifactBooter.
|
|
2253
|
+
*/
|
|
2254
|
+
interface IBooterOptions {
|
|
2255
|
+
/** Logger scope name (typically the booter class name) */
|
|
2256
|
+
scope: string;
|
|
2257
|
+
/** Absolute path to project root directory */
|
|
2258
|
+
root: string;
|
|
2259
|
+
/** User-provided artifact discovery options */
|
|
2260
|
+
artifactOptions: IArtifactOptions;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// =============================================================================
|
|
2264
|
+
// Bootstrapper
|
|
2265
|
+
// =============================================================================
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Options for controlling which phases and booters to execute.
|
|
2269
|
+
*/
|
|
2270
|
+
interface IBootExecutionOptions {
|
|
2271
|
+
/** Phases to execute. Default: ['configure', 'discover', 'load'] */
|
|
2272
|
+
phases?: TBootPhase[];
|
|
2273
|
+
/** Specific booters to run by name. Default: all discovered booters */
|
|
2274
|
+
booters?: string[];
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* Interface for the Bootstrapper orchestrator.
|
|
2279
|
+
*/
|
|
2280
|
+
interface IBootstrapper {
|
|
2281
|
+
boot(opts: IBootExecutionOptions): Promise<IBootReport>;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Boot execution report. Currently empty; designed as an extension point
|
|
2286
|
+
* for future timing and statistics data.
|
|
2287
|
+
*/
|
|
2288
|
+
interface IBootReport {}
|
|
2289
|
+
|
|
2290
|
+
// =============================================================================
|
|
2291
|
+
// Application
|
|
2292
|
+
// =============================================================================
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Minimal application interface required by the boot system.
|
|
2296
|
+
* Extends Container with a method to get the project root path.
|
|
2297
|
+
*/
|
|
2298
|
+
interface IApplication extends Container {
|
|
2299
|
+
getProjectRoot(): string;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/**
|
|
2303
|
+
* Interface for applications that support the boot() method.
|
|
2304
|
+
* Implemented by BootMixin and BaseApplication.
|
|
2305
|
+
*/
|
|
2306
|
+
interface IBootableApplication {
|
|
2307
|
+
boot(): Promise<IBootReport>;
|
|
2308
|
+
}
|
|
2309
|
+
```
|
|
2310
|
+
|
|
2311
|
+
### Exported Classes
|
|
2312
|
+
|
|
2313
|
+
```typescript
|
|
2314
|
+
/**
|
|
2315
|
+
* Abstract base class for all artifact booters.
|
|
2316
|
+
* Implements the Template Method pattern for three-phase boot lifecycle.
|
|
2317
|
+
*
|
|
2318
|
+
* Protected properties:
|
|
2319
|
+
* root: string -- Project root path
|
|
2320
|
+
* artifactOptions: IArtifactOptions -- Merged discovery config
|
|
2321
|
+
* discoveredFiles: string[] -- Files found during discover phase
|
|
2322
|
+
* loadedClasses: TClass[] -- Classes extracted during load phase
|
|
2323
|
+
*
|
|
2324
|
+
* Abstract methods (must be implemented by subclasses):
|
|
2325
|
+
* getDefaultDirs(): string[]
|
|
2326
|
+
* getDefaultExtensions(): string[]
|
|
2327
|
+
* bind(): Promise<void>
|
|
2328
|
+
*/
|
|
2329
|
+
abstract class BaseArtifactBooter extends BaseHelper implements IBooter { }
|
|
2330
|
+
|
|
2331
|
+
/**
|
|
2332
|
+
* Orchestrates the boot process across all discovered booters.
|
|
2333
|
+
*
|
|
2334
|
+
* Private properties:
|
|
2335
|
+
* booters: IBooter[]
|
|
2336
|
+
* phaseStartTimings: Map<string, number>
|
|
2337
|
+
* phaseEndTimings: Map<string, number>
|
|
2338
|
+
*
|
|
2339
|
+
* Constructor injection:
|
|
2340
|
+
* @inject({ key: '@app/instance' }) application: IApplication
|
|
2341
|
+
*/
|
|
2342
|
+
class Bootstrapper extends BaseHelper implements IBootstrapper { }
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Built-in booter for controller artifacts.
|
|
2346
|
+
* Default dir: 'controllers', extension: '.controller.js', scope: transient
|
|
2347
|
+
*/
|
|
2348
|
+
class ControllerBooter extends BaseArtifactBooter { }
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* Built-in booter for service artifacts.
|
|
2352
|
+
* Default dir: 'services', extension: '.service.js', scope: transient
|
|
2353
|
+
*/
|
|
2354
|
+
class ServiceBooter extends BaseArtifactBooter { }
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Built-in booter for repository artifacts.
|
|
2358
|
+
* Default dir: 'repositories', extension: '.repository.js', scope: transient
|
|
2359
|
+
*/
|
|
2360
|
+
class RepositoryBooter extends BaseArtifactBooter { }
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Built-in booter for datasource artifacts.
|
|
2364
|
+
* Default dir: 'datasources', extension: '.datasource.js', scope: SINGLETON
|
|
2365
|
+
*/
|
|
2366
|
+
class DatasourceBooter extends BaseArtifactBooter { }
|
|
2367
|
+
```
|
|
2368
|
+
|
|
2369
|
+
### Exported Functions
|
|
2370
|
+
|
|
2371
|
+
```typescript
|
|
2372
|
+
/**
|
|
2373
|
+
* Mixin that enhances a Container subclass with boot capabilities.
|
|
2374
|
+
* Registers all built-in booters and the Bootstrapper in the constructor.
|
|
2375
|
+
* Adds bootOptions property and boot() method to the class.
|
|
2376
|
+
*/
|
|
2377
|
+
const BootMixin: <T extends TMixinTarget<Container>>(baseClass: T) => T & IBootableApplication;
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* Discover files matching a glob pattern relative to a root directory.
|
|
2381
|
+
* Returns absolute file paths. Uses the 'glob' npm package internally.
|
|
2382
|
+
*/
|
|
2383
|
+
const discoverFiles: (opts: { pattern: string; root: string }) => Promise<string[]>;
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* Dynamically import files and extract class constructor exports.
|
|
2387
|
+
* Non-class exports are filtered out using isClass().
|
|
2388
|
+
*/
|
|
2389
|
+
const loadClasses: (opts: { files: string[]; root: string }) => Promise<AnyType[]>;
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* Type guard: checks if a value is a class constructor
|
|
2393
|
+
* (a function with a prototype property).
|
|
2394
|
+
*/
|
|
2395
|
+
const isClass: <T>(target: AnyType) => target is TClass<T>;
|
|
2396
|
+
```
|
|
2397
|
+
|
|
2398
|
+
---
|
|
2399
|
+
|
|
2400
|
+
## Constants Reference
|
|
2401
|
+
|
|
2402
|
+
```typescript
|
|
2403
|
+
/**
|
|
2404
|
+
* Static class providing boot phase name constants.
|
|
2405
|
+
* Values match the TBootPhase union type.
|
|
2406
|
+
*/
|
|
2407
|
+
class BootPhases {
|
|
2408
|
+
static readonly CONFIGURE = 'configure';
|
|
2409
|
+
static readonly DISCOVER = 'discover';
|
|
2410
|
+
static readonly LOAD = 'load';
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* Ordered array of all boot phases.
|
|
2415
|
+
* Used as the default when Bootstrapper.boot() is called without specific phases.
|
|
2416
|
+
*/
|
|
2417
|
+
const BOOT_PHASES: TBootPhase[] = ['configure', 'discover', 'load'];
|
|
2418
|
+
```
|
|
2419
|
+
|
|
2420
|
+
### DI Binding Keys Used by the Boot System
|
|
2421
|
+
|
|
2422
|
+
| Key | Bound By | Type | Description |
|
|
2423
|
+
|---|---|---|---|
|
|
2424
|
+
| `@app/project_root` | Application | `string` | Absolute path to project's build output root |
|
|
2425
|
+
| `@app/instance` | Application | `IApplication` | The application container itself |
|
|
2426
|
+
| `@app/boot-options` | BootMixin / BaseApplication | `IBootOptions` | User's boot configuration |
|
|
2427
|
+
| `booter.DatasourceBooter` | BootMixin | `DatasourceBooter` class | Tagged `'booter'` |
|
|
2428
|
+
| `booter.RepositoryBooter` | BootMixin | `RepositoryBooter` class | Tagged `'booter'` |
|
|
2429
|
+
| `booter.ServiceBooter` | BootMixin | `ServiceBooter` class | Tagged `'booter'` |
|
|
2430
|
+
| `booter.ControllerBooter` | BootMixin | `ControllerBooter` class | Tagged `'booter'` |
|
|
2431
|
+
| `bootstrapper` | BootMixin / BaseApplication | `Bootstrapper` class | Singleton scope |
|
|
2432
|
+
|
|
2433
|
+
Note: When used with `BaseApplication`, the booter keys follow the pattern `booters.{ClassName}` (using the `BindingNamespaces.BOOTERS` namespace) instead of `booter.{ClassName}`.
|
|
2434
|
+
|
|
2435
|
+
---
|
|
2436
|
+
|
|
2437
|
+
## Testing Patterns
|
|
2438
|
+
|
|
2439
|
+
The boot package tests run on **built** output (`dist/cjs/`) since the boot system discovers compiled `.js` files, not `.ts` source files.
|
|
2440
|
+
|
|
2441
|
+
### Test fixture structure
|
|
2442
|
+
|
|
2443
|
+
```
|
|
2444
|
+
src/__tests__/fixtures/
|
|
2445
|
+
repositories/
|
|
2446
|
+
model1.repository.ts (exports Model1Repository class)
|
|
2447
|
+
model2.repository.ts (exports Model2Repository class)
|
|
2448
|
+
sub-repositories/
|
|
2449
|
+
model3.repository.ts (exports Model3Repository class)
|
|
2450
|
+
non-repositories/
|
|
2451
|
+
non.repository.ts (empty file -- no class exports)
|
|
2452
|
+
```
|
|
2453
|
+
|
|
2454
|
+
The `non-repositories/` directory contains a file with no class exports, used to verify that `loadClasses()` correctly filters non-class modules and returns an empty array.
|
|
2455
|
+
|
|
2456
|
+
### Testing a custom booter
|
|
2457
|
+
|
|
2458
|
+
Create a concrete test booter by extending `BaseArtifactBooter`:
|
|
2459
|
+
|
|
2460
|
+
```typescript
|
|
2461
|
+
import { BaseArtifactBooter } from '@venizia/ignis-boot';
|
|
2462
|
+
import { beforeAll, describe, expect, test } from 'bun:test';
|
|
2463
|
+
import path from 'node:path';
|
|
2464
|
+
|
|
2465
|
+
class TestBooter extends BaseArtifactBooter {
|
|
2466
|
+
protected override getDefaultDirs(): string[] {
|
|
2467
|
+
return ['repositories'];
|
|
2468
|
+
}
|
|
2469
|
+
protected override getDefaultExtensions(): string[] {
|
|
2470
|
+
return ['.repository.js'];
|
|
2471
|
+
}
|
|
2472
|
+
protected override bind(): Promise<void> {
|
|
2473
|
+
return Promise.resolve();
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
describe('TestBooter', () => {
|
|
2478
|
+
let booter: TestBooter;
|
|
2479
|
+
const root = path.resolve(process.cwd(), 'dist/cjs/__tests__/fixtures');
|
|
2480
|
+
|
|
2481
|
+
beforeAll(() => {
|
|
2482
|
+
booter = new TestBooter({ root, artifactOptions: {}, scope: TestBooter.name });
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
test('should use default options after configure', async () => {
|
|
2486
|
+
await booter.configure();
|
|
2487
|
+
expect(booter['artifactOptions'].dirs).toEqual(['repositories']);
|
|
2488
|
+
expect(booter['artifactOptions'].extensions).toEqual(['.repository.js']);
|
|
2489
|
+
expect(booter['artifactOptions'].isNested).toEqual(true);
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test('should discover files after discover', async () => {
|
|
2493
|
+
await booter.configure();
|
|
2494
|
+
await booter.discover();
|
|
2495
|
+
expect(booter['discoveredFiles'].length).toBeGreaterThan(0);
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
test('should load classes after load', async () => {
|
|
2499
|
+
await booter.configure();
|
|
2500
|
+
await booter.discover();
|
|
2501
|
+
await booter.load();
|
|
2502
|
+
expect(booter['loadedClasses'].length).toBeGreaterThan(0);
|
|
2503
|
+
});
|
|
2504
|
+
});
|
|
2505
|
+
```
|
|
2506
|
+
|
|
2507
|
+
### Testing pattern generation
|
|
2508
|
+
|
|
2509
|
+
```typescript
|
|
2510
|
+
test('should generate pattern with defaults', async () => {
|
|
2511
|
+
await booter.configure();
|
|
2512
|
+
const pattern = booter['getPattern']();
|
|
2513
|
+
expect(pattern).toBe('repositories/{**/*,*}.repository.js');
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
test('should generate pattern with multiple dirs and extensions', async () => {
|
|
2517
|
+
const multiBooter = new TestBooter({
|
|
2518
|
+
scope: TestBooter.name,
|
|
2519
|
+
root,
|
|
2520
|
+
artifactOptions: {
|
|
2521
|
+
dirs: ['dir1', 'dir2'],
|
|
2522
|
+
extensions: ['.ext1.js', '.ext2.js'],
|
|
2523
|
+
},
|
|
2524
|
+
});
|
|
2525
|
+
await multiBooter.configure();
|
|
2526
|
+
const pattern = multiBooter['getPattern']();
|
|
2527
|
+
expect(pattern).toBe('{dir1,dir2}/{**/*,*}.{ext1.js,ext2.js}');
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
test('should use custom glob if provided', async () => {
|
|
2531
|
+
const globBooter = new TestBooter({
|
|
2532
|
+
scope: TestBooter.name,
|
|
2533
|
+
root,
|
|
2534
|
+
artifactOptions: {
|
|
2535
|
+
glob: 'custom/glob/pattern/**/*.js',
|
|
2536
|
+
},
|
|
2537
|
+
});
|
|
2538
|
+
await globBooter.configure();
|
|
2539
|
+
const pattern = globBooter['getPattern']();
|
|
2540
|
+
expect(pattern).toBe('custom/glob/pattern/**/*.js');
|
|
2541
|
+
});
|
|
2542
|
+
```
|
|
2543
|
+
|
|
2544
|
+
### Testing utility functions
|
|
2545
|
+
|
|
2546
|
+
```typescript
|
|
2547
|
+
import { discoverFiles, isClass, loadClasses } from '@venizia/ignis-boot';
|
|
2548
|
+
|
|
2549
|
+
describe('isClass', () => {
|
|
2550
|
+
test('should return true for class constructors', () => {
|
|
2551
|
+
class TestClass {}
|
|
2552
|
+
abstract class AbstractClass {}
|
|
2553
|
+
function FunctionClass() {}
|
|
2554
|
+
|
|
2555
|
+
expect(isClass(TestClass)).toBe(true);
|
|
2556
|
+
expect(isClass(AbstractClass)).toBe(true);
|
|
2557
|
+
expect(isClass(FunctionClass)).toBe(true);
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
test('should return false for non-class types', () => {
|
|
2561
|
+
const ArrowFunction = () => {};
|
|
2562
|
+
expect(isClass(ArrowFunction)).toBe(false);
|
|
2563
|
+
expect(isClass({})).toBe(false);
|
|
2564
|
+
expect(isClass('string')).toBe(false);
|
|
2565
|
+
expect(isClass(null)).toBe(false);
|
|
2566
|
+
expect(isClass(undefined)).toBe(false);
|
|
2567
|
+
});
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
describe('discoverFiles', () => {
|
|
2571
|
+
test('should return files matching the glob pattern', async () => {
|
|
2572
|
+
const files = await discoverFiles({
|
|
2573
|
+
pattern: '**/*.repository.js',
|
|
2574
|
+
root: '/path/to/dist/__tests__/fixtures',
|
|
2575
|
+
});
|
|
2576
|
+
expect(files.length).toBeGreaterThan(0);
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
test('should return empty array for no matches', async () => {
|
|
2580
|
+
const files = await discoverFiles({
|
|
2581
|
+
pattern: '**/*.nonexistent',
|
|
2582
|
+
root: process.cwd(),
|
|
2583
|
+
});
|
|
2584
|
+
expect(files).toEqual([]);
|
|
2585
|
+
});
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
describe('loadClasses', () => {
|
|
2589
|
+
test('should load classes from files', async () => {
|
|
2590
|
+
const files = await discoverFiles({
|
|
2591
|
+
pattern: 'repositories/*.repository.js',
|
|
2592
|
+
root: '/path/to/dist/__tests__/fixtures',
|
|
2593
|
+
});
|
|
2594
|
+
const classes = await loadClasses({ files, root: '/path/to/dist/__tests__/fixtures' });
|
|
2595
|
+
expect(classes.length).toBeGreaterThan(0);
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
test('should return empty array when no classes exported', async () => {
|
|
2599
|
+
const files = await discoverFiles({
|
|
2600
|
+
pattern: 'non-repositories/*.repository.js',
|
|
2601
|
+
root: '/path/to/dist/__tests__/fixtures',
|
|
2602
|
+
});
|
|
2603
|
+
const classes = await loadClasses({ files, root: '/path/to/dist/__tests__/fixtures' });
|
|
2604
|
+
expect(classes).toEqual([]);
|
|
2605
|
+
});
|
|
2606
|
+
});
|
|
2607
|
+
```
|
|
61
2608
|
|
|
62
|
-
|
|
2609
|
+
**Key testing conventions:**
|
|
63
2610
|
|
|
64
|
-
|
|
2611
|
+
- Use bracket notation to access protected members: `booter['artifactOptions']`, `booter['discoveredFiles']`
|
|
2612
|
+
- Test each phase independently and sequentially (configure before discover, discover before load)
|
|
2613
|
+
- Use fixture directories with simple class exports for predictable discovery results
|
|
2614
|
+
- Non-class fixtures (empty files, constant exports) verify that filtering works correctly
|
|
2615
|
+
- Tests use `dist/cjs/__tests__/fixtures/` (built output) as the root, not the `src/` directory
|
|
2616
|
+
- The `pretest` script (`bun run rebuild`) ensures fixtures are compiled before tests run
|
|
65
2617
|
|
|
66
|
-
|
|
67
|
-
- [Getting Started](https://github.com/venizia-ai/ignis/blob/main/packages/docs/wiki/get-started/index.md)
|
|
68
|
-
- [Bootstrapping Guide](https://github.com/venizia-ai/ignis/blob/main/packages/docs/wiki/get-started/core-concepts/bootstrapping.md)
|
|
69
|
-
- [Boot Package Reference](https://github.com/venizia-ai/ignis/blob/main/packages/docs/wiki/references/src-details/boot.md)
|
|
2618
|
+
---
|
|
70
2619
|
|
|
71
2620
|
## License
|
|
72
2621
|
|