@gzl10/nexus-sdk 0.1.10 → 0.2.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
@@ -17,7 +17,7 @@ pnpm add @gzl10/nexus-sdk
17
17
  ### Define a module
18
18
 
19
19
  ```typescript
20
- import type { ModuleManifest } from '@gzl10/nexus-sdk'
20
+ import type { ModuleManifest, PluginAuthRequest } from '@gzl10/nexus-sdk'
21
21
 
22
22
  export const tasksModule: ModuleManifest = {
23
23
  name: 'tasks',
@@ -37,6 +37,7 @@ export const tasksModule: ModuleManifest = {
37
37
  table.string('title').notNullable()
38
38
  table.text('description')
39
39
  table.boolean('completed').defaultTo(false)
40
+ table.string('created_by').references('id').inTable('users')
40
41
  helpers.addTimestamps(table, db)
41
42
  })
42
43
  }
@@ -44,10 +45,19 @@ export const tasksModule: ModuleManifest = {
44
45
 
45
46
  routes: (ctx) => {
46
47
  const router = ctx.createRouter()
48
+ const { abilities, services } = ctx
49
+
50
+ router.get('/', async (req: PluginAuthRequest, res) => {
51
+ // Check permissions with CASL
52
+ abilities.ForbiddenError.from(req.ability).throwUnlessCan('read', 'tasks')
47
53
 
48
- router.get('/', async (req, res) => {
49
54
  const tasks = await ctx.db('tasks').select('*')
50
- res.json(tasks)
55
+
56
+ // Resolve user relations
57
+ const userIds = [...new Set(tasks.map(t => t.created_by).filter(Boolean))]
58
+ const users = await services.users?.findByIds(userIds) ?? []
59
+
60
+ res.json({ tasks, users })
51
61
  })
52
62
 
53
63
  return router
@@ -90,14 +100,35 @@ export const projectPlugin: PluginManifest = {
90
100
 
91
101
  ## Main Types
92
102
 
103
+ ### Manifests & Context
104
+
93
105
  | Type | Description |
94
106
  |------|-------------|
95
107
  | `ModuleManifest` | Defines a module: routes, migrations, seeds, CRUD entities |
96
108
  | `PluginManifest` | Groups modules under a plugin with shared metadata |
97
- | `ModuleContext` | Context injected by Nexus: `db`, `logger`, `helpers`, `events`, `mail` |
109
+ | `ModuleContext` | Context injected by Nexus: `db`, `logger`, `helpers`, `services`, `abilities` |
98
110
  | `ModuleEntity` | Declarative entity configuration for CRUD UI |
99
111
  | `FormField` | Form field configuration with validation |
100
112
 
113
+ ### Request & Auth
114
+
115
+ | Type | Description |
116
+ |------|-------------|
117
+ | `AuthRequest<TUser, TAbility>` | Generic authenticated request |
118
+ | `PluginAuthRequest` | Pre-typed `AuthRequest<BaseUser, AbilityLike>` for plugins |
119
+ | `BaseUser` | User without password for relations |
120
+ | `AbilityLike` | Generic CASL ability interface (`can`, `cannot`) |
121
+
122
+ ### Services & Utilities
123
+
124
+ | Type | Description |
125
+ |------|-------------|
126
+ | `CoreServices` | Core services container (`users`, etc.) |
127
+ | `UsersResolver` | User lookup service (`findById`, `findByIds`) |
128
+ | `PaginationParams` | Pagination input (`page`, `limit`) |
129
+ | `PaginatedResult<T>` | Paginated response with metadata |
130
+ | `ValidationSchema` | Generic Zod-compatible schema interface |
131
+
101
132
  ### ModuleContext
102
133
 
103
134
  The context provides access to:
@@ -106,11 +137,20 @@ The context provides access to:
106
137
  - `logger` - Pino logger
107
138
  - `helpers` - Migration utilities (`addTimestamps`, `addColumnIfMissing`)
108
139
  - `createRouter()` - Express Router factory
109
- - `middleware` - Registered middlewares (includes `validate`)
140
+ - `middleware.validate()` - Request validation with Zod schemas
110
141
  - `errors` - Error classes (`AppError`, `NotFoundError`, `UnauthorizedError`, etc.)
142
+ - `abilities` - CASL helpers (`subject`, `ForbiddenError`)
143
+ - `services` - Core services (`users.findById`, `users.findByIds`)
111
144
  - `events` - EventEmitter for inter-module communication
112
145
  - `mail` - Email sending service
113
146
 
147
+ ### Re-exported Types
148
+
149
+ For convenience, the SDK re-exports commonly used types:
150
+
151
+ - **Express:** `Request`, `Response`, `NextFunction`, `RequestHandler`, `Router`, `CookieOptions`
152
+ - **Knex:** `Knex`, `KnexCreateTableBuilder`, `KnexAlterTableBuilder`, `KnexTransaction`
153
+
114
154
  ## License
115
155
 
116
156
  MIT © [Gonzalo Díez](https://www.gzl10.com)
package/dist/index.d.ts CHANGED
@@ -4,6 +4,80 @@ 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
+ * Genera código de migración Knex desde EntityDefinition
16
+ *
17
+ * @example
18
+ * const code = generateMigration(postEntity)
19
+ * // Genera:
20
+ * // import type { ModuleContext, Knex } from '@gzl10/nexus-sdk'
21
+ * //
22
+ * // export async function migrate(ctx: ModuleContext): Promise<void> {
23
+ * // const { db, logger, helpers } = ctx
24
+ * // ...
25
+ * // }
26
+ */
27
+ declare function generateMigration(entity: EntityDefinition): string;
28
+ /**
29
+ * Genera código de schemas Zod desde EntityDefinition
30
+ *
31
+ * @example
32
+ * const code = generateZodSchema(postEntity)
33
+ * // Genera createPostSchema, updatePostSchema, postParamsSchema, postQuerySchema
34
+ */
35
+ declare function generateZodSchema(entity: EntityDefinition): string;
36
+ /**
37
+ * Genera interface TypeScript desde EntityDefinition
38
+ *
39
+ * @example
40
+ * const code = generateModel(postEntity)
41
+ * // Genera:
42
+ * // export interface Post {
43
+ * // id: string
44
+ * // title: string
45
+ * // ...
46
+ * // }
47
+ */
48
+ declare function generateModel(entity: EntityDefinition): string;
49
+ /**
50
+ * Estructura de permiso generado para insertar en BD
51
+ */
52
+ interface GeneratedPermission {
53
+ role: string;
54
+ action: string;
55
+ subject: string;
56
+ conditions: string | null;
57
+ fields: string | null;
58
+ inverted: boolean;
59
+ }
60
+ /**
61
+ * Genera permisos CASL desde EntityDefinition
62
+ *
63
+ * @example
64
+ * const permissions = generateCaslPermissions(postEntity)
65
+ * // Genera array de permisos para insertar en rol_role_permissions
66
+ */
67
+ declare function generateCaslPermissions(entity: EntityDefinition): GeneratedPermission[];
68
+ /**
69
+ * Genera código de seed para permisos CASL
70
+ *
71
+ * @example
72
+ * const code = generateCaslSeed([postEntity, pageEntity])
73
+ * // Genera código para insertar permisos en rol_role_permissions
74
+ */
75
+ declare function generateCaslSeed(entities: EntityDefinition[]): string;
76
+ /**
77
+ * Obtiene el subject CASL de una entidad
78
+ */
79
+ declare function getEntitySubject(entity: EntityDefinition): string;
80
+
7
81
  /**
8
82
  * @gzl10/nexus-sdk
9
83
  *
@@ -93,18 +167,307 @@ interface FormField {
93
167
  */
94
168
  type ListType = 'table' | 'list' | 'grid' | 'masonry';
95
169
  /**
96
- * Configuración de una entidad/tabla del módulo para UI CRUD
170
+ * Tipos de columna en base de datos
171
+ */
172
+ type DbType = 'string' | 'text' | 'integer' | 'decimal' | 'boolean' | 'date' | 'datetime' | 'json' | 'uuid';
173
+ /**
174
+ * Tipos de input en UI
175
+ */
176
+ type InputType = 'text' | 'email' | 'password' | 'url' | 'tel' | 'number' | 'decimal' | 'textarea' | 'markdown' | 'select' | 'multiselect' | 'checkbox' | 'switch' | 'date' | 'datetime' | 'file' | 'image' | 'hidden';
177
+ /**
178
+ * Configuración de base de datos para un campo
97
179
  */
98
- interface ModuleEntity {
180
+ interface FieldDbConfig {
181
+ type: DbType;
182
+ size?: number;
183
+ precision?: [number, number];
184
+ nullable: boolean;
185
+ unique?: boolean;
186
+ default?: unknown;
187
+ defaultFn?: 'now' | 'uuid';
188
+ index?: boolean;
189
+ }
190
+ /**
191
+ * Configuración de relación (Foreign Key)
192
+ */
193
+ interface FieldRelation {
194
+ table: string;
195
+ column?: string;
196
+ onDelete?: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION';
197
+ onUpdate?: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION';
198
+ }
199
+ /**
200
+ * Configuración de validación para un campo
201
+ */
202
+ interface FieldValidationConfig {
203
+ required?: boolean;
204
+ min?: number;
205
+ max?: number;
206
+ pattern?: string;
207
+ format?: 'email' | 'url' | 'uuid' | 'slug';
208
+ enum?: string[];
209
+ }
210
+ /**
211
+ * Opciones para campos select/relaciones
212
+ */
213
+ interface FieldOptions {
214
+ endpoint?: string;
215
+ valueField?: string;
216
+ labelField?: string;
217
+ static?: Array<{
218
+ value: string;
219
+ label: string;
220
+ }>;
221
+ }
222
+ /**
223
+ * Restricciones de acceso CASL para un campo
224
+ */
225
+ interface FieldCaslAccess {
226
+ /** Roles que pueden leer este campo (vacío = todos, null = nadie excepto ADMIN) */
227
+ readRoles?: string[];
228
+ /** Roles que pueden escribir este campo (vacío = todos, null = nadie excepto ADMIN) */
229
+ writeRoles?: string[];
230
+ }
231
+ /**
232
+ * Metadata del campo
233
+ */
234
+ interface FieldMeta {
235
+ sortable?: boolean;
236
+ searchable?: boolean;
237
+ exportable?: boolean;
238
+ auditField?: 'created_by' | 'updated_by';
239
+ /** Restricciones de acceso CASL para este campo */
240
+ casl?: FieldCaslAccess;
241
+ }
242
+ /**
243
+ * Definición completa de un campo
244
+ *
245
+ * Contiene toda la información necesaria para:
246
+ * - UI: formularios, listas
247
+ * - BD: migraciones Knex
248
+ * - Validación: schemas Zod
249
+ */
250
+ interface FieldDefinition {
99
251
  name: string;
100
252
  label: string;
253
+ input: InputType;
254
+ placeholder?: string;
255
+ hint?: string;
256
+ hidden?: boolean;
257
+ disabled?: boolean;
258
+ db: FieldDbConfig;
259
+ relation?: FieldRelation;
260
+ validation?: FieldValidationConfig;
261
+ options?: FieldOptions;
262
+ meta?: FieldMeta;
263
+ }
264
+ /**
265
+ * Índice compuesto de tabla
266
+ */
267
+ interface EntityIndex {
268
+ columns: string[];
269
+ unique?: boolean;
270
+ }
271
+ /**
272
+ * Acciones CASL estándar
273
+ */
274
+ type CaslAction = 'manage' | 'create' | 'read' | 'update' | 'delete';
275
+ /**
276
+ * Condición de ownership para permisos
277
+ * El campo de la entidad que referencia al usuario
278
+ */
279
+ interface OwnershipCondition {
280
+ /** Campo que contiene el ID del propietario (ej: 'author_id', 'created_by') */
281
+ field: string;
282
+ /** Variable del usuario a comparar (default: 'id') */
283
+ userField?: string;
284
+ }
285
+ /**
286
+ * Permiso CASL para un rol específico
287
+ */
288
+ interface RolePermission {
289
+ /** Acciones permitidas */
290
+ actions: CaslAction[];
291
+ /** Condiciones (ownership, status, etc.) */
292
+ conditions?: Record<string, unknown>;
293
+ /** Campos permitidos (null = todos) */
294
+ fields?: string[] | null;
295
+ /** Invertir permiso (cannot en lugar de can) */
296
+ inverted?: boolean;
297
+ }
298
+ /**
299
+ * Configuración de autorización CASL para una entidad
300
+ */
301
+ interface EntityCaslConfig {
302
+ /**
303
+ * Subject CASL (default: inferido de table → 'cms_posts' → 'CmsPost')
304
+ */
305
+ subject?: string;
306
+ /**
307
+ * Campo de ownership para conditions automáticas
308
+ * Si se define, se genera condition { [field]: '${user.id}' }
309
+ */
310
+ ownership?: OwnershipCondition;
311
+ /**
312
+ * Permisos por rol
313
+ * Las keys son nombres de roles (ADMIN, EDITOR, VIEWER, etc.)
314
+ */
315
+ permissions?: Record<string, RolePermission>;
316
+ /**
317
+ * Campos sensibles que requieren permiso especial
318
+ * Se excluyen de 'read' para roles sin acceso explícito
319
+ */
320
+ sensitiveFields?: string[];
321
+ }
322
+ /**
323
+ * Definición completa de una entidad
324
+ *
325
+ * Contiene toda la información necesaria para:
326
+ * - Generar migraciones Knex
327
+ * - Generar schemas Zod
328
+ * - Generar tipos TypeScript
329
+ * - Configurar UI CRUD
330
+ * - Configurar permisos CASL
331
+ */
332
+ interface EntityDefinition {
333
+ table: string;
334
+ label: string;
335
+ labelField: string;
336
+ fields: Record<string, FieldDefinition>;
337
+ timestamps?: boolean;
338
+ audit?: boolean;
339
+ indexes?: EntityIndex[];
340
+ casl?: EntityCaslConfig;
341
+ }
342
+ /**
343
+ * Propiedades base compartidas por todas las entidades
344
+ */
345
+ interface BaseEntity {
346
+ name: string;
347
+ label: string;
348
+ }
349
+ /**
350
+ * Entidad de colección - CRUD completo (users, posts, orders)
351
+ * Default cuando no se especifica type
352
+ */
353
+ interface CollectionEntity extends BaseEntity {
354
+ type?: 'collection';
355
+ listFields: Record<string, string>;
356
+ formFields?: Record<string, FormField>;
357
+ labelField: string;
358
+ editMode?: 'modal' | 'page';
359
+ listType?: ListType;
360
+ }
361
+ /**
362
+ * Entidad de referencia - Catálogos con CRUD admin (countries, currencies)
363
+ */
364
+ interface ReferenceEntity extends BaseEntity {
365
+ type: 'reference';
366
+ listFields: Record<string, string>;
367
+ formFields?: Record<string, FormField>;
368
+ labelField: string;
369
+ editMode?: 'modal' | 'page';
370
+ listType?: ListType;
371
+ }
372
+ /**
373
+ * Entidad temporal - CRUD con TTL (otp_codes, cache)
374
+ */
375
+ interface TempEntity extends BaseEntity {
376
+ type: 'temp';
101
377
  listFields: Record<string, string>;
102
378
  formFields?: Record<string, FormField>;
103
379
  labelField: string;
104
- routePrefix?: string;
105
380
  editMode?: 'modal' | 'page';
106
381
  listType?: ListType;
107
382
  }
383
+ /**
384
+ * Entidad singleton - Solo Update/Read, sin lista (site_config)
385
+ */
386
+ interface SingleEntity extends BaseEntity {
387
+ type: 'single';
388
+ formFields?: Record<string, FormField>;
389
+ }
390
+ /**
391
+ * Entidad de configuración - Config por módulo, singleton
392
+ */
393
+ interface ConfigEntity extends BaseEntity {
394
+ type: 'config';
395
+ formFields?: Record<string, FormField>;
396
+ }
397
+ /**
398
+ * Entidad externa - Read-only desde sistemas externos (stripe_customers)
399
+ */
400
+ interface ExternalEntity extends BaseEntity {
401
+ type: 'external';
402
+ listFields: Record<string, string>;
403
+ labelField: string;
404
+ listType?: ListType;
405
+ }
406
+ /**
407
+ * Entidad virtual - Orquestación de múltiples fuentes (unified_customers)
408
+ */
409
+ interface VirtualEntity extends BaseEntity {
410
+ type: 'virtual';
411
+ listFields: Record<string, string>;
412
+ labelField: string;
413
+ listType?: ListType;
414
+ }
415
+ /**
416
+ * Entidad computada - KPIs, estadísticas calculadas
417
+ */
418
+ interface ComputedEntity extends BaseEntity {
419
+ type: 'computed';
420
+ listFields: Record<string, string>;
421
+ labelField: string;
422
+ listType?: ListType;
423
+ }
424
+ /**
425
+ * Entidad vista - Proyección optimizada para lectura
426
+ */
427
+ interface ViewEntity extends BaseEntity {
428
+ type: 'view';
429
+ listFields: Record<string, string>;
430
+ labelField: string;
431
+ listType?: ListType;
432
+ }
433
+ /**
434
+ * Entidad de eventos - Logs de auditoría, append-only
435
+ */
436
+ interface EventEntity extends BaseEntity {
437
+ type: 'event';
438
+ listFields: Record<string, string>;
439
+ labelField: string;
440
+ listType?: ListType;
441
+ }
442
+ /**
443
+ * Entidad de acción - Comandos/operaciones sin persistencia
444
+ */
445
+ interface ActionEntity extends BaseEntity {
446
+ type: 'action';
447
+ formFields?: Record<string, FormField>;
448
+ }
449
+ /**
450
+ * Unión discriminada de todos los tipos de entidad
451
+ *
452
+ * | Type | Persistencia | CRUD | Uso principal |
453
+ * |------------|--------------|-----------------|----------------------------------|
454
+ * | collection | Sí (BD) | Completo | Datos de negocio (users, posts) |
455
+ * | single | Sí (BD) | Update/Read | Config global (site_config) |
456
+ * | external | No | Read | Datos externos (stripe_customers)|
457
+ * | virtual | No | Read | Orquestación (unified_customers) |
458
+ * | computed | No/opcional | Read | KPIs, estadísticas |
459
+ * | view | Sí/virtual | Read | Lectura optimizada (projections) |
460
+ * | reference | Sí | Read (admin) | Catálogos (countries, currencies)|
461
+ * | config | Sí | Update/Read | Config por módulo |
462
+ * | event | Sí | Append | Auditoría (audit_logs) |
463
+ * | temp | No (TTL) | Read/Write | Cache, sesiones (otp_codes) |
464
+ * | action | No | Execute | Operaciones, workflows |
465
+ */
466
+ type ModuleEntity = CollectionEntity | ReferenceEntity | TempEntity | SingleEntity | ConfigEntity | ExternalEntity | VirtualEntity | ComputedEntity | ViewEntity | EventEntity | ActionEntity;
467
+ /**
468
+ * Helper type para extraer el tipo de entidad
469
+ */
470
+ type EntityType = ModuleEntity['type'];
108
471
  /**
109
472
  * Requisitos para activar un módulo
110
473
  */
@@ -130,7 +493,7 @@ interface ForbiddenErrorInstance {
130
493
  * Constructor de ForbiddenError con método from()
131
494
  */
132
495
  interface ForbiddenErrorConstructor {
133
- from: (ability: AbilityLike) => ForbiddenErrorInstance;
496
+ from: (ability: any) => ForbiddenErrorInstance;
134
497
  }
135
498
  /**
136
499
  * Abilities CASL disponibles en el contexto
@@ -138,11 +501,9 @@ interface ForbiddenErrorConstructor {
138
501
  */
139
502
  interface ModuleAbilities {
140
503
  /** Wrapper para verificar permisos contra instancias */
141
- subject: (type: string, object: unknown) => unknown;
504
+ subject: (type: string, object: Record<string, any>) => unknown;
142
505
  /** Error de CASL para throwUnlessCan */
143
506
  ForbiddenError: ForbiddenErrorConstructor;
144
- /** Otros métodos que el backend pueda añadir */
145
- [key: string]: unknown;
146
507
  }
147
508
  /**
148
509
  * AuthRequest pre-tipado para uso en plugins
@@ -206,6 +567,7 @@ interface ModuleContext {
206
567
  createRouter: () => Router;
207
568
  middleware: ModuleMiddlewares;
208
569
  registerMiddleware: (name: string, handler: RequestHandler) => void;
570
+ /** Configuración resuelta de la aplicación */
209
571
  config: Record<string, unknown>;
210
572
  errors: {
211
573
  AppError: new (message: string, statusCode?: number) => Error;
@@ -227,7 +589,7 @@ interface ModuleContext {
227
589
  text?: string;
228
590
  template?: string;
229
591
  data?: Record<string, unknown>;
230
- }) => Promise<void>;
592
+ }) => Promise<unknown>;
231
593
  };
232
594
  /** Servicios de módulos core (users, etc.) */
233
595
  services: CoreServices & Record<string, unknown>;
@@ -264,7 +626,16 @@ interface ModuleManifest {
264
626
  routePrefix?: string;
265
627
  /** Subjects CASL adicionales (sin entity). Los subjects de entities se infieren automáticamente */
266
628
  additionalSubjects?: string[];
267
- /** Entidades/tablas del módulo con config CRUD para UI. Sus `name` se registran como subjects CASL */
629
+ /**
630
+ * Definiciones de entidades (nuevo sistema unificado).
631
+ * Single source of truth para BD, validación, UI y CASL.
632
+ * Sus subjects se registran automáticamente.
633
+ */
634
+ definitions?: EntityDefinition[];
635
+ /**
636
+ * @deprecated Usar `definitions` en su lugar.
637
+ * Entidades/tablas del módulo con config CRUD para UI.
638
+ */
268
639
  entities?: ModuleEntity[];
269
640
  }
270
641
  /**
@@ -293,4 +664,4 @@ interface PluginManifest {
293
664
  modules: ModuleManifest[];
294
665
  }
295
666
 
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 };
667
+ export { type AbilityLike, type ActionEntity, type AuthRequest, type BaseUser, type CaslAction, type CollectionEntity, type ComputedEntity, type ConfigEntity, type CoreServices, type DbType, type EntityCaslConfig, type EntityDefinition, type EntityIndex, type EntityType, type EventEntity, type ExternalEntity, 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 ModuleEntity, type ModuleManifest, type ModuleMiddlewares, type ModuleRequirements, type OwnershipCondition, type PaginatedResult, type PaginationParams, type PluginAuthRequest, type PluginCategory, type PluginManifest, type ReferenceEntity, type RolePermission, type SingleEntity, type TempEntity, type UsersResolver, type ValidateSchemas, type ValidationSchema, type ViewEntity, type VirtualEntity, generateCaslPermissions, generateCaslSeed, generateMigration, generateModel, generateZodSchema, getEntitySubject };
package/dist/index.js CHANGED
@@ -0,0 +1,440 @@
1
+ // src/generators.ts
2
+ function generateMigration(entity) {
3
+ const { table, fields, timestamps, audit, indexes } = entity;
4
+ const lines = [
5
+ `import type { ModuleContext, Knex } from '@gzl10/nexus-sdk'`,
6
+ ``,
7
+ `export async function migrate(ctx: ModuleContext): Promise<void> {`,
8
+ ` const { db, logger, helpers } = ctx`
9
+ ];
10
+ if (timestamps) {
11
+ lines.push(` const { addTimestamps } = helpers`);
12
+ }
13
+ if (audit) {
14
+ lines.push(` const { addAuditFieldsIfMissing } = helpers`);
15
+ }
16
+ lines.push(``);
17
+ lines.push(` if (!(await db.schema.hasTable('${table}'))) {`);
18
+ lines.push(` await db.schema.createTable('${table}', (table: Knex.CreateTableBuilder) => {`);
19
+ for (const [name, field] of Object.entries(fields)) {
20
+ const columnCode = generateColumnCode(name, field);
21
+ lines.push(` ${columnCode}`);
22
+ }
23
+ if (timestamps) {
24
+ lines.push(` addTimestamps(table, db)`);
25
+ }
26
+ if (indexes?.length) {
27
+ lines.push(``);
28
+ for (const idx of indexes) {
29
+ if (idx.unique) {
30
+ lines.push(` table.unique([${idx.columns.map((c) => `'${c}'`).join(", ")}])`);
31
+ } else {
32
+ lines.push(` table.index([${idx.columns.map((c) => `'${c}'`).join(", ")}])`);
33
+ }
34
+ }
35
+ }
36
+ lines.push(` })`);
37
+ lines.push(` logger.info('Created table: ${table}')`);
38
+ lines.push(` }`);
39
+ if (audit) {
40
+ lines.push(``);
41
+ lines.push(` await addAuditFieldsIfMissing(db, '${table}')`);
42
+ }
43
+ lines.push(`}`);
44
+ lines.push(``);
45
+ return lines.join("\n");
46
+ }
47
+ function generateColumnCode(name, field) {
48
+ const { db, relation } = field;
49
+ let code = "";
50
+ switch (db.type) {
51
+ case "string":
52
+ code = db.size ? `table.string('${name}', ${db.size})` : `table.string('${name}')`;
53
+ break;
54
+ case "text":
55
+ code = `table.text('${name}')`;
56
+ break;
57
+ case "integer":
58
+ code = `table.integer('${name}')`;
59
+ break;
60
+ case "decimal":
61
+ if (db.precision) {
62
+ code = `table.decimal('${name}', ${db.precision[0]}, ${db.precision[1]})`;
63
+ } else {
64
+ code = `table.decimal('${name}')`;
65
+ }
66
+ break;
67
+ case "boolean":
68
+ code = `table.boolean('${name}')`;
69
+ break;
70
+ case "date":
71
+ code = `table.date('${name}')`;
72
+ break;
73
+ case "datetime":
74
+ code = `table.timestamp('${name}')`;
75
+ break;
76
+ case "json":
77
+ code = `table.json('${name}')`;
78
+ break;
79
+ case "uuid":
80
+ code = `table.uuid('${name}')`;
81
+ break;
82
+ default:
83
+ code = `table.string('${name}')`;
84
+ }
85
+ if (name === "id") {
86
+ code += `.primary()`;
87
+ }
88
+ if (db.nullable) {
89
+ code += `.nullable()`;
90
+ } else {
91
+ code += `.notNullable()`;
92
+ }
93
+ if (db.unique) {
94
+ code += `.unique()`;
95
+ }
96
+ if (db.default !== void 0) {
97
+ if (typeof db.default === "string") {
98
+ code += `.defaultTo('${db.default}')`;
99
+ } else {
100
+ code += `.defaultTo(${JSON.stringify(db.default)})`;
101
+ }
102
+ } else if (db.defaultFn === "now") {
103
+ code += `.defaultTo(db.fn.now())`;
104
+ } else if (db.defaultFn === "uuid") {
105
+ code += `.defaultTo(db.raw('gen_random_uuid()'))`;
106
+ }
107
+ if (relation) {
108
+ const col = relation.column ?? "id";
109
+ code += `.references('${col}').inTable('${relation.table}')`;
110
+ if (relation.onDelete) {
111
+ code += `.onDelete('${relation.onDelete}')`;
112
+ }
113
+ if (relation.onUpdate) {
114
+ code += `.onUpdate('${relation.onUpdate}')`;
115
+ }
116
+ }
117
+ if (db.index && !db.unique && name !== "id") {
118
+ }
119
+ return code;
120
+ }
121
+ function generateZodSchema(entity) {
122
+ const { table, fields } = entity;
123
+ const entityName = tableToEntityName(table);
124
+ const lines = [
125
+ `import { z } from 'zod'`,
126
+ ``
127
+ ];
128
+ lines.push(`// === CREATE ===`);
129
+ lines.push(`export const create${entityName}Schema = z.object({`);
130
+ for (const [name, field] of Object.entries(fields)) {
131
+ if (name === "id" || field.meta?.auditField) continue;
132
+ const zodCode = generateZodFieldCode(field, "create");
133
+ if (zodCode) {
134
+ lines.push(` ${name}: ${zodCode},`);
135
+ }
136
+ }
137
+ lines.push(`})`);
138
+ lines.push(``);
139
+ lines.push(`// === UPDATE ===`);
140
+ lines.push(`export const update${entityName}Schema = z.object({`);
141
+ for (const [name, field] of Object.entries(fields)) {
142
+ if (name === "id" || field.meta?.auditField) continue;
143
+ const zodCode = generateZodFieldCode(field, "update");
144
+ if (zodCode) {
145
+ lines.push(` ${name}: ${zodCode},`);
146
+ }
147
+ }
148
+ lines.push(`})`);
149
+ lines.push(``);
150
+ lines.push(`// === PARAMS ===`);
151
+ lines.push(`export const ${entityName.toLowerCase()}ParamsSchema = z.object({`);
152
+ lines.push(` id: z.string(),`);
153
+ lines.push(`})`);
154
+ lines.push(``);
155
+ lines.push(`// === QUERY ===`);
156
+ lines.push(`export const ${entityName.toLowerCase()}QuerySchema = z.object({`);
157
+ lines.push(` page: z.coerce.number().int().min(1).default(1),`);
158
+ lines.push(` limit: z.coerce.number().int().min(1).max(100).default(20),`);
159
+ for (const [name, field] of Object.entries(fields)) {
160
+ if (field.meta?.searchable) {
161
+ const zodType = dbTypeToZodType(field.db.type);
162
+ lines.push(` ${name}: ${zodType}.optional(),`);
163
+ }
164
+ }
165
+ lines.push(`})`);
166
+ lines.push(``);
167
+ lines.push(`// === INFERRED TYPES ===`);
168
+ lines.push(`export type Create${entityName}Input = z.infer<typeof create${entityName}Schema>`);
169
+ lines.push(`export type Update${entityName}Input = z.infer<typeof update${entityName}Schema>`);
170
+ lines.push(`export type ${entityName}Params = z.infer<typeof ${entityName.toLowerCase()}ParamsSchema>`);
171
+ lines.push(`export type ${entityName}Query = z.infer<typeof ${entityName.toLowerCase()}QuerySchema>`);
172
+ lines.push(``);
173
+ return lines.join("\n");
174
+ }
175
+ function generateZodFieldCode(field, mode) {
176
+ const { db, validation } = field;
177
+ let code = dbTypeToZodType(db.type);
178
+ if (validation) {
179
+ if (validation.format === "email") {
180
+ code += `.email()`;
181
+ } else if (validation.format === "url") {
182
+ code += `.url()`;
183
+ } else if (validation.format === "uuid") {
184
+ code += `.uuid()`;
185
+ }
186
+ if (validation.min !== void 0) {
187
+ if (db.type === "string" || db.type === "text") {
188
+ code += `.min(${validation.min})`;
189
+ } else if (db.type === "integer" || db.type === "decimal") {
190
+ code += `.min(${validation.min})`;
191
+ }
192
+ }
193
+ if (validation.max !== void 0) {
194
+ if (db.type === "string" || db.type === "text") {
195
+ code += `.max(${validation.max})`;
196
+ } else if (db.type === "integer" || db.type === "decimal") {
197
+ code += `.max(${validation.max})`;
198
+ }
199
+ }
200
+ if (validation.pattern) {
201
+ code += `.regex(/${validation.pattern}/)`;
202
+ }
203
+ if (validation.enum?.length) {
204
+ code = `z.enum([${validation.enum.map((v) => `'${v}'`).join(", ")}])`;
205
+ }
206
+ }
207
+ if (db.nullable) {
208
+ code += `.nullable()`;
209
+ }
210
+ if (mode === "update") {
211
+ code += `.optional()`;
212
+ } else if (mode === "create" && !validation?.required && db.default !== void 0) {
213
+ code += `.optional()`;
214
+ }
215
+ return code;
216
+ }
217
+ function dbTypeToZodType(dbType) {
218
+ switch (dbType) {
219
+ case "string":
220
+ case "text":
221
+ case "uuid":
222
+ return "z.string()";
223
+ case "integer":
224
+ return "z.number().int()";
225
+ case "decimal":
226
+ return "z.number()";
227
+ case "boolean":
228
+ return "z.boolean()";
229
+ case "date":
230
+ case "datetime":
231
+ return "z.string().datetime()";
232
+ case "json":
233
+ return "z.record(z.unknown())";
234
+ default:
235
+ return "z.string()";
236
+ }
237
+ }
238
+ function generateModel(entity) {
239
+ const { table, fields, timestamps, audit } = entity;
240
+ const entityName = tableToEntityName(table);
241
+ const lines = [
242
+ `/**`,
243
+ ` * ${entity.label}`,
244
+ ` * Generated from EntityDefinition`,
245
+ ` */`,
246
+ `export interface ${entityName} {`
247
+ ];
248
+ for (const [name, field] of Object.entries(fields)) {
249
+ const tsType = dbTypeToTsType(field.db);
250
+ const optional = field.db.nullable ? "?" : "";
251
+ lines.push(` ${name}${optional}: ${tsType}`);
252
+ }
253
+ if (timestamps) {
254
+ lines.push(` created_at: Date`);
255
+ lines.push(` updated_at: Date`);
256
+ }
257
+ if (audit) {
258
+ lines.push(` created_by: string | null`);
259
+ lines.push(` updated_by: string | null`);
260
+ }
261
+ lines.push(`}`);
262
+ lines.push(``);
263
+ return lines.join("\n");
264
+ }
265
+ function dbTypeToTsType(db) {
266
+ switch (db.type) {
267
+ case "string":
268
+ case "text":
269
+ case "uuid":
270
+ return "string";
271
+ case "integer":
272
+ case "decimal":
273
+ return "number";
274
+ case "boolean":
275
+ return "boolean";
276
+ case "date":
277
+ case "datetime":
278
+ return "Date";
279
+ case "json":
280
+ return "Record<string, unknown>";
281
+ default:
282
+ return "unknown";
283
+ }
284
+ }
285
+ function tableToEntityName(table) {
286
+ const withoutPrefix = table.replace(/^[a-z]{2,4}_/, "");
287
+ const singular = withoutPrefix.endsWith("ies") ? withoutPrefix.slice(0, -3) + "y" : withoutPrefix.endsWith("s") ? withoutPrefix.slice(0, -1) : withoutPrefix;
288
+ return singular.charAt(0).toUpperCase() + singular.slice(1);
289
+ }
290
+ function tableToSubject(table) {
291
+ const match = table.match(/^([a-z]{2,4})_(.+)$/);
292
+ if (!match) return tableToEntityName(table);
293
+ const [, prefix, rest] = match;
294
+ const prefixPascal = prefix.charAt(0).toUpperCase() + prefix.slice(1);
295
+ const singular = rest.endsWith("ies") ? rest.slice(0, -3) + "y" : rest.endsWith("s") ? rest.slice(0, -1) : rest;
296
+ const restPascal = singular.charAt(0).toUpperCase() + singular.slice(1);
297
+ return prefixPascal + restPascal;
298
+ }
299
+ function getFieldsForRole(entity, role, action) {
300
+ const { fields, casl } = entity;
301
+ const isReadAction = action === "read";
302
+ const isWriteAction = action === "create" || action === "update";
303
+ if (action === "manage") return null;
304
+ const allowedFields = [];
305
+ const sensitiveFields = casl?.sensitiveFields ?? [];
306
+ for (const [fieldName, field] of Object.entries(fields)) {
307
+ const fieldCasl = field.meta?.casl;
308
+ if (sensitiveFields.includes(fieldName) && role !== "ADMIN") {
309
+ continue;
310
+ }
311
+ if (isReadAction) {
312
+ if (fieldCasl?.readRoles !== void 0) {
313
+ if (fieldCasl.readRoles.length === 0 || fieldCasl.readRoles.includes(role)) {
314
+ allowedFields.push(fieldName);
315
+ }
316
+ } else {
317
+ allowedFields.push(fieldName);
318
+ }
319
+ } else if (isWriteAction) {
320
+ if (fieldCasl?.writeRoles !== void 0) {
321
+ if (fieldCasl.writeRoles.length === 0 || fieldCasl.writeRoles.includes(role)) {
322
+ allowedFields.push(fieldName);
323
+ }
324
+ } else {
325
+ allowedFields.push(fieldName);
326
+ }
327
+ } else {
328
+ return null;
329
+ }
330
+ }
331
+ const allFieldNames = Object.keys(fields).filter((f) => !sensitiveFields.includes(f) || role === "ADMIN");
332
+ if (allowedFields.length === allFieldNames.length) {
333
+ return null;
334
+ }
335
+ return allowedFields.length > 0 ? allowedFields : null;
336
+ }
337
+ function generateCaslPermissions(entity) {
338
+ const { table, fields, casl } = entity;
339
+ if (!casl) return [];
340
+ const subject = casl.subject ?? tableToSubject(table);
341
+ const permissions = [];
342
+ for (const [role, config] of Object.entries(casl.permissions ?? {})) {
343
+ for (const action of config.actions) {
344
+ let conditions = config.conditions ?? null;
345
+ if (casl.ownership && (action === "update" || action === "delete") && !conditions) {
346
+ const userField = casl.ownership.userField ?? "id";
347
+ conditions = { [casl.ownership.field]: `\${user.${userField}}` };
348
+ }
349
+ let permFields = config.fields ?? null;
350
+ if (!permFields) {
351
+ permFields = getFieldsForRole(entity, role, action);
352
+ }
353
+ permissions.push({
354
+ role,
355
+ action,
356
+ subject,
357
+ conditions: conditions ? JSON.stringify(conditions) : null,
358
+ fields: permFields ? JSON.stringify(permFields) : null,
359
+ inverted: config.inverted ?? false
360
+ });
361
+ }
362
+ }
363
+ return permissions;
364
+ }
365
+ function generateCaslSeed(entities) {
366
+ const allPermissions = [];
367
+ for (const entity of entities) {
368
+ allPermissions.push(...generateCaslPermissions(entity));
369
+ }
370
+ if (allPermissions.length === 0) {
371
+ return "// No CASL permissions defined in entities\n";
372
+ }
373
+ const lines = [
374
+ `import type { ModuleContext } from '@gzl10/nexus-sdk'`,
375
+ ``,
376
+ `/**`,
377
+ ` * Seed de permisos CASL generado desde EntityDefinition`,
378
+ ` */`,
379
+ `export async function seedPermissions(ctx: ModuleContext): Promise<void> {`,
380
+ ` const { db, generateId, logger } = ctx`,
381
+ ``,
382
+ ` // Obtener IDs de roles`,
383
+ ` const roles = await db('rol_roles').select('id', 'name')`,
384
+ ` const roleMap = Object.fromEntries(roles.map((r: { id: string; name: string }) => [r.name, r.id]))`,
385
+ ``,
386
+ ` const permissions = [`
387
+ ];
388
+ for (const perm of allPermissions) {
389
+ lines.push(` {`);
390
+ lines.push(` role: '${perm.role}',`);
391
+ lines.push(` action: '${perm.action}',`);
392
+ lines.push(` subject: '${perm.subject}',`);
393
+ lines.push(` conditions: ${perm.conditions ? `'${perm.conditions}'` : "null"},`);
394
+ lines.push(` fields: ${perm.fields ? `'${perm.fields}'` : "null"},`);
395
+ lines.push(` inverted: ${perm.inverted}`);
396
+ lines.push(` },`);
397
+ }
398
+ lines.push(` ]`);
399
+ lines.push(``);
400
+ lines.push(` for (const perm of permissions) {`);
401
+ lines.push(` const roleId = roleMap[perm.role]`);
402
+ lines.push(` if (!roleId) {`);
403
+ lines.push(` logger.warn({ role: perm.role }, 'Role not found, skipping permission')`);
404
+ lines.push(` continue`);
405
+ lines.push(` }`);
406
+ lines.push(``);
407
+ lines.push(` // Check if permission exists`);
408
+ lines.push(` const existing = await db('rol_role_permissions')`);
409
+ lines.push(` .where({ role_id: roleId, action: perm.action, subject: perm.subject })`);
410
+ lines.push(` .first()`);
411
+ lines.push(``);
412
+ lines.push(` if (!existing) {`);
413
+ lines.push(` await db('rol_role_permissions').insert({`);
414
+ lines.push(` id: generateId(),`);
415
+ lines.push(` role_id: roleId,`);
416
+ lines.push(` action: perm.action,`);
417
+ lines.push(` subject: perm.subject,`);
418
+ lines.push(` conditions: perm.conditions,`);
419
+ lines.push(` fields: perm.fields,`);
420
+ lines.push(` inverted: perm.inverted`);
421
+ lines.push(` })`);
422
+ lines.push(` }`);
423
+ lines.push(` }`);
424
+ lines.push(``);
425
+ lines.push(` logger.info('Seeded CASL permissions from EntityDefinitions')`);
426
+ lines.push(`}`);
427
+ lines.push(``);
428
+ return lines.join("\n");
429
+ }
430
+ function getEntitySubject(entity) {
431
+ return entity.casl?.subject ?? tableToSubject(entity.table);
432
+ }
433
+ export {
434
+ generateCaslPermissions,
435
+ generateCaslSeed,
436
+ generateMigration,
437
+ generateModel,
438
+ generateZodSchema,
439
+ getEntitySubject
440
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gzl10/nexus-sdk",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "SDK types for creating Nexus plugins and modules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",