@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
package/wiki/changelogs/index.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|