@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 CHANGED
@@ -2,71 +2,2620 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@venizia/ignis-boot.svg)](https://www.npmjs.com/package/@venizia/ignis-boot)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
5
6
 
6
- An **automated bootstrapping system** for the **Ignis Framework** - provides artifact auto-discovery and loading during application startup.
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
- ## Quick Example
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 "@venizia/ignis";
20
- import { IBootOptions } from "@venizia/ignis-boot";
118
+ import { BaseApplication, IApplicationConfigs } from '@venizia/ignis';
21
119
 
22
- // Configure auto-discovery
23
- export const appConfigs: IApplicationConfigs = {
24
- name: "MyApp",
120
+ const appConfigs: IApplicationConfigs = {
121
+ name: 'MyApp',
25
122
  bootOptions: {
26
- controllers: { dirs: ["controllers"], isNested: true },
27
- services: { dirs: ["services"], isNested: true },
28
- repositories: { dirs: ["repositories"] },
29
- datasources: { dirs: ["datasources"] },
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
- export class Application extends BaseApplication {
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
- ## Features
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
- | Feature | Description |
44
- |---------|-------------|
45
- | **Auto-Discovery** | Automatically finds controllers, services, repositories, and datasources |
46
- | **Convention-Based** | Follow naming patterns (`.controller.js`, `.service.js`, etc.) |
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
- ## Built-in Booters
759
+ **Matches:** `controllers/user.controller.js`, `controllers/admin/user.controller.js`, `controllers/a/b/c/deep.controller.js`
52
760
 
53
- | Booter | Default Directory | Default Extension |
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
- ## About Ignis
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
- Ignis brings together the structured, enterprise development experience of **LoopBack 4** with the blazing speed and simplicity of **Hono** - giving you the best of both worlds.
2609
+ **Key testing conventions:**
63
2610
 
64
- ## Documentation
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
- - [Ignis Repository](https://github.com/venizia-ai/ignis)
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