@venizia/ignis-docs 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venizia/ignis-docs",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Documentation and MCP Server for Ignis Framework",
5
5
  "keywords": [
6
6
  "ignis",
@@ -12,6 +12,7 @@ This section tracks the history of significant changes, refactors, and updates t
12
12
  | Feature | Description | Priority |
13
13
  |---------|-------------|----------|
14
14
  | [Transaction Support](./planned-transaction-support) | Loopback 4-style explicit transaction objects with isolation levels | Future |
15
+ | [Schema Migrator](./planned-schema-migrator) | LoopBack 4-style auto schema migration without Drizzle Kit | High |
15
16
 
16
17
  ## Recent Changes
17
18
 
@@ -0,0 +1,561 @@
1
+ ---
2
+ title: Planned - Schema Migrator (Auto-Update)
3
+ description: Implementation plan for LoopBack 4-style auto schema migration without Drizzle Kit
4
+ ---
5
+
6
+ # Planned: Schema Migrator (Auto-Update)
7
+
8
+ **Status:** Planned (Not Yet Implemented)
9
+ **Priority:** High
10
+
11
+ ## Goal
12
+
13
+ Implement LoopBack 4-style automatic schema migration that reads model definitions from `@model` decorated classes and syncs them to the database. No external CLI tools (like Drizzle Kit) required.
14
+
15
+ Key principle: **Never drop tables or lose data** - only apply incremental changes (ADD/ALTER/DROP columns).
16
+
17
+ ## Target API
18
+
19
+ ```typescript
20
+ // In application boot
21
+ const migrator = dataSource.getMigrator();
22
+
23
+ // Auto-update: safe, only applies differences
24
+ await migrator.autoupdate();
25
+
26
+ // Or with options
27
+ await migrator.autoupdate({
28
+ models: [User, Role], // Specific models only (optional)
29
+ dryRun: true, // Preview SQL without executing
30
+ });
31
+
32
+ // Fresh start (destructive - use with caution)
33
+ await migrator.automigrate(); // Drops and recreates all tables
34
+ ```
35
+
36
+ ### Migration Behaviors
37
+
38
+ | Method | Behavior | Data Loss |
39
+ |--------|----------|-----------|
40
+ | `autoupdate()` | Compares DB ↔ Model, applies ALTER statements | **No** |
41
+ | `automigrate()` | Drops and recreates tables | **Yes** |
42
+
43
+ ---
44
+
45
+ ## Implementation Steps
46
+
47
+ ### Step 1: Define Migrator Types
48
+
49
+ **File:** `packages/core/src/base/datasources/types.ts`
50
+
51
+ ```typescript
52
+ /** Column information from database introspection */
53
+ export interface IColumnInfo {
54
+ column_name: string;
55
+ data_type: string;
56
+ is_nullable: 'YES' | 'NO';
57
+ column_default: string | null;
58
+ character_maximum_length: number | null;
59
+ }
60
+
61
+ /** Constraint information from database */
62
+ export interface IConstraintInfo {
63
+ constraint_name: string;
64
+ constraint_type: 'PRIMARY KEY' | 'FOREIGN KEY' | 'UNIQUE' | 'CHECK';
65
+ column_name: string;
66
+ foreign_table_name?: string;
67
+ foreign_column_name?: string;
68
+ }
69
+
70
+ /** Index information from database */
71
+ export interface IIndexInfo {
72
+ index_name: string;
73
+ column_name: string;
74
+ is_unique: boolean;
75
+ }
76
+
77
+ /** Options for autoupdate */
78
+ export interface IAutoupdateOptions {
79
+ /** Only migrate specific models (default: all registered models) */
80
+ models?: Array<typeof BaseEntity>;
81
+ /** Preview SQL without executing (default: false) */
82
+ dryRun?: boolean;
83
+ /** Log generated SQL statements (default: true) */
84
+ verbose?: boolean;
85
+ }
86
+
87
+ /** Options for automigrate */
88
+ export interface IAutomigrateOptions extends IAutoupdateOptions {
89
+ /** Skip confirmation for destructive operation (default: false) */
90
+ force?: boolean;
91
+ }
92
+
93
+ /** Result of migration operation */
94
+ export interface IMigrationResult {
95
+ /** Tables created */
96
+ created: string[];
97
+ /** Tables altered */
98
+ altered: string[];
99
+ /** SQL statements executed (or would be executed if dryRun) */
100
+ statements: string[];
101
+ /** Errors encountered */
102
+ errors: Array<{ table: string; error: string }>;
103
+ }
104
+ ```
105
+
106
+ ### Step 2: Create Schema Introspector
107
+
108
+ **File:** `packages/core/src/base/datasources/introspector.ts`
109
+
110
+ ```typescript
111
+ import { sql } from 'drizzle-orm';
112
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
113
+
114
+ /**
115
+ * Introspects PostgreSQL database schema using information_schema
116
+ */
117
+ export class SchemaIntrospector {
118
+ constructor(private db: NodePgDatabase) {}
119
+
120
+ /** Check if table exists */
121
+ async tableExists(tableName: string): Promise<boolean>;
122
+
123
+ /** Get all columns for a table */
124
+ async getColumns(tableName: string): Promise<Map<string, IColumnInfo>>;
125
+
126
+ /** Get all constraints for a table */
127
+ async getConstraints(tableName: string): Promise<IConstraintInfo[]>;
128
+
129
+ /** Get all indexes for a table */
130
+ async getIndexes(tableName: string): Promise<IIndexInfo[]>;
131
+
132
+ /** Get all table names in public schema */
133
+ async getAllTables(): Promise<string[]>;
134
+ }
135
+ ```
136
+
137
+ **Key Queries:**
138
+
139
+ ```sql
140
+ -- Get columns
141
+ SELECT column_name, data_type, is_nullable, column_default, character_maximum_length
142
+ FROM information_schema.columns
143
+ WHERE table_schema = 'public' AND table_name = $1;
144
+
145
+ -- Get constraints
146
+ SELECT tc.constraint_name, tc.constraint_type, kcu.column_name,
147
+ ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name
148
+ FROM information_schema.table_constraints tc
149
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
150
+ LEFT JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
151
+ WHERE tc.table_schema = 'public' AND tc.table_name = $1;
152
+
153
+ -- Get indexes
154
+ SELECT i.relname AS index_name, a.attname AS column_name, ix.indisunique AS is_unique
155
+ FROM pg_class t
156
+ JOIN pg_index ix ON t.oid = ix.indrelid
157
+ JOIN pg_class i ON i.oid = ix.indexrelid
158
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
159
+ WHERE t.relname = $1;
160
+ ```
161
+
162
+ ### Step 3: Create Schema Differ
163
+
164
+ **File:** `packages/core/src/base/datasources/differ.ts`
165
+
166
+ ```typescript
167
+ import { getTableConfig, PgTable, PgColumn } from 'drizzle-orm/pg-core';
168
+
169
+ /** Types of schema changes */
170
+ export type TSchemaChange =
171
+ | { type: 'CREATE_TABLE'; table: string; sql: string }
172
+ | { type: 'ADD_COLUMN'; table: string; column: string; sql: string }
173
+ | { type: 'DROP_COLUMN'; table: string; column: string; sql: string }
174
+ | { type: 'ALTER_COLUMN_TYPE'; table: string; column: string; sql: string }
175
+ | { type: 'ALTER_COLUMN_NULL'; table: string; column: string; sql: string }
176
+ | { type: 'ADD_CONSTRAINT'; table: string; constraint: string; sql: string }
177
+ | { type: 'DROP_CONSTRAINT'; table: string; constraint: string; sql: string }
178
+ | { type: 'ADD_INDEX'; table: string; index: string; sql: string }
179
+ | { type: 'DROP_INDEX'; table: string; index: string; sql: string };
180
+
181
+ /**
182
+ * Compares model schema with database schema and generates changes
183
+ */
184
+ export class SchemaDiffer {
185
+ constructor(private introspector: SchemaIntrospector) {}
186
+
187
+ /** Compare a single model with database and return changes */
188
+ async diffTable(schema: PgTable): Promise<TSchemaChange[]>;
189
+
190
+ /** Compare all models with database */
191
+ async diffAll(schemas: PgTable[]): Promise<TSchemaChange[]>;
192
+ }
193
+ ```
194
+
195
+ **Diff Logic:**
196
+
197
+ ```typescript
198
+ async diffTable(schema: PgTable): Promise<TSchemaChange[]> {
199
+ const config = getTableConfig(schema);
200
+ const tableName = config.name;
201
+ const changes: TSchemaChange[] = [];
202
+
203
+ const tableExists = await this.introspector.tableExists(tableName);
204
+
205
+ if (!tableExists) {
206
+ // Generate CREATE TABLE
207
+ changes.push({
208
+ type: 'CREATE_TABLE',
209
+ table: tableName,
210
+ sql: this.generateCreateTable(config),
211
+ });
212
+ return changes;
213
+ }
214
+
215
+ // Table exists - compare columns
216
+ const dbColumns = await this.introspector.getColumns(tableName);
217
+ const modelColumns = new Map(Object.entries(config.columns));
218
+
219
+ // Find columns to ADD
220
+ for (const [colName, colDef] of modelColumns) {
221
+ if (!dbColumns.has(colName)) {
222
+ changes.push({
223
+ type: 'ADD_COLUMN',
224
+ table: tableName,
225
+ column: colName,
226
+ sql: `ALTER TABLE "${tableName}" ADD COLUMN ${this.columnToSQL(colName, colDef)}`,
227
+ });
228
+ }
229
+ }
230
+
231
+ // Find columns to DROP
232
+ for (const [colName] of dbColumns) {
233
+ if (!modelColumns.has(colName)) {
234
+ changes.push({
235
+ type: 'DROP_COLUMN',
236
+ table: tableName,
237
+ column: colName,
238
+ sql: `ALTER TABLE "${tableName}" DROP COLUMN "${colName}"`,
239
+ });
240
+ }
241
+ }
242
+
243
+ // Find columns to ALTER
244
+ for (const [colName, colDef] of modelColumns) {
245
+ const dbCol = dbColumns.get(colName);
246
+ if (dbCol) {
247
+ changes.push(...this.diffColumn(tableName, colName, colDef, dbCol));
248
+ }
249
+ }
250
+
251
+ // Diff constraints and indexes...
252
+ return changes;
253
+ }
254
+ ```
255
+
256
+ ### Step 4: Create Type Mapper
257
+
258
+ **File:** `packages/core/src/base/datasources/type-mapper.ts`
259
+
260
+ ```typescript
261
+ import { PgColumn } from 'drizzle-orm/pg-core';
262
+
263
+ /**
264
+ * Maps between Drizzle column types and PostgreSQL types
265
+ */
266
+ export class TypeMapper {
267
+ /** Convert Drizzle column to PostgreSQL type string */
268
+ static drizzleToPostgres(column: PgColumn): string {
269
+ const type = column.dataType;
270
+
271
+ switch (type) {
272
+ case 'string': return column.length ? `varchar(${column.length})` : 'text';
273
+ case 'number': return 'integer';
274
+ case 'bigint': return 'bigint';
275
+ case 'boolean': return 'boolean';
276
+ case 'date': return 'timestamp';
277
+ case 'json': return 'jsonb';
278
+ case 'uuid': return 'uuid';
279
+ case 'custom': return column.sqlName; // For custom types
280
+ default: return 'text';
281
+ }
282
+ }
283
+
284
+ /** Check if two types are compatible (for ALTER TYPE) */
285
+ static isCompatible(pgType: string, drizzleType: string): boolean;
286
+
287
+ /** Generate USING clause for type conversion if needed */
288
+ static getTypeConversion(from: string, to: string): string | null;
289
+ }
290
+ ```
291
+
292
+ ### Step 5: Create Schema Migrator
293
+
294
+ **File:** `packages/core/src/base/datasources/migrator.ts`
295
+
296
+ ```typescript
297
+ import { MetadataRegistry } from '@/helpers/inversion';
298
+ import { SchemaIntrospector } from './introspector';
299
+ import { SchemaDiffer } from './differ';
300
+
301
+ /**
302
+ * LoopBack 4-style schema migrator
303
+ * Syncs model definitions to database without external CLI tools
304
+ */
305
+ export class SchemaMigrator {
306
+ private introspector: SchemaIntrospector;
307
+ private differ: SchemaDiffer;
308
+
309
+ constructor(private db: NodePgDatabase) {
310
+ this.introspector = new SchemaIntrospector(db);
311
+ this.differ = new SchemaDiffer(this.introspector);
312
+ }
313
+
314
+ /**
315
+ * Auto-update schema - safe, incremental changes only
316
+ * Like LoopBack 4's datasource.autoupdate()
317
+ */
318
+ async autoupdate(opts?: IAutoupdateOptions): Promise<IMigrationResult> {
319
+ const schemas = this.getSchemas(opts?.models);
320
+ const changes = await this.differ.diffAll(schemas);
321
+
322
+ if (opts?.dryRun) {
323
+ return this.buildResult(changes, { executed: false });
324
+ }
325
+
326
+ return this.executeChanges(changes, opts);
327
+ }
328
+
329
+ /**
330
+ * Auto-migrate schema - destructive, drops and recreates
331
+ * Like LoopBack 4's datasource.automigrate()
332
+ */
333
+ async automigrate(opts?: IAutomigrateOptions): Promise<IMigrationResult> {
334
+ if (!opts?.force) {
335
+ throw getError({
336
+ message: '[automigrate] Destructive operation requires force: true',
337
+ });
338
+ }
339
+
340
+ const schemas = this.getSchemas(opts?.models);
341
+
342
+ // Drop tables in reverse order (for FK constraints)
343
+ for (const schema of [...schemas].reverse()) {
344
+ const tableName = getTableConfig(schema).name;
345
+ await this.db.execute(sql.raw(`DROP TABLE IF EXISTS "${tableName}" CASCADE`));
346
+ }
347
+
348
+ // Create all tables fresh
349
+ const changes = schemas.map(schema => ({
350
+ type: 'CREATE_TABLE' as const,
351
+ table: getTableConfig(schema).name,
352
+ sql: this.generateCreateTable(schema),
353
+ }));
354
+
355
+ return this.executeChanges(changes, opts);
356
+ }
357
+
358
+ /** Get schemas from registry or specific models */
359
+ private getSchemas(models?: Array<typeof BaseEntity>): PgTable[] {
360
+ if (models) {
361
+ return models.map(m => m.schema);
362
+ }
363
+
364
+ const registry = MetadataRegistry.getInstance();
365
+ const allModels = registry.getAllModels();
366
+ return [...allModels.values()].map(entry => entry.schema);
367
+ }
368
+
369
+ /** Execute schema changes */
370
+ private async executeChanges(
371
+ changes: TSchemaChange[],
372
+ opts?: { verbose?: boolean }
373
+ ): Promise<IMigrationResult>;
374
+ }
375
+ ```
376
+
377
+ ### Step 6: Integrate with DataSource
378
+
379
+ **File:** `packages/core/src/base/datasources/base.ts`
380
+
381
+ ```typescript
382
+ export abstract class BaseDataSource<...> {
383
+ private _migrator?: SchemaMigrator;
384
+
385
+ /**
386
+ * Get schema migrator instance
387
+ * Like LoopBack 4's datasource property
388
+ */
389
+ getMigrator(): SchemaMigrator {
390
+ if (!this._migrator) {
391
+ this._migrator = new SchemaMigrator(this.connector);
392
+ }
393
+ return this._migrator;
394
+ }
395
+
396
+ /**
397
+ * Convenience method: auto-update schema
398
+ */
399
+ async autoupdate(opts?: IAutoupdateOptions): Promise<IMigrationResult> {
400
+ return this.getMigrator().autoupdate(opts);
401
+ }
402
+
403
+ /**
404
+ * Convenience method: auto-migrate schema (destructive)
405
+ */
406
+ async automigrate(opts?: IAutomigrateOptions): Promise<IMigrationResult> {
407
+ return this.getMigrator().automigrate(opts);
408
+ }
409
+ }
410
+ ```
411
+
412
+ ---
413
+
414
+ ## Files to Create
415
+
416
+ | File | Purpose |
417
+ |------|---------|
418
+ | `packages/core/src/base/datasources/migrator.ts` | Main SchemaMigrator class |
419
+ | `packages/core/src/base/datasources/introspector.ts` | Database schema introspection |
420
+ | `packages/core/src/base/datasources/differ.ts` | Schema comparison logic |
421
+ | `packages/core/src/base/datasources/type-mapper.ts` | Drizzle ↔ PostgreSQL type mapping |
422
+
423
+ ## Files to Modify
424
+
425
+ | File | Changes |
426
+ |------|---------|
427
+ | `packages/core/src/base/datasources/types.ts` | Add migration types and interfaces |
428
+ | `packages/core/src/base/datasources/base.ts` | Add `getMigrator()`, `autoupdate()`, `automigrate()` |
429
+ | `packages/core/src/base/datasources/index.ts` | Export new classes |
430
+
431
+ ---
432
+
433
+ ## Drizzle to PostgreSQL Type Mapping
434
+
435
+ | Drizzle | PostgreSQL | Notes |
436
+ |---------|------------|-------|
437
+ | `text()` | `text` | Variable-length string |
438
+ | `varchar({ length })` | `varchar(n)` | Fixed max length |
439
+ | `integer()` | `integer` | 32-bit signed |
440
+ | `bigint()` | `bigint` | 64-bit signed |
441
+ | `serial()` | `serial` | Auto-increment |
442
+ | `boolean()` | `boolean` | true/false |
443
+ | `timestamp()` | `timestamp` | Date and time |
444
+ | `date()` | `date` | Date only |
445
+ | `json()` | `json` | JSON storage |
446
+ | `jsonb()` | `jsonb` | Binary JSON (indexed) |
447
+ | `uuid()` | `uuid` | UUID type |
448
+ | `numeric({ precision, scale })` | `numeric(p,s)` | Exact decimal |
449
+ | `real()` | `real` | 32-bit float |
450
+ | `doublePrecision()` | `double precision` | 64-bit float |
451
+
452
+ ---
453
+
454
+ ## Change Detection Matrix
455
+
456
+ | Change Type | Detection Method | SQL Generated |
457
+ |-------------|------------------|---------------|
458
+ | New table | Table not in DB | `CREATE TABLE` |
459
+ | New column | Column not in DB | `ALTER TABLE ADD COLUMN` |
460
+ | Removed column | Column not in model | `ALTER TABLE DROP COLUMN` |
461
+ | Type change | Compare data_type | `ALTER TABLE ALTER COLUMN TYPE` |
462
+ | Nullability | Compare is_nullable | `SET NOT NULL` / `DROP NOT NULL` |
463
+ | Default change | Compare column_default | `SET DEFAULT` / `DROP DEFAULT` |
464
+ | New FK | Constraint not in DB | `ADD CONSTRAINT ... FOREIGN KEY` |
465
+ | Removed FK | Constraint not in model | `DROP CONSTRAINT` |
466
+ | New index | Index not in DB | `CREATE INDEX` |
467
+ | Removed index | Index not in model | `DROP INDEX` |
468
+
469
+ ---
470
+
471
+ ## Usage Examples
472
+
473
+ ### Basic Auto-Update (Recommended)
474
+
475
+ ```typescript
476
+ // Boot application
477
+ const app = new MyApplication();
478
+ await app.boot();
479
+
480
+ // Auto-update all models
481
+ const dataSource = app.getSync<PostgresDataSource>('datasources.PostgresDataSource');
482
+ const result = await dataSource.autoupdate();
483
+
484
+ console.log('Created tables:', result.created);
485
+ console.log('Altered tables:', result.altered);
486
+ console.log('SQL executed:', result.statements);
487
+ ```
488
+
489
+ ### Dry Run (Preview Changes)
490
+
491
+ ```typescript
492
+ const result = await dataSource.autoupdate({ dryRun: true });
493
+
494
+ console.log('Would execute:');
495
+ for (const sql of result.statements) {
496
+ console.log(sql);
497
+ }
498
+ ```
499
+
500
+ ### Migrate Specific Models
501
+
502
+ ```typescript
503
+ import { User, Role } from './models';
504
+
505
+ await dataSource.autoupdate({
506
+ models: [User, Role],
507
+ });
508
+ ```
509
+
510
+ ### Fresh Database (Development Only)
511
+
512
+ ```typescript
513
+ if (process.env.NODE_ENV === 'development') {
514
+ await dataSource.automigrate({ force: true });
515
+ }
516
+ ```
517
+
518
+ ---
519
+
520
+ ## Safety Considerations
521
+
522
+ ### autoupdate() Guarantees
523
+
524
+ | Guarantee | Description |
525
+ |-----------|-------------|
526
+ | No table drops | Never drops tables, even if removed from models |
527
+ | No data loss | Column drops are explicit (column must be removed from model) |
528
+ | Transactional | Each ALTER runs in its own transaction |
529
+ | Idempotent | Safe to run multiple times |
530
+
531
+ ### Dangerous Operations (Require Confirmation)
532
+
533
+ | Operation | Risk | Mitigation |
534
+ |-----------|------|------------|
535
+ | `DROP COLUMN` | Data loss | Logged with warning |
536
+ | `ALTER TYPE` | May fail if incompatible | Checks compatibility first |
537
+ | `automigrate()` | Drops all tables | Requires `force: true` |
538
+
539
+ ---
540
+
541
+ ## Future Enhancements
542
+
543
+ 1. **Migration History Table** - Track applied migrations in `_ignis_migrations`
544
+ 2. **Rollback Support** - Generate reverse migrations
545
+ 3. **Data Migrations** - LoopBack 4-style numbered data seed files
546
+ 4. **Multi-Schema Support** - Support for PostgreSQL schemas beyond `public`
547
+ 5. **MySQL/SQLite Support** - Extend beyond PostgreSQL
548
+
549
+ ---
550
+
551
+ ## Comparison with Alternatives
552
+
553
+ | Feature | Ignis Migrator | Drizzle Kit | LoopBack 4 |
554
+ |---------|----------------|-------------|------------|
555
+ | CLI required | No | Yes | No |
556
+ | Auto-discovery | Yes (from @model) | No (manual schema file) | Yes |
557
+ | Incremental updates | Yes | Yes | Yes |
558
+ | Dry run | Yes | Yes | No |
559
+ | Type safety | Full | Full | Partial |
560
+ | Random file names | No | Yes | No |
561
+ | Bidirectional | Planned | No | No |
@@ -15,7 +15,7 @@ This package provides:
15
15
  - **Prettier settings** - Consistent formatting across all Ignis projects
16
16
  - **TypeScript configs** - Shared base and common configurations
17
17
 
18
- ## Prettier Configuration
18
+ ### Prettier Configuration
19
19
 
20
20
  Automatic code formatting eliminates style debates.
21
21
 
@@ -50,9 +50,7 @@ bun run prettier:cli # Check formatting
50
50
  bun run prettier:fix # Auto-fix
51
51
  ```
52
52
 
53
- **IDE Integration:** Configure your editor to format on save.
54
-
55
- ## ESLint Configuration
53
+ ### ESLint Configuration
56
54
 
57
55
  Prevents common errors and enforces best practices.
58
56
 
@@ -88,9 +86,7 @@ bun run eslint --fix # Auto-fix issues
88
86
  bun run lint:fix # Run both ESLint + Prettier
89
87
  ```
90
88
 
91
- **Pre-commit Hook:** Add ESLint to your pre-commit hooks to catch issues before committing.
92
-
93
- ## TypeScript Configuration
89
+ ### TypeScript Configuration
94
90
 
95
91
  Use the centralized TypeScript configs:
96
92
 
@@ -121,6 +117,358 @@ Use the centralized TypeScript configs:
121
117
 
122
118
  See [`@venizia/dev-configs` documentation](../../references/src-details/dev-configs.md) for full details.
123
119
 
120
+ ## Naming Conventions
121
+
122
+ ### Class Names
123
+
124
+ | Type | Pattern | Example |
125
+ |------|---------|---------|
126
+ | Components | `[Feature]Component` | `HealthCheckComponent`, `AuthComponent` |
127
+ | Controllers | `[Feature]Controller` | `UserController`, `AuthController` |
128
+ | Services | `[Feature]Service` | `JWTTokenService`, `PaymentService` |
129
+ | Repositories | `[Feature]Repository` | `UserRepository`, `OrderRepository` |
130
+ | Strategies | `[Feature]Strategy` | `JWTAuthenticationStrategy` |
131
+ | Factories | `[Feature]Factory` | `UIProviderFactory` |
132
+
133
+ ### File Names
134
+
135
+ Both styles are acceptable: `[type].ts` or `[name].[type].ts`
136
+
137
+ | Type | Single File | Multiple Files |
138
+ |------|-------------|----------------|
139
+ | Components | `component.ts` | `auth.component.ts` |
140
+ | Controllers | `controller.ts` | `user.controller.ts` |
141
+ | Services | `service.ts` | `jwt-token.service.ts` |
142
+ | Repositories | `repository.ts` | `user.repository.ts` |
143
+ | Types/Interfaces | `types.ts` | `user.types.ts` |
144
+ | Constants | `constants.ts` | `keys.ts`, `rest-paths.ts` |
145
+ | Schemas | `schema.ts` | `sign-in.schema.ts` |
146
+
147
+ **Guidelines:**
148
+ - Use `[type].ts` when there's only one file of that type in the folder
149
+ - Use `[name].[type].ts` when there are multiple files of the same type
150
+ - Use kebab-case for multi-word names: `jwt-token.service.ts`
151
+
152
+ ### Type and Interface Prefixes
153
+
154
+ ```typescript
155
+ // Interfaces use 'I' prefix
156
+ interface IHealthCheckOptions {
157
+ restOptions: { path: string };
158
+ }
159
+
160
+ interface IAuthService {
161
+ signIn(context: Context): Promise<void>;
162
+ }
163
+
164
+ // Type aliases use 'T' prefix
165
+ type TSignInRequest = z.infer<typeof SignInRequestSchema>;
166
+ type TRouteContext = Context<Env, Path, Input>;
167
+
168
+ // Generic constraints
169
+ type TTableSchemaWithId = { id: PgColumn };
170
+ ```
171
+
172
+ ### Binding Keys
173
+
174
+ Use static class with `@app/[component]/[feature]` format:
175
+
176
+ ```typescript
177
+ export class HealthCheckBindingKeys {
178
+ static readonly HEALTH_CHECK_OPTIONS = '@app/health-check/options';
179
+ }
180
+
181
+ export class SocketIOBindingKeys {
182
+ static readonly SOCKET_IO_INSTANCE = '@app/socket-io/instance';
183
+ static readonly SERVER_OPTIONS = '@app/socket-io/server-options';
184
+ }
185
+ ```
186
+
187
+ ## Directory Structure
188
+
189
+ ### Component Organization
190
+
191
+ ```
192
+ src/components/[feature]/
193
+ ├── index.ts # Barrel exports
194
+ ├── component.ts # IoC binding setup
195
+ ├── controller.ts # Route handlers
196
+ └── common/
197
+ ├── index.ts # Barrel exports
198
+ ├── keys.ts # Binding key constants
199
+ ├── types.ts # Interfaces and types
200
+ └── rest-paths.ts # Route path constants
201
+ ```
202
+
203
+ ### Complex Component (with multiple features)
204
+
205
+ ```
206
+ src/components/auth/
207
+ ├── index.ts
208
+ ├── authenticate/
209
+ │ ├── index.ts
210
+ │ ├── component.ts
211
+ │ ├── common/
212
+ │ ├── controllers/
213
+ │ ├── services/
214
+ │ └── strategies/
215
+ └── models/
216
+ ├── entities/ # Database models
217
+ └── requests/ # Request schemas
218
+ ```
219
+
220
+ ### Barrel Exports
221
+
222
+ Every folder should have an `index.ts` that re-exports its contents:
223
+
224
+ ```typescript
225
+ // components/health-check/index.ts
226
+ export * from './common';
227
+ export * from './component';
228
+ export * from './controller';
229
+
230
+ // components/health-check/common/index.ts
231
+ export * from './keys';
232
+ export * from './rest-paths';
233
+ export * from './types';
234
+ ```
235
+
236
+ ## Constants Pattern
237
+
238
+ **Prefer static classes over enums** for better tree-shaking and extensibility.
239
+
240
+ ### Basic Constants
241
+
242
+ ```typescript
243
+ export class Authentication {
244
+ static readonly STRATEGY_BASIC = 'basic';
245
+ static readonly STRATEGY_JWT = 'jwt';
246
+ static readonly TYPE_BEARER = 'Bearer';
247
+ }
248
+
249
+ export class HealthCheckRestPaths {
250
+ static readonly ROOT = '/';
251
+ static readonly PING = '/ping';
252
+ static readonly METRICS = '/metrics';
253
+ }
254
+ ```
255
+
256
+ ### Typed Constants with Validation
257
+
258
+ For constants that need type extraction and runtime validation, use this pattern:
259
+
260
+ ```typescript
261
+ import { TConstValue } from '@venizia/ignis-helpers';
262
+
263
+ export class DocumentUITypes {
264
+ // 1. Define static readonly values
265
+ static readonly SWAGGER = 'swagger';
266
+ static readonly SCALAR = 'scalar';
267
+
268
+ // 2. Create a Set for O(1) validation lookup
269
+ static readonly SCHEME_SET = new Set([this.SWAGGER, this.SCALAR]);
270
+
271
+ // 3. Validation helper method
272
+ static isValid(value: string): boolean {
273
+ return this.SCHEME_SET.has(value);
274
+ }
275
+ }
276
+
277
+ // 4. Extract union type from class values
278
+ export type TDocumentUIType = TConstValue<typeof DocumentUITypes>;
279
+ // Result: 'swagger' | 'scalar'
280
+ ```
281
+
282
+ **Full Example with Usage:**
283
+
284
+ ```typescript
285
+ import { TConstValue } from '@venizia/ignis-helpers';
286
+
287
+ export class UserStatuses {
288
+ static readonly ACTIVE = 'active';
289
+ static readonly INACTIVE = 'inactive';
290
+ static readonly PENDING = 'pending';
291
+ static readonly BANNED = 'banned';
292
+
293
+ static readonly SCHEME_SET = new Set([
294
+ this.ACTIVE,
295
+ this.INACTIVE,
296
+ this.PENDING,
297
+ this.BANNED,
298
+ ]);
299
+
300
+ static isValid(value: string): boolean {
301
+ return this.SCHEME_SET.has(value);
302
+ }
303
+
304
+ // Optional: get all values as array
305
+ static values(): string[] {
306
+ return [...this.SCHEME_SET];
307
+ }
308
+ }
309
+
310
+ // Type-safe union type
311
+ export type TUserStatus = TConstValue<typeof UserStatuses>;
312
+ // Result: 'active' | 'inactive' | 'pending' | 'banned'
313
+
314
+ // Usage in interfaces
315
+ interface IUser {
316
+ id: string;
317
+ status: TUserStatus; // Type-safe!
318
+ }
319
+
320
+ // Usage with validation
321
+ function updateUserStatus(userId: string, status: string) {
322
+ if (!UserStatuses.isValid(status)) {
323
+ throw getError({
324
+ statusCode: HTTP.ResultCodes.RS_4.BadRequest,
325
+ message: `Invalid status: ${status}. Valid: ${UserStatuses.values().join(', ')}`,
326
+ });
327
+ }
328
+ // status is validated at runtime
329
+ }
330
+ ```
331
+
332
+ ### Enum vs Static Class Comparison
333
+
334
+ | Aspect | Static Class | TypeScript Enum |
335
+ |--------|--------------|-----------------|
336
+ | Tree-shaking | Full support | Partial (IIFE blocks it) |
337
+ | Bundle size | Minimal | Larger (IIFE wrapper) |
338
+ | Runtime validation | O(1) with `Set` | O(n) with `Object.values()` |
339
+ | Type extraction | `TConstValue<typeof X>` → values | `keyof typeof X` → keys (not values!) |
340
+ | Add methods | Yes | Not possible |
341
+ | Compiled output | Clean class | IIFE wrapper |
342
+
343
+ **Compiled JavaScript:**
344
+
345
+ ```typescript
346
+ // Enum compiles to IIFE (not tree-shakable)
347
+ var UserStatus;
348
+ (function (UserStatus) {
349
+ UserStatus["ACTIVE"] = "active";
350
+ })(UserStatus || (UserStatus = {}));
351
+
352
+ // Static class compiles cleanly
353
+ class UserStatuses { }
354
+ UserStatuses.ACTIVE = 'active';
355
+ ```
356
+
357
+ **Type Extraction Difference:**
358
+
359
+ ```typescript
360
+ // Enum - extracts KEYS
361
+ type T = keyof typeof UserStatus; // 'ACTIVE' | 'INACTIVE'
362
+
363
+ // Static Class - extracts VALUES
364
+ type T = TConstValue<typeof UserStatuses>; // 'active' | 'inactive'
365
+ ```
366
+
367
+ **When to use `const enum`:** Only for numeric flags with no iteration needed (values are inlined, zero runtime). But doesn't work with `--isolatedModules`.
368
+
369
+ **Verdict:** Use Static Class for 90% of cases - better tree-shaking, easy validation, type-safe values, extensible with methods.
370
+
371
+ ## Configuration Patterns
372
+
373
+ ### Default Options
374
+
375
+ Every configurable class should define `DEFAULT_OPTIONS`:
376
+
377
+ ```typescript
378
+ const DEFAULT_OPTIONS: IHealthCheckOptions = {
379
+ restOptions: { path: '/health' },
380
+ };
381
+
382
+ const DEFAULT_SERVER_OPTIONS: Partial<IServerOptions> = {
383
+ identifier: 'SOCKET_IO_SERVER',
384
+ path: '/io',
385
+ cors: {
386
+ origin: '*',
387
+ methods: ['GET', 'POST'],
388
+ },
389
+ };
390
+ ```
391
+
392
+ ### Option Merging
393
+
394
+ ```typescript
395
+ // In component constructor or binding
396
+ const extraOptions = this.application.get<Partial<IServerOptions>>({
397
+ key: BindingKeys.SERVER_OPTIONS,
398
+ isOptional: true,
399
+ }) ?? {};
400
+
401
+ this.options = Object.assign({}, DEFAULT_OPTIONS, extraOptions);
402
+ ```
403
+
404
+ ### Constructor Validation
405
+
406
+ Validate required options in the constructor:
407
+
408
+ ```typescript
409
+ constructor(options: IJWTTokenServiceOptions) {
410
+ super({ scope: JWTTokenService.name });
411
+
412
+ if (!options.jwtSecret) {
413
+ throw getError({
414
+ statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
415
+ message: '[JWTTokenService] Invalid jwtSecret',
416
+ });
417
+ }
418
+
419
+ if (!options.applicationSecret) {
420
+ throw getError({
421
+ statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
422
+ message: '[JWTTokenService] Invalid applicationSecret',
423
+ });
424
+ }
425
+
426
+ this.options = options;
427
+ }
428
+ ```
429
+
430
+ ## Logging Patterns
431
+
432
+ ### Method Context Prefix
433
+
434
+ Always include class and method context in log messages:
435
+
436
+ ```typescript
437
+ // Format: [ClassName][methodName] Message with %s placeholders
438
+ this.logger.info('[binding] Asset storage bound | Key: %s | Type: %s', key, storageType);
439
+ this.logger.debug('[authenticate] Token validated | User: %s', userId);
440
+ this.logger.warn('[register] Skipping duplicate registration | Type: %s', opts.type);
441
+ this.logger.error('[generate] Token generation failed | Error: %s', error.message);
442
+ ```
443
+
444
+ ### Structured Data
445
+
446
+ Use format specifiers for structured logging:
447
+
448
+ ```typescript
449
+ // %s - string, %d - number, %j - JSON object
450
+ this.logger.info('[create] User created | ID: %s | Email: %s', user.id, user.email);
451
+ this.logger.debug('[config] Server options: %j', this.serverOptions);
452
+ ```
453
+
454
+ ## Scope Naming
455
+
456
+ Every class extending a base class should set its scope using `ClassName.name`:
457
+
458
+ ```typescript
459
+ export class JWTTokenService extends BaseService {
460
+ constructor() {
461
+ super({ scope: JWTTokenService.name });
462
+ }
463
+ }
464
+
465
+ export class UserController extends BaseController {
466
+ constructor() {
467
+ super({ scope: UserController.name });
468
+ }
469
+ }
470
+ ```
471
+
124
472
  ## Environment Variables Management
125
473
 
126
474
  Avoid using `process.env` directly in your business logic. Instead, use the `applicationEnvironment` helper and define your keys as constants. This ensures type safety and centralized management.
@@ -147,7 +495,8 @@ const retries = applicationEnvironment.get<number>(EnvironmentKeys.APP_ENV_MAX_R
147
495
 
148
496
  Use the `getError` helper and `HTTP` constants to throw consistent, formatted exceptions that the framework's error handler can process correctly.
149
497
 
150
- **Example:**
498
+ ### Basic Error
499
+
151
500
  ```typescript
152
501
  import { getError, HTTP } from '@venizia/ignis';
153
502
 
@@ -155,9 +504,225 @@ if (!record) {
155
504
  throw getError({
156
505
  statusCode: HTTP.ResultCodes.RS_4.NotFound,
157
506
  message: 'Record not found',
158
- // Optional details
159
- details: { id: requestedId }
507
+ details: { id: requestedId },
160
508
  });
161
509
  }
162
510
  ```
163
511
 
512
+ ### Error with Context
513
+
514
+ Include class/method context in error messages:
515
+
516
+ ```typescript
517
+ // Format: [ClassName][methodName] Descriptive message
518
+ throw getError({
519
+ statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
520
+ message: '[JWTTokenService][generate] Failed to generate token',
521
+ });
522
+
523
+ throw getError({
524
+ statusCode: HTTP.ResultCodes.RS_4.Unauthorized,
525
+ message: '[AuthMiddleware][authenticate] Missing authorization header',
526
+ });
527
+ ```
528
+
529
+ ### Validation Errors
530
+
531
+ ```typescript
532
+ constructor(options: IServiceOptions) {
533
+ if (!options.apiKey) {
534
+ throw getError({
535
+ statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
536
+ message: '[PaymentService] Missing required apiKey configuration',
537
+ });
538
+ }
539
+ }
540
+ ```
541
+
542
+ ### HTTP Status Code Categories
543
+
544
+ | Category | Constant | Use Case |
545
+ |----------|----------|----------|
546
+ | Success | `HTTP.ResultCodes.RS_2.Ok` | Successful response |
547
+ | Created | `HTTP.ResultCodes.RS_2.Created` | Resource created |
548
+ | Bad Request | `HTTP.ResultCodes.RS_4.BadRequest` | Invalid input |
549
+ | Unauthorized | `HTTP.ResultCodes.RS_4.Unauthorized` | Missing/invalid auth |
550
+ | Forbidden | `HTTP.ResultCodes.RS_4.Forbidden` | Insufficient permissions |
551
+ | Not Found | `HTTP.ResultCodes.RS_4.NotFound` | Resource not found |
552
+ | Internal Error | `HTTP.ResultCodes.RS_5.InternalServerError` | Server errors |
553
+
554
+ ## Route Definition Patterns
555
+
556
+ Ignis supports three methods for defining routes. Choose based on your needs:
557
+
558
+ ### Method 1: Config-Driven Routes
559
+
560
+ Define route configurations as constants:
561
+
562
+ ```typescript
563
+ // common/rest-paths.ts
564
+ export class UserRestPaths {
565
+ static readonly ROOT = '/';
566
+ static readonly BY_ID = '/:id';
567
+ static readonly PROFILE = '/profile';
568
+ }
569
+
570
+ // common/route-configs.ts
571
+ export const ROUTE_CONFIGS = {
572
+ [UserRestPaths.ROOT]: {
573
+ method: HTTP.Methods.GET,
574
+ path: UserRestPaths.ROOT,
575
+ responses: jsonResponse({
576
+ [HTTP.ResultCodes.RS_2.Ok]: UserListSchema,
577
+ }),
578
+ },
579
+ [UserRestPaths.BY_ID]: {
580
+ method: HTTP.Methods.GET,
581
+ path: UserRestPaths.BY_ID,
582
+ request: {
583
+ params: z.object({ id: z.string().uuid() }),
584
+ },
585
+ responses: jsonResponse({
586
+ [HTTP.ResultCodes.RS_2.Ok]: UserSchema,
587
+ [HTTP.ResultCodes.RS_4.NotFound]: ErrorSchema,
588
+ }),
589
+ },
590
+ } as const;
591
+ ```
592
+
593
+ ### Method 2: Using `@api` Decorator
594
+
595
+ ```typescript
596
+ @controller({ path: '/users' })
597
+ export class UserController extends BaseController {
598
+
599
+ @api({ configs: ROUTE_CONFIGS[UserRestPaths.ROOT] })
600
+ list(context: TRouteContext<typeof ROUTE_CONFIGS[typeof UserRestPaths.ROOT]>) {
601
+ return context.json({ users: [] }, HTTP.ResultCodes.RS_2.Ok);
602
+ }
603
+
604
+ @api({ configs: ROUTE_CONFIGS[UserRestPaths.BY_ID] })
605
+ getById(context: TRouteContext<typeof ROUTE_CONFIGS[typeof UserRestPaths.BY_ID]>) {
606
+ const { id } = context.req.valid('param');
607
+ return context.json({ id, name: 'User' }, HTTP.ResultCodes.RS_2.Ok);
608
+ }
609
+ }
610
+ ```
611
+
612
+ ### Method 3: Using `bindRoute` (Programmatic)
613
+
614
+ ```typescript
615
+ @controller({ path: '/health' })
616
+ export class HealthCheckController extends BaseController {
617
+ constructor() {
618
+ super({ scope: HealthCheckController.name });
619
+
620
+ this.bindRoute({ configs: ROUTE_CONFIGS['/'] }).to({
621
+ handler: context => context.json({ status: 'ok' }),
622
+ });
623
+ }
624
+ }
625
+ ```
626
+
627
+ ### Method 4: Using `defineRoute` (Inline)
628
+
629
+ ```typescript
630
+ @controller({ path: '/health' })
631
+ export class HealthCheckController extends BaseController {
632
+ constructor() {
633
+ super({ scope: HealthCheckController.name });
634
+
635
+ this.defineRoute({
636
+ configs: ROUTE_CONFIGS['/ping'],
637
+ handler: context => {
638
+ const { message } = context.req.valid('json');
639
+ return context.json({ echo: message }, HTTP.ResultCodes.RS_2.Ok);
640
+ },
641
+ });
642
+ }
643
+ }
644
+ ```
645
+
646
+ ### OpenAPI Schema Integration
647
+
648
+ Use Zod with `.openapi()` for automatic documentation:
649
+
650
+ ```typescript
651
+ const CreateUserSchema = z.object({
652
+ email: z.string().email(),
653
+ name: z.string().min(1).max(100),
654
+ }).openapi({
655
+ description: 'Create user request body',
656
+ example: { email: 'user@example.com', name: 'John Doe' },
657
+ });
658
+
659
+ const UserSchema = z.object({
660
+ id: z.string().uuid(),
661
+ email: z.string().email(),
662
+ name: z.string(),
663
+ createdAt: z.string().datetime(),
664
+ }).openapi({
665
+ description: 'User response',
666
+ });
667
+ ```
668
+
669
+ ## Type Inference Patterns
670
+
671
+ ### Zod Schema to Type
672
+
673
+ ```typescript
674
+ // Define schema
675
+ export const SignInRequestSchema = z.object({
676
+ email: z.string().email(),
677
+ password: z.string().min(8),
678
+ });
679
+
680
+ // Infer type from schema
681
+ export type TSignInRequest = z.infer<typeof SignInRequestSchema>;
682
+ ```
683
+
684
+ ### Const Assertion for Literal Types
685
+
686
+ ```typescript
687
+ const ROUTE_CONFIGS = {
688
+ '/users': { method: 'GET', path: '/users' },
689
+ '/users/:id': { method: 'GET', path: '/users/:id' },
690
+ } as const;
691
+
692
+ // Type is now narrowed to literal values
693
+ type RouteKey = keyof typeof ROUTE_CONFIGS; // '/users' | '/users/:id'
694
+ ```
695
+
696
+ ### Generic Type Constraints
697
+
698
+ ```typescript
699
+ export class DefaultCRUDRepository<
700
+ Schema extends TTableSchemaWithId = TTableSchemaWithId
701
+ > {
702
+ // Schema is constrained to have an 'id' column
703
+ }
704
+
705
+ export interface IAuthService<
706
+ SIRQ extends TSignInRequest = TSignInRequest,
707
+ SIRS = AnyObject,
708
+ > {
709
+ signIn(context: Context, opts: SIRQ): Promise<SIRS>;
710
+ }
711
+ ```
712
+
713
+ ## Summary Table
714
+
715
+ | Aspect | Standard |
716
+ |--------|----------|
717
+ | Interface prefix | `I` (e.g., `IUserService`) |
718
+ | Type alias prefix | `T` (e.g., `TUserRequest`) |
719
+ | Class naming | PascalCase with suffix (e.g., `UserController`) |
720
+ | File naming | kebab-case (e.g., `user.controller.ts`) |
721
+ | Binding keys | `@app/[component]/[feature]` |
722
+ | Constants | Static readonly class (not enums) |
723
+ | Barrel exports | `index.ts` at every folder level |
724
+ | Error format | `[ClassName][method] Message` |
725
+ | Logging format | `[method] Message \| Key: %s` |
726
+ | Default options | `DEFAULT_OPTIONS` constant |
727
+ | Scope naming | `ClassName.name` |
728
+