@gzl10/nexus-sdk 0.1.11 → 0.3.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
@@ -98,17 +98,131 @@ export const projectPlugin: PluginManifest = {
98
98
  }
99
99
  ```
100
100
 
101
+ ## EntityDefinition (v0.2.0+)
102
+
103
+ Define entities as single source of truth for DB, validation, UI, and CASL:
104
+
105
+ ```typescript
106
+ import type { EntityDefinition } from '@gzl10/nexus-sdk'
107
+
108
+ export const postEntity: EntityDefinition = {
109
+ table: 'cms_posts',
110
+ label: 'Posts',
111
+ labelField: 'title',
112
+ timestamps: true,
113
+ audit: true,
114
+
115
+ fields: {
116
+ id: {
117
+ name: 'id',
118
+ label: 'ID',
119
+ input: 'hidden',
120
+ db: { type: 'uuid', nullable: false, defaultFn: 'uuid' }
121
+ },
122
+ title: {
123
+ name: 'title',
124
+ label: 'Title',
125
+ input: 'text',
126
+ db: { type: 'string', size: 200, nullable: false },
127
+ validation: { required: true, min: 3, max: 200 },
128
+ meta: { searchable: true, sortable: true }
129
+ },
130
+ content: {
131
+ name: 'content',
132
+ label: 'Content',
133
+ input: 'markdown',
134
+ db: { type: 'text', nullable: true }
135
+ },
136
+ status: {
137
+ name: 'status',
138
+ label: 'Status',
139
+ input: 'select',
140
+ db: { type: 'string', nullable: false, default: 'draft' },
141
+ validation: { enum: ['draft', 'published', 'archived'] },
142
+ options: {
143
+ static: [
144
+ { value: 'draft', label: 'Draft' },
145
+ { value: 'published', label: 'Published' },
146
+ { value: 'archived', label: 'Archived' }
147
+ ]
148
+ }
149
+ }
150
+ },
151
+
152
+ casl: {
153
+ ownership: { field: 'created_by' },
154
+ permissions: {
155
+ ADMIN: { actions: ['manage'] },
156
+ EDITOR: { actions: ['create', 'read', 'update'] },
157
+ VIEWER: { actions: ['read'] }
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ ### Code Generators
164
+
165
+ Generate migrations, Zod schemas, TypeScript models, and CASL permissions:
166
+
167
+ ```typescript
168
+ import {
169
+ generateMigration,
170
+ generateZodSchema,
171
+ generateModel,
172
+ generateCaslPermissions
173
+ } from '@gzl10/nexus-sdk'
174
+
175
+ // Generate Knex migration code
176
+ const migrationCode = generateMigration(postEntity)
177
+
178
+ // Generate Zod validation schemas
179
+ const zodCode = generateZodSchema(postEntity)
180
+ // Creates: createPostSchema, updatePostSchema, postParamsSchema, postQuerySchema
181
+
182
+ // Generate TypeScript interface
183
+ const modelCode = generateModel(postEntity)
184
+ // Creates: export interface Post { ... }
185
+
186
+ // Generate CASL permissions for seeding
187
+ const permissions = generateCaslPermissions(postEntity)
188
+ // Returns: Array<{ role, action, subject, conditions, fields, inverted }>
189
+ ```
190
+
101
191
  ## Main Types
102
192
 
103
193
  ### Manifests & Context
104
194
 
105
195
  | Type | Description |
106
196
  |------|-------------|
107
- | `ModuleManifest` | Defines a module: routes, migrations, seeds, CRUD entities |
197
+ | `ModuleManifest` | Defines a module: routes, migrations, seeds, entities |
108
198
  | `PluginManifest` | Groups modules under a plugin with shared metadata |
109
199
  | `ModuleContext` | Context injected by Nexus: `db`, `logger`, `helpers`, `services`, `abilities` |
110
- | `ModuleEntity` | Declarative entity configuration for CRUD UI |
111
- | `FormField` | Form field configuration with validation |
200
+
201
+ ### Entity Definition System
202
+
203
+ | Type | Description |
204
+ |------|-------------|
205
+ | `EntityDefinition` | Complete entity definition (DB, UI, validation, CASL) |
206
+ | `FieldDefinition` | Field configuration with DB, UI, and validation settings |
207
+ | `FieldDbConfig` | Database column configuration |
208
+ | `FieldRelation` | Foreign key configuration |
209
+ | `EntityCaslConfig` | CASL authorization configuration |
210
+
211
+ ### Entity Types (Discriminated Union)
212
+
213
+ | Type | Description |
214
+ |------|-------------|
215
+ | `CollectionEntity` | Full CRUD (users, posts) |
216
+ | `ReferenceEntity` | Catalogs with admin CRUD (countries) |
217
+ | `SingleEntity` | Singleton, update/read only (site_config) |
218
+ | `ConfigEntity` | Per-module config, singleton |
219
+ | `ExternalEntity` | Read-only from external systems |
220
+ | `VirtualEntity` | Orchestration of multiple sources |
221
+ | `ComputedEntity` | KPIs, calculated stats |
222
+ | `ViewEntity` | Optimized read projections |
223
+ | `EventEntity` | Audit logs, append-only |
224
+ | `TempEntity` | TTL-based (cache, sessions) |
225
+ | `ActionEntity` | Commands without persistence |
112
226
 
113
227
  ### Request & Auth
114
228
 
package/dist/index.d.ts CHANGED
@@ -4,6 +4,85 @@ import { Request, RequestHandler, Router } from 'express';
4
4
  export { CookieOptions, NextFunction, Request, RequestHandler, Response, Router } from 'express';
5
5
  import { Logger } from 'pino';
6
6
 
7
+ /**
8
+ * Generadores de código desde EntityDefinition
9
+ *
10
+ * Estos generadores producen código TypeScript como strings
11
+ * que pueden ser escritos a archivos o usados en runtime.
12
+ */
13
+
14
+ /**
15
+ * Entidades que persisten en BD local
16
+ * Excluye: action, external, virtual, computed
17
+ */
18
+ type PersistentEntityDefinition = CollectionEntityDefinition | SingleEntityDefinition | ReferenceEntityDefinition | EventEntityDefinition | ConfigEntityDefinition | TempEntityDefinition | ViewEntityDefinition;
19
+ /**
20
+ * Genera código de migración Knex desde EntityDefinition
21
+ *
22
+ * @example
23
+ * const code = generateMigration(postEntity)
24
+ * // Genera:
25
+ * // import type { ModuleContext, Knex } from '@gzl10/nexus-sdk'
26
+ * //
27
+ * // export async function migrate(ctx: ModuleContext): Promise<void> {
28
+ * // const { db, logger, helpers } = ctx
29
+ * // ...
30
+ * // }
31
+ */
32
+ declare function generateMigration(entity: PersistentEntityDefinition): string;
33
+ /**
34
+ * Genera código de schemas Zod desde EntityDefinition
35
+ *
36
+ * @example
37
+ * const code = generateZodSchema(postEntity)
38
+ * // Genera createPostSchema, updatePostSchema, postParamsSchema, postQuerySchema
39
+ */
40
+ declare function generateZodSchema(entity: PersistentEntityDefinition): string;
41
+ /**
42
+ * Genera interface TypeScript desde EntityDefinition
43
+ *
44
+ * @example
45
+ * const code = generateModel(postEntity)
46
+ * // Genera:
47
+ * // export interface Post {
48
+ * // id: string
49
+ * // title: string
50
+ * // ...
51
+ * // }
52
+ */
53
+ declare function generateModel(entity: PersistentEntityDefinition): string;
54
+ /**
55
+ * Estructura de permiso generado para insertar en BD
56
+ */
57
+ interface GeneratedPermission {
58
+ role: string;
59
+ action: string;
60
+ subject: string;
61
+ conditions: string | null;
62
+ fields: string | null;
63
+ inverted: boolean;
64
+ }
65
+ /**
66
+ * Genera permisos CASL desde EntityDefinition
67
+ *
68
+ * @example
69
+ * const permissions = generateCaslPermissions(postEntity)
70
+ * // Genera array de permisos para insertar en rol_role_permissions
71
+ */
72
+ declare function generateCaslPermissions(entity: PersistentEntityDefinition): GeneratedPermission[];
73
+ /**
74
+ * Genera código de seed para permisos CASL
75
+ *
76
+ * @example
77
+ * const code = generateCaslSeed([postEntity, pageEntity])
78
+ * // Genera código para insertar permisos en rol_role_permissions
79
+ */
80
+ declare function generateCaslSeed(entities: EntityDefinition[]): string;
81
+ /**
82
+ * Obtiene el subject CASL de una entidad
83
+ */
84
+ declare function getEntitySubject(entity: PersistentEntityDefinition): string;
85
+
7
86
  /**
8
87
  * @gzl10/nexus-sdk
9
88
  *
@@ -11,6 +90,23 @@ import { Logger } from 'pino';
11
90
  * Use this package to define plugin/module manifests without
12
91
  * depending on the full @gzl10/nexus-backend package.
13
92
  */
93
+ /**
94
+ * Unión discriminada de todos los tipos de entidad
95
+ *
96
+ * | Type | Persistencia | CRUD | Uso principal |
97
+ * |------------|--------------|-----------------|----------------------------------|
98
+ * | collection | Sí (BD) | Completo | Datos de negocio (users, posts) |
99
+ * | single | Sí (BD) | Update/Read | Config global (site_config) |
100
+ * | external | No | Read | Datos externos (stripe_customers)|
101
+ * | virtual | No | Read | Orquestación (unified_customers) |
102
+ * | computed | No/opcional | Read | KPIs, estadísticas |
103
+ * | view | Sí/virtual | Read | Lectura optimizada (projections) |
104
+ * | reference | Sí | Read (admin) | Catálogos (countries, currencies)|
105
+ * | config | Sí | Update/Read | Config por módulo |
106
+ * | event | Sí | Append | Auditoría (audit_logs) |
107
+ * | temp | No (TTL) | Read/Write | Cache, sesiones (otp_codes) |
108
+ * | action | No | Execute | Operaciones, workflows |
109
+ */
14
110
 
15
111
  type KnexCreateTableBuilder = Knex.CreateTableBuilder;
16
112
  type KnexAlterTableBuilder = Knex.AlterTableBuilder;
@@ -93,18 +189,347 @@ interface FormField {
93
189
  */
94
190
  type ListType = 'table' | 'list' | 'grid' | 'masonry';
95
191
  /**
96
- * Configuración de una entidad/tabla del módulo para UI CRUD
192
+ * Tipos de columna en base de datos
193
+ */
194
+ type DbType = 'string' | 'text' | 'integer' | 'decimal' | 'boolean' | 'date' | 'datetime' | 'json' | 'uuid';
195
+ /**
196
+ * Tipos de input en UI
97
197
  */
98
- interface ModuleEntity {
198
+ type InputType = 'text' | 'email' | 'password' | 'url' | 'tel' | 'number' | 'decimal' | 'textarea' | 'markdown' | 'select' | 'multiselect' | 'checkbox' | 'switch' | 'date' | 'datetime' | 'file' | 'image' | 'hidden';
199
+ /**
200
+ * Configuración de base de datos para un campo
201
+ */
202
+ interface FieldDbConfig {
203
+ type: DbType;
204
+ size?: number;
205
+ precision?: [number, number];
206
+ nullable: boolean;
207
+ unique?: boolean;
208
+ default?: unknown;
209
+ defaultFn?: 'now' | 'uuid';
210
+ index?: boolean;
211
+ }
212
+ /**
213
+ * Configuración de relación (Foreign Key)
214
+ */
215
+ interface FieldRelation {
216
+ table: string;
217
+ column?: string;
218
+ onDelete?: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION';
219
+ onUpdate?: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION';
220
+ }
221
+ /**
222
+ * Configuración de validación para un campo
223
+ */
224
+ interface FieldValidationConfig {
225
+ required?: boolean;
226
+ min?: number;
227
+ max?: number;
228
+ pattern?: string;
229
+ format?: 'email' | 'url' | 'uuid' | 'slug';
230
+ enum?: string[];
231
+ }
232
+ /**
233
+ * Opciones para campos select/relaciones
234
+ */
235
+ interface FieldOptions {
236
+ endpoint?: string;
237
+ valueField?: string;
238
+ labelField?: string;
239
+ static?: Array<{
240
+ value: string;
241
+ label: string;
242
+ }>;
243
+ }
244
+ /**
245
+ * Restricciones de acceso CASL para un campo
246
+ */
247
+ interface FieldCaslAccess {
248
+ /** Roles que pueden leer este campo (vacío = todos, null = nadie excepto ADMIN) */
249
+ readRoles?: string[];
250
+ /** Roles que pueden escribir este campo (vacío = todos, null = nadie excepto ADMIN) */
251
+ writeRoles?: string[];
252
+ }
253
+ /**
254
+ * Metadata del campo
255
+ */
256
+ interface FieldMeta {
257
+ sortable?: boolean;
258
+ searchable?: boolean;
259
+ exportable?: boolean;
260
+ auditField?: 'created_by' | 'updated_by';
261
+ /** Restricciones de acceso CASL para este campo */
262
+ casl?: FieldCaslAccess;
263
+ }
264
+ /**
265
+ * Definición completa de un campo
266
+ *
267
+ * Contiene toda la información necesaria para:
268
+ * - UI: formularios, listas
269
+ * - BD: migraciones Knex
270
+ * - Validación: schemas Zod
271
+ */
272
+ interface FieldDefinition {
99
273
  name: string;
100
274
  label: string;
101
- listFields: Record<string, string>;
102
- formFields?: Record<string, FormField>;
275
+ input: InputType;
276
+ placeholder?: string;
277
+ hint?: string;
278
+ hidden?: boolean;
279
+ disabled?: boolean;
280
+ db: FieldDbConfig;
281
+ relation?: FieldRelation;
282
+ validation?: FieldValidationConfig;
283
+ options?: FieldOptions;
284
+ meta?: FieldMeta;
285
+ }
286
+ /**
287
+ * Índice compuesto de tabla
288
+ */
289
+ interface EntityIndex {
290
+ columns: string[];
291
+ unique?: boolean;
292
+ }
293
+ /**
294
+ * Acciones CASL estándar
295
+ */
296
+ type CaslAction = 'manage' | 'create' | 'read' | 'update' | 'delete';
297
+ /**
298
+ * Condición de ownership para permisos
299
+ * El campo de la entidad que referencia al usuario
300
+ */
301
+ interface OwnershipCondition {
302
+ /** Campo que contiene el ID del propietario (ej: 'author_id', 'created_by') */
303
+ field: string;
304
+ /** Variable del usuario a comparar (default: 'id') */
305
+ userField?: string;
306
+ }
307
+ /**
308
+ * Permiso CASL para un rol específico
309
+ */
310
+ interface RolePermission {
311
+ /** Acciones permitidas */
312
+ actions: CaslAction[];
313
+ /** Condiciones (ownership, status, etc.) */
314
+ conditions?: Record<string, unknown>;
315
+ /** Campos permitidos (null = todos) */
316
+ fields?: string[] | null;
317
+ /** Invertir permiso (cannot en lugar de can) */
318
+ inverted?: boolean;
319
+ }
320
+ /**
321
+ * Configuración de autorización CASL para una entidad
322
+ */
323
+ interface EntityCaslConfig {
324
+ /**
325
+ * Subject CASL (default: inferido de table → 'cms_posts' → 'CmsPost')
326
+ */
327
+ subject?: string;
328
+ /**
329
+ * Campo de ownership para conditions automáticas
330
+ * Si se define, se genera condition { [field]: '${user.id}' }
331
+ */
332
+ ownership?: OwnershipCondition;
333
+ /**
334
+ * Permisos por rol
335
+ * Las keys son nombres de roles (ADMIN, EDITOR, VIEWER, etc.)
336
+ */
337
+ permissions?: Record<string, RolePermission>;
338
+ /**
339
+ * Campos sensibles que requieren permiso especial
340
+ * Se excluyen de 'read' para roles sin acceso explícito
341
+ */
342
+ sensitiveFields?: string[];
343
+ }
344
+ /**
345
+ * Propiedades base compartidas por todas las EntityDefinition
346
+ */
347
+ interface BaseEntityDefinition {
348
+ /** Nombre de tabla en BD (con prefijo: 'cms_posts') */
349
+ table: string;
350
+ /** Nombre para mostrar en UI */
351
+ label: string;
352
+ /** Definición de campos */
353
+ fields: Record<string, FieldDefinition>;
354
+ /** Autorización CASL */
355
+ casl?: EntityCaslConfig;
356
+ }
357
+ /**
358
+ * Entidad de colección - CRUD completo (users, posts, orders)
359
+ * Default cuando no se especifica type
360
+ */
361
+ interface CollectionEntityDefinition extends BaseEntityDefinition {
362
+ type?: 'collection';
363
+ /** Campo para mostrar en selects/referencias */
103
364
  labelField: string;
104
- routePrefix?: string;
105
- editMode?: 'modal' | 'page';
106
- listType?: ListType;
365
+ /** Añadir created_at, updated_at */
366
+ timestamps?: boolean;
367
+ /** Añadir created_by, updated_by */
368
+ audit?: boolean;
369
+ /** Índices compuestos */
370
+ indexes?: EntityIndex[];
371
+ }
372
+ /**
373
+ * Entidad singleton - Solo Update/Read, sin lista (site_config)
374
+ */
375
+ interface SingleEntityDefinition extends BaseEntityDefinition {
376
+ type: 'single';
377
+ /** Añadir updated_at */
378
+ timestamps?: boolean;
379
+ /** Añadir updated_by */
380
+ audit?: boolean;
381
+ }
382
+ /**
383
+ * Entidad de referencia - Catálogos con CRUD admin (countries, currencies)
384
+ */
385
+ interface ReferenceEntityDefinition extends BaseEntityDefinition {
386
+ type: 'reference';
387
+ /** Campo para mostrar en selects/referencias */
388
+ labelField: string;
389
+ /** Añadir created_at, updated_at */
390
+ timestamps?: boolean;
391
+ /** Índices compuestos */
392
+ indexes?: EntityIndex[];
393
+ }
394
+ /**
395
+ * Entidad de eventos - Logs de auditoría, append-only
396
+ */
397
+ interface EventEntityDefinition extends BaseEntityDefinition {
398
+ type: 'event';
399
+ /** Campo para mostrar en listas */
400
+ labelField: string;
401
+ /** Siempre true para eventos */
402
+ timestamps: true;
403
+ }
404
+ /**
405
+ * Entidad de acción - Comandos/operaciones sin persistencia
406
+ */
407
+ interface ActionEntityDefinition {
408
+ type: 'action';
409
+ /** Nombre para mostrar en UI */
410
+ label: string;
411
+ /** Solo campos de formulario, sin BD */
412
+ fields: Record<string, FieldDefinition>;
413
+ }
414
+ /**
415
+ * Entidad externa - Datos de APIs externas (stripe_customers, github_repos)
416
+ * Read-only, sin persistencia local
417
+ */
418
+ interface ExternalEntityDefinition {
419
+ type: 'external';
420
+ /** Nombre para mostrar en UI */
421
+ label: string;
422
+ /** Campo para mostrar en selects/referencias */
423
+ labelField: string;
424
+ /** Definición de campos (estructura esperada del API externo) */
425
+ fields: Record<string, FieldDefinition>;
426
+ /** Configuración del origen externo */
427
+ source: {
428
+ /** Tipo de origen */
429
+ provider: string;
430
+ /** Endpoint o recurso */
431
+ endpoint?: string;
432
+ };
433
+ /** Autorización CASL */
434
+ casl?: EntityCaslConfig;
435
+ }
436
+ /**
437
+ * Entidad virtual - Orquestación de múltiples fuentes (unified_customers)
438
+ * Read-only, combina datos de varias entidades
439
+ */
440
+ interface VirtualEntityDefinition {
441
+ type: 'virtual';
442
+ /** Nombre para mostrar en UI */
443
+ label: string;
444
+ /** Campo para mostrar en selects/referencias */
445
+ labelField: string;
446
+ /** Definición de campos (esquema unificado) */
447
+ fields: Record<string, FieldDefinition>;
448
+ /** Fuentes de datos que se combinan */
449
+ sources: string[];
450
+ /** Autorización CASL */
451
+ casl?: EntityCaslConfig;
452
+ }
453
+ /**
454
+ * Entidad computed - KPIs, estadísticas, métricas calculadas
455
+ * Read-only, puede cachear opcionalmente
456
+ */
457
+ interface ComputedEntityDefinition {
458
+ type: 'computed';
459
+ /** Nombre para mostrar en UI */
460
+ label: string;
461
+ /** Campo para mostrar en selects/referencias */
462
+ labelField?: string;
463
+ /** Definición de campos (estructura del resultado) */
464
+ fields: Record<string, FieldDefinition>;
465
+ /** Tiempo de cache en segundos (0 = sin cache) */
466
+ cacheTtl?: number;
467
+ /** Autorización CASL */
468
+ casl?: EntityCaslConfig;
107
469
  }
470
+ /**
471
+ * Entidad view - Vista optimizada para lectura (projections, denormalizaciones)
472
+ * Read-only, puede ser vista de BD o virtual
473
+ */
474
+ interface ViewEntityDefinition {
475
+ type: 'view';
476
+ /** Tabla o vista en BD (puede ser VIEW SQL) */
477
+ table: string;
478
+ /** Nombre para mostrar en UI */
479
+ label: string;
480
+ /** Campo para mostrar en selects/referencias */
481
+ labelField: string;
482
+ /** Definición de campos */
483
+ fields: Record<string, FieldDefinition>;
484
+ /** Entidad fuente de la que deriva */
485
+ sourceEntity?: string;
486
+ /** Autorización CASL */
487
+ casl?: EntityCaslConfig;
488
+ }
489
+ /**
490
+ * Entidad config - Configuración por módulo/tenant
491
+ * Similar a single pero con scope
492
+ */
493
+ interface ConfigEntityDefinition extends BaseEntityDefinition {
494
+ type: 'config';
495
+ /** Scope de la configuración */
496
+ scope?: 'global' | 'module' | 'tenant' | 'user';
497
+ /** Añadir updated_at */
498
+ timestamps?: boolean;
499
+ /** Añadir updated_by */
500
+ audit?: boolean;
501
+ }
502
+ /**
503
+ * Entidad temporal - Cache, sesiones, OTP codes
504
+ * Con TTL automático, sin auditoría
505
+ */
506
+ interface TempEntityDefinition extends BaseEntityDefinition {
507
+ type: 'temp';
508
+ /** Tiempo de vida en segundos */
509
+ ttl: number;
510
+ /** Campo para mostrar en listas (opcional) */
511
+ labelField?: string;
512
+ /** Índices para búsqueda rápida */
513
+ indexes?: EntityIndex[];
514
+ }
515
+ /**
516
+ * Union de todas las definiciones de entidad
517
+ *
518
+ * | Type | Persistencia | CRUD | Uso principal |
519
+ * |------------|--------------|-----------------|----------------------------------|
520
+ * | collection | Sí (BD) | Completo | Datos de negocio (users, posts) |
521
+ * | single | Sí (BD) | Update/Read | Config global (site_config) |
522
+ * | external | No | Read | Datos externos (stripe_customers)|
523
+ * | virtual | No | Read | Orquestación (unified_customers) |
524
+ * | computed | No/opcional | Read | KPIs, estadísticas |
525
+ * | view | Sí/virtual | Read | Lectura optimizada (projections) |
526
+ * | reference | Sí | Read (admin) | Catálogos (countries, currencies)|
527
+ * | config | Sí | Update/Read | Config por módulo |
528
+ * | event | Sí | Append | Auditoría (audit_logs) |
529
+ * | temp | No (TTL) | Read/Write | Cache, sesiones (otp_codes) |
530
+ * | action | No | Execute | Operaciones, workflows |
531
+ */
532
+ type EntityDefinition = CollectionEntityDefinition | SingleEntityDefinition | ExternalEntityDefinition | VirtualEntityDefinition | ComputedEntityDefinition | ViewEntityDefinition | ReferenceEntityDefinition | ConfigEntityDefinition | EventEntityDefinition | TempEntityDefinition | ActionEntityDefinition;
108
533
  /**
109
534
  * Requisitos para activar un módulo
110
535
  */
@@ -130,19 +555,21 @@ interface ForbiddenErrorInstance {
130
555
  * Constructor de ForbiddenError con método from()
131
556
  */
132
557
  interface ForbiddenErrorConstructor {
133
- from: (ability: AbilityLike) => ForbiddenErrorInstance;
558
+ from: (ability: any) => ForbiddenErrorInstance;
134
559
  }
135
560
  /**
136
561
  * Abilities CASL disponibles en el contexto
137
562
  * Permite usar CASL en plugins sin importar @casl/ability directamente
138
563
  */
139
564
  interface ModuleAbilities {
565
+ /** Crea una ability CASL para un usuario (acepta argumentos adicionales como permissions) */
566
+ defineAbilityFor: (user: any, ...args: any[]) => any;
567
+ /** Empaqueta reglas CASL para enviar al cliente (recibe ability, retorna reglas) */
568
+ packRules: (ability: any) => unknown[];
140
569
  /** Wrapper para verificar permisos contra instancias */
141
- subject: (type: string, object: unknown) => unknown;
570
+ subject: (type: string, object: Record<string, any>) => unknown;
142
571
  /** Error de CASL para throwUnlessCan */
143
572
  ForbiddenError: ForbiddenErrorConstructor;
144
- /** Otros métodos que el backend pueda añadir */
145
- [key: string]: unknown;
146
573
  }
147
574
  /**
148
575
  * AuthRequest pre-tipado para uso en plugins
@@ -206,7 +633,8 @@ interface ModuleContext {
206
633
  createRouter: () => Router;
207
634
  middleware: ModuleMiddlewares;
208
635
  registerMiddleware: (name: string, handler: RequestHandler) => void;
209
- config: {};
636
+ /** Configuración resuelta de la aplicación (permite propiedades tipadas del backend) */
637
+ config: any;
210
638
  errors: {
211
639
  AppError: new (message: string, statusCode?: number) => Error;
212
640
  NotFoundError: new (message?: string) => Error;
@@ -215,10 +643,8 @@ interface ModuleContext {
215
643
  ConflictError: new (message?: string) => Error;
216
644
  };
217
645
  abilities: ModuleAbilities;
218
- events: {
219
- emit: (event: string, ...args: unknown[]) => boolean;
220
- on: (event: string, listener: (...args: unknown[]) => void) => unknown;
221
- };
646
+ /** Sistema de eventos (EventEmitter2 compatible, permite implementaciones tipadas) */
647
+ events: any;
222
648
  mail: {
223
649
  send: (options: {
224
650
  to: string | string[];
@@ -227,7 +653,7 @@ interface ModuleContext {
227
653
  text?: string;
228
654
  template?: string;
229
655
  data?: Record<string, unknown>;
230
- }) => Promise<void>;
656
+ }) => Promise<unknown>;
231
657
  };
232
658
  /** Servicios de módulos core (users, etc.) */
233
659
  services: CoreServices & Record<string, unknown>;
@@ -264,8 +690,12 @@ interface ModuleManifest {
264
690
  routePrefix?: string;
265
691
  /** Subjects CASL adicionales (sin entity). Los subjects de entities se infieren automáticamente */
266
692
  additionalSubjects?: string[];
267
- /** Entidades/tablas del módulo con config CRUD para UI. Sus `name` se registran como subjects CASL */
268
- entities?: ModuleEntity[];
693
+ /**
694
+ * Definiciones de entidades (nuevo sistema unificado).
695
+ * Single source of truth para BD, validación, UI y CASL.
696
+ * Sus subjects se registran automáticamente.
697
+ */
698
+ definitions?: EntityDefinition[];
269
699
  }
270
700
  /**
271
701
  * Categorías disponibles para plugins
@@ -293,4 +723,4 @@ interface PluginManifest {
293
723
  modules: ModuleManifest[];
294
724
  }
295
725
 
296
- export type { AbilityLike, AuthRequest, BaseUser, CoreServices, FieldValidation, ForbiddenErrorConstructor, ForbiddenErrorInstance, FormField, FormFieldType, KnexAlterTableBuilder, KnexCreateTableBuilder, KnexTransaction, ListType, MigrationHelpers, ModuleAbilities, ModuleContext, ModuleEntity, ModuleManifest, ModuleMiddlewares, ModuleRequirements, PaginatedResult, PaginationParams, PluginAuthRequest, PluginCategory, PluginManifest, UsersResolver, ValidateSchemas, ValidationSchema };
726
+ export { type AbilityLike, type ActionEntityDefinition, type AuthRequest, type BaseUser, type CaslAction, type CollectionEntityDefinition, type ComputedEntityDefinition, type ConfigEntityDefinition, type CoreServices, type DbType, type EntityCaslConfig, type EntityDefinition, type EntityIndex, type EventEntityDefinition, type ExternalEntityDefinition, type FieldCaslAccess, type FieldDbConfig, type FieldDefinition, type FieldMeta, type FieldOptions, type FieldRelation, type FieldValidation, type FieldValidationConfig, type ForbiddenErrorConstructor, type ForbiddenErrorInstance, type FormField, type FormFieldType, type GeneratedPermission, type InputType, type KnexAlterTableBuilder, type KnexCreateTableBuilder, type KnexTransaction, type ListType, type MigrationHelpers, type ModuleAbilities, type ModuleContext, type ModuleManifest, type ModuleMiddlewares, type ModuleRequirements, type OwnershipCondition, type PaginatedResult, type PaginationParams, type PluginAuthRequest, type PluginCategory, type PluginManifest, type ReferenceEntityDefinition, type RolePermission, type SingleEntityDefinition, type TempEntityDefinition, type UsersResolver, type ValidateSchemas, type ValidationSchema, type ViewEntityDefinition, type VirtualEntityDefinition, generateCaslPermissions, generateCaslSeed, generateMigration, generateModel, generateZodSchema, getEntitySubject };
package/dist/index.js CHANGED
@@ -0,0 +1,451 @@
1
+ // src/generators.ts
2
+ function isPersistentEntity(entity) {
3
+ const nonPersistent = ["action", "external", "virtual", "computed"];
4
+ return !nonPersistent.includes(entity.type ?? "collection");
5
+ }
6
+ function generateMigration(entity) {
7
+ const { table, fields } = entity;
8
+ const timestamps = "timestamps" in entity ? entity.timestamps : false;
9
+ const audit = "audit" in entity ? entity.audit : false;
10
+ const indexes = "indexes" in entity ? entity.indexes : void 0;
11
+ const lines = [
12
+ `import type { ModuleContext, Knex } from '@gzl10/nexus-sdk'`,
13
+ ``,
14
+ `export async function migrate(ctx: ModuleContext): Promise<void> {`,
15
+ ` const { db, logger, helpers } = ctx`
16
+ ];
17
+ if (timestamps) {
18
+ lines.push(` const { addTimestamps } = helpers`);
19
+ }
20
+ if (audit) {
21
+ lines.push(` const { addAuditFieldsIfMissing } = helpers`);
22
+ }
23
+ lines.push(``);
24
+ lines.push(` if (!(await db.schema.hasTable('${table}'))) {`);
25
+ lines.push(` await db.schema.createTable('${table}', (table: Knex.CreateTableBuilder) => {`);
26
+ for (const [name, field] of Object.entries(fields)) {
27
+ const columnCode = generateColumnCode(name, field);
28
+ lines.push(` ${columnCode}`);
29
+ }
30
+ if (timestamps) {
31
+ lines.push(` addTimestamps(table, db)`);
32
+ }
33
+ if (indexes?.length) {
34
+ lines.push(``);
35
+ for (const idx of indexes) {
36
+ if (idx.unique) {
37
+ lines.push(` table.unique([${idx.columns.map((c) => `'${c}'`).join(", ")}])`);
38
+ } else {
39
+ lines.push(` table.index([${idx.columns.map((c) => `'${c}'`).join(", ")}])`);
40
+ }
41
+ }
42
+ }
43
+ lines.push(` })`);
44
+ lines.push(` logger.info('Created table: ${table}')`);
45
+ lines.push(` }`);
46
+ if (audit) {
47
+ lines.push(``);
48
+ lines.push(` await addAuditFieldsIfMissing(db, '${table}')`);
49
+ }
50
+ lines.push(`}`);
51
+ lines.push(``);
52
+ return lines.join("\n");
53
+ }
54
+ function generateColumnCode(name, field) {
55
+ const { db, relation } = field;
56
+ let code = "";
57
+ switch (db.type) {
58
+ case "string":
59
+ code = db.size ? `table.string('${name}', ${db.size})` : `table.string('${name}')`;
60
+ break;
61
+ case "text":
62
+ code = `table.text('${name}')`;
63
+ break;
64
+ case "integer":
65
+ code = `table.integer('${name}')`;
66
+ break;
67
+ case "decimal":
68
+ if (db.precision) {
69
+ code = `table.decimal('${name}', ${db.precision[0]}, ${db.precision[1]})`;
70
+ } else {
71
+ code = `table.decimal('${name}')`;
72
+ }
73
+ break;
74
+ case "boolean":
75
+ code = `table.boolean('${name}')`;
76
+ break;
77
+ case "date":
78
+ code = `table.date('${name}')`;
79
+ break;
80
+ case "datetime":
81
+ code = `table.timestamp('${name}')`;
82
+ break;
83
+ case "json":
84
+ code = `table.json('${name}')`;
85
+ break;
86
+ case "uuid":
87
+ code = `table.uuid('${name}')`;
88
+ break;
89
+ default:
90
+ code = `table.string('${name}')`;
91
+ }
92
+ if (name === "id") {
93
+ code += `.primary()`;
94
+ }
95
+ if (db.nullable) {
96
+ code += `.nullable()`;
97
+ } else {
98
+ code += `.notNullable()`;
99
+ }
100
+ if (db.unique) {
101
+ code += `.unique()`;
102
+ }
103
+ if (db.default !== void 0) {
104
+ if (typeof db.default === "string") {
105
+ code += `.defaultTo('${db.default}')`;
106
+ } else {
107
+ code += `.defaultTo(${JSON.stringify(db.default)})`;
108
+ }
109
+ } else if (db.defaultFn === "now") {
110
+ code += `.defaultTo(db.fn.now())`;
111
+ } else if (db.defaultFn === "uuid") {
112
+ code += `.defaultTo(db.raw('gen_random_uuid()'))`;
113
+ }
114
+ if (relation) {
115
+ const col = relation.column ?? "id";
116
+ code += `.references('${col}').inTable('${relation.table}')`;
117
+ if (relation.onDelete) {
118
+ code += `.onDelete('${relation.onDelete}')`;
119
+ }
120
+ if (relation.onUpdate) {
121
+ code += `.onUpdate('${relation.onUpdate}')`;
122
+ }
123
+ }
124
+ if (db.index && !db.unique && name !== "id") {
125
+ }
126
+ return code;
127
+ }
128
+ function generateZodSchema(entity) {
129
+ const { table, fields } = entity;
130
+ const entityName = tableToEntityName(table);
131
+ const lines = [
132
+ `import { z } from 'zod'`,
133
+ ``
134
+ ];
135
+ lines.push(`// === CREATE ===`);
136
+ lines.push(`export const create${entityName}Schema = z.object({`);
137
+ for (const [name, field] of Object.entries(fields)) {
138
+ if (name === "id" || field.meta?.auditField) continue;
139
+ const zodCode = generateZodFieldCode(field, "create");
140
+ if (zodCode) {
141
+ lines.push(` ${name}: ${zodCode},`);
142
+ }
143
+ }
144
+ lines.push(`})`);
145
+ lines.push(``);
146
+ lines.push(`// === UPDATE ===`);
147
+ lines.push(`export const update${entityName}Schema = z.object({`);
148
+ for (const [name, field] of Object.entries(fields)) {
149
+ if (name === "id" || field.meta?.auditField) continue;
150
+ const zodCode = generateZodFieldCode(field, "update");
151
+ if (zodCode) {
152
+ lines.push(` ${name}: ${zodCode},`);
153
+ }
154
+ }
155
+ lines.push(`})`);
156
+ lines.push(``);
157
+ lines.push(`// === PARAMS ===`);
158
+ lines.push(`export const ${entityName.toLowerCase()}ParamsSchema = z.object({`);
159
+ lines.push(` id: z.string(),`);
160
+ lines.push(`})`);
161
+ lines.push(``);
162
+ lines.push(`// === QUERY ===`);
163
+ lines.push(`export const ${entityName.toLowerCase()}QuerySchema = z.object({`);
164
+ lines.push(` page: z.coerce.number().int().min(1).default(1),`);
165
+ lines.push(` limit: z.coerce.number().int().min(1).max(100).default(20),`);
166
+ for (const [name, field] of Object.entries(fields)) {
167
+ if (field.meta?.searchable) {
168
+ const zodType = dbTypeToZodType(field.db.type);
169
+ lines.push(` ${name}: ${zodType}.optional(),`);
170
+ }
171
+ }
172
+ lines.push(`})`);
173
+ lines.push(``);
174
+ lines.push(`// === INFERRED TYPES ===`);
175
+ lines.push(`export type Create${entityName}Input = z.infer<typeof create${entityName}Schema>`);
176
+ lines.push(`export type Update${entityName}Input = z.infer<typeof update${entityName}Schema>`);
177
+ lines.push(`export type ${entityName}Params = z.infer<typeof ${entityName.toLowerCase()}ParamsSchema>`);
178
+ lines.push(`export type ${entityName}Query = z.infer<typeof ${entityName.toLowerCase()}QuerySchema>`);
179
+ lines.push(``);
180
+ return lines.join("\n");
181
+ }
182
+ function generateZodFieldCode(field, mode) {
183
+ const { db, validation } = field;
184
+ let code = dbTypeToZodType(db.type);
185
+ if (validation) {
186
+ if (validation.format === "email") {
187
+ code += `.email()`;
188
+ } else if (validation.format === "url") {
189
+ code += `.url()`;
190
+ } else if (validation.format === "uuid") {
191
+ code += `.uuid()`;
192
+ }
193
+ if (validation.min !== void 0) {
194
+ if (db.type === "string" || db.type === "text") {
195
+ code += `.min(${validation.min})`;
196
+ } else if (db.type === "integer" || db.type === "decimal") {
197
+ code += `.min(${validation.min})`;
198
+ }
199
+ }
200
+ if (validation.max !== void 0) {
201
+ if (db.type === "string" || db.type === "text") {
202
+ code += `.max(${validation.max})`;
203
+ } else if (db.type === "integer" || db.type === "decimal") {
204
+ code += `.max(${validation.max})`;
205
+ }
206
+ }
207
+ if (validation.pattern) {
208
+ code += `.regex(/${validation.pattern}/)`;
209
+ }
210
+ if (validation.enum?.length) {
211
+ code = `z.enum([${validation.enum.map((v) => `'${v}'`).join(", ")}])`;
212
+ }
213
+ }
214
+ if (db.nullable) {
215
+ code += `.nullable()`;
216
+ }
217
+ if (mode === "update") {
218
+ code += `.optional()`;
219
+ } else if (mode === "create" && !validation?.required && db.default !== void 0) {
220
+ code += `.optional()`;
221
+ }
222
+ return code;
223
+ }
224
+ function dbTypeToZodType(dbType) {
225
+ switch (dbType) {
226
+ case "string":
227
+ case "text":
228
+ case "uuid":
229
+ return "z.string()";
230
+ case "integer":
231
+ return "z.number().int()";
232
+ case "decimal":
233
+ return "z.number()";
234
+ case "boolean":
235
+ return "z.boolean()";
236
+ case "date":
237
+ case "datetime":
238
+ return "z.string().datetime()";
239
+ case "json":
240
+ return "z.record(z.unknown())";
241
+ default:
242
+ return "z.string()";
243
+ }
244
+ }
245
+ function generateModel(entity) {
246
+ const { table, fields } = entity;
247
+ const timestamps = "timestamps" in entity ? entity.timestamps : false;
248
+ const audit = "audit" in entity ? entity.audit : false;
249
+ const entityName = tableToEntityName(table);
250
+ const lines = [
251
+ `/**`,
252
+ ` * ${entity.label}`,
253
+ ` * Generated from EntityDefinition`,
254
+ ` */`,
255
+ `export interface ${entityName} {`
256
+ ];
257
+ for (const [name, field] of Object.entries(fields)) {
258
+ const tsType = dbTypeToTsType(field.db);
259
+ const optional = field.db.nullable ? "?" : "";
260
+ lines.push(` ${name}${optional}: ${tsType}`);
261
+ }
262
+ if (timestamps) {
263
+ lines.push(` created_at: Date`);
264
+ lines.push(` updated_at: Date`);
265
+ }
266
+ if (audit) {
267
+ lines.push(` created_by: string | null`);
268
+ lines.push(` updated_by: string | null`);
269
+ }
270
+ lines.push(`}`);
271
+ lines.push(``);
272
+ return lines.join("\n");
273
+ }
274
+ function dbTypeToTsType(db) {
275
+ switch (db.type) {
276
+ case "string":
277
+ case "text":
278
+ case "uuid":
279
+ return "string";
280
+ case "integer":
281
+ case "decimal":
282
+ return "number";
283
+ case "boolean":
284
+ return "boolean";
285
+ case "date":
286
+ case "datetime":
287
+ return "Date";
288
+ case "json":
289
+ return "Record<string, unknown>";
290
+ default:
291
+ return "unknown";
292
+ }
293
+ }
294
+ function tableToEntityName(table) {
295
+ const withoutPrefix = table.replace(/^[a-z]{2,4}_/, "");
296
+ const singular = withoutPrefix.endsWith("ies") ? withoutPrefix.slice(0, -3) + "y" : withoutPrefix.endsWith("s") ? withoutPrefix.slice(0, -1) : withoutPrefix;
297
+ return singular.charAt(0).toUpperCase() + singular.slice(1);
298
+ }
299
+ function tableToSubject(table) {
300
+ const match = table.match(/^([a-z]{2,4})_(.+)$/);
301
+ if (!match) return tableToEntityName(table);
302
+ const [, prefix, rest] = match;
303
+ const prefixPascal = prefix.charAt(0).toUpperCase() + prefix.slice(1);
304
+ const singular = rest.endsWith("ies") ? rest.slice(0, -3) + "y" : rest.endsWith("s") ? rest.slice(0, -1) : rest;
305
+ const restPascal = singular.charAt(0).toUpperCase() + singular.slice(1);
306
+ return prefixPascal + restPascal;
307
+ }
308
+ function getFieldsForRole(entity, role, action) {
309
+ const { fields, casl } = entity;
310
+ const isReadAction = action === "read";
311
+ const isWriteAction = action === "create" || action === "update";
312
+ if (action === "manage") return null;
313
+ const allowedFields = [];
314
+ const sensitiveFields = casl?.sensitiveFields ?? [];
315
+ for (const [fieldName, field] of Object.entries(fields)) {
316
+ const fieldCasl = field.meta?.casl;
317
+ if (sensitiveFields.includes(fieldName) && role !== "ADMIN") {
318
+ continue;
319
+ }
320
+ if (isReadAction) {
321
+ if (fieldCasl?.readRoles !== void 0) {
322
+ if (fieldCasl.readRoles.length === 0 || fieldCasl.readRoles.includes(role)) {
323
+ allowedFields.push(fieldName);
324
+ }
325
+ } else {
326
+ allowedFields.push(fieldName);
327
+ }
328
+ } else if (isWriteAction) {
329
+ if (fieldCasl?.writeRoles !== void 0) {
330
+ if (fieldCasl.writeRoles.length === 0 || fieldCasl.writeRoles.includes(role)) {
331
+ allowedFields.push(fieldName);
332
+ }
333
+ } else {
334
+ allowedFields.push(fieldName);
335
+ }
336
+ } else {
337
+ return null;
338
+ }
339
+ }
340
+ const allFieldNames = Object.keys(fields).filter((f) => !sensitiveFields.includes(f) || role === "ADMIN");
341
+ if (allowedFields.length === allFieldNames.length) {
342
+ return null;
343
+ }
344
+ return allowedFields.length > 0 ? allowedFields : null;
345
+ }
346
+ function generateCaslPermissions(entity) {
347
+ const { table, fields, casl } = entity;
348
+ if (!casl) return [];
349
+ const subject = casl.subject ?? tableToSubject(table);
350
+ const permissions = [];
351
+ for (const [role, config] of Object.entries(casl.permissions ?? {})) {
352
+ for (const action of config.actions) {
353
+ let conditions = config.conditions ?? null;
354
+ if (casl.ownership && (action === "update" || action === "delete") && !conditions) {
355
+ const userField = casl.ownership.userField ?? "id";
356
+ conditions = { [casl.ownership.field]: `\${user.${userField}}` };
357
+ }
358
+ let permFields = config.fields ?? null;
359
+ if (!permFields) {
360
+ permFields = getFieldsForRole(entity, role, action);
361
+ }
362
+ permissions.push({
363
+ role,
364
+ action,
365
+ subject,
366
+ conditions: conditions ? JSON.stringify(conditions) : null,
367
+ fields: permFields ? JSON.stringify(permFields) : null,
368
+ inverted: config.inverted ?? false
369
+ });
370
+ }
371
+ }
372
+ return permissions;
373
+ }
374
+ function generateCaslSeed(entities) {
375
+ const allPermissions = [];
376
+ for (const entity of entities) {
377
+ if (isPersistentEntity(entity)) {
378
+ allPermissions.push(...generateCaslPermissions(entity));
379
+ }
380
+ }
381
+ if (allPermissions.length === 0) {
382
+ return "// No CASL permissions defined in entities\n";
383
+ }
384
+ const lines = [
385
+ `import type { ModuleContext } from '@gzl10/nexus-sdk'`,
386
+ ``,
387
+ `/**`,
388
+ ` * Seed de permisos CASL generado desde EntityDefinition`,
389
+ ` */`,
390
+ `export async function seedPermissions(ctx: ModuleContext): Promise<void> {`,
391
+ ` const { db, generateId, logger } = ctx`,
392
+ ``,
393
+ ` // Obtener IDs de roles`,
394
+ ` const roles = await db('rol_roles').select('id', 'name')`,
395
+ ` const roleMap = Object.fromEntries(roles.map((r: { id: string; name: string }) => [r.name, r.id]))`,
396
+ ``,
397
+ ` const permissions = [`
398
+ ];
399
+ for (const perm of allPermissions) {
400
+ lines.push(` {`);
401
+ lines.push(` role: '${perm.role}',`);
402
+ lines.push(` action: '${perm.action}',`);
403
+ lines.push(` subject: '${perm.subject}',`);
404
+ lines.push(` conditions: ${perm.conditions ? `'${perm.conditions}'` : "null"},`);
405
+ lines.push(` fields: ${perm.fields ? `'${perm.fields}'` : "null"},`);
406
+ lines.push(` inverted: ${perm.inverted}`);
407
+ lines.push(` },`);
408
+ }
409
+ lines.push(` ]`);
410
+ lines.push(``);
411
+ lines.push(` for (const perm of permissions) {`);
412
+ lines.push(` const roleId = roleMap[perm.role]`);
413
+ lines.push(` if (!roleId) {`);
414
+ lines.push(` logger.warn({ role: perm.role }, 'Role not found, skipping permission')`);
415
+ lines.push(` continue`);
416
+ lines.push(` }`);
417
+ lines.push(``);
418
+ lines.push(` // Check if permission exists`);
419
+ lines.push(` const existing = await db('rol_role_permissions')`);
420
+ lines.push(` .where({ role_id: roleId, action: perm.action, subject: perm.subject })`);
421
+ lines.push(` .first()`);
422
+ lines.push(``);
423
+ lines.push(` if (!existing) {`);
424
+ lines.push(` await db('rol_role_permissions').insert({`);
425
+ lines.push(` id: generateId(),`);
426
+ lines.push(` role_id: roleId,`);
427
+ lines.push(` action: perm.action,`);
428
+ lines.push(` subject: perm.subject,`);
429
+ lines.push(` conditions: perm.conditions,`);
430
+ lines.push(` fields: perm.fields,`);
431
+ lines.push(` inverted: perm.inverted`);
432
+ lines.push(` })`);
433
+ lines.push(` }`);
434
+ lines.push(` }`);
435
+ lines.push(``);
436
+ lines.push(` logger.info('Seeded CASL permissions from EntityDefinitions')`);
437
+ lines.push(`}`);
438
+ lines.push(``);
439
+ return lines.join("\n");
440
+ }
441
+ function getEntitySubject(entity) {
442
+ return entity.casl?.subject ?? tableToSubject(entity.table);
443
+ }
444
+ export {
445
+ generateCaslPermissions,
446
+ generateCaslSeed,
447
+ generateMigration,
448
+ generateModel,
449
+ generateZodSchema,
450
+ getEntitySubject
451
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gzl10/nexus-sdk",
3
- "version": "0.1.11",
3
+ "version": "0.3.0",
4
4
  "description": "SDK types for creating Nexus plugins and modules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",