From fcbae17866e13f7d278684a7491738d6d6cda382 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 10:05:55 -0800 Subject: [PATCH] feat(platform-service): implement P0-1 - Module generator script (pnpm gen:module) - Add gen-module.ts with full CLI interface - Generate types.ts, repository.ts, routes.ts, and test.ts - Support field types: string, number, boolean, date, datetime, enum, string[], number[] - Add --dry-run flag for preview - Register script in package.json --- services/platform-service/package.json | 3 +- .../platform-service/scripts/gen-module.ts | 518 ++++++++++++++++++ 2 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 services/platform-service/scripts/gen-module.ts diff --git a/services/platform-service/package.json b/services/platform-service/package.json index 7575699c..f70977ce 100644 --- a/services/platform-service/package.json +++ b/services/platform-service/package.json @@ -10,7 +10,8 @@ "start": "node dist/server.js", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint src/" + "lint": "eslint src/", + "gen:module": "tsx scripts/gen-module.ts" }, "dependencies": { "@azure/cosmos": "^4.2.0", diff --git a/services/platform-service/scripts/gen-module.ts b/services/platform-service/scripts/gen-module.ts new file mode 100644 index 00000000..11b931b4 --- /dev/null +++ b/services/platform-service/scripts/gen-module.ts @@ -0,0 +1,518 @@ +#!/usr/bin/env node +/** + * Module Generator Script + * + * Generates the standard platform-service module structure: + * types.ts → repository.ts → routes.ts → test.ts + * + * Usage: + * pnpm gen:module --name tasks --fields "title:string,status:enum(pending,active,done),priority:number" --partition userId + * + * @module platform-service/scripts/gen-module + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Parse CLI arguments +function parseArgs() { + const args = process.argv.slice(2); + const options = { + name: '', + fields: '', + partition: 'id', + dryRun: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--name' || arg === '-n') { + options.name = args[++i]; + } else if (arg === '--fields' || arg === '-f') { + options.fields = args[++i]; + } else if (arg === '--partition' || arg === '-p') { + options.partition = args[++i]; + } else if (arg === '--dry-run' || arg === '-d') { + options.dryRun = true; + } else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + if (!options.name || !options.fields) { + console.error('Error: --name and --fields are required'); + showHelp(); + process.exit(1); + } + + return options; +} + +function showHelp() { + console.log(` +Module Generator — Create platform-service module scaffolding + +Usage: + pnpm gen:module --name --fields "" --partition [--dry-run] + +Options: + --name, -n Module name (e.g., "tasks", "notifications") + --fields, -f Field definitions (e.g., "title:string,status:enum(pending,done)") + --partition, -p Partition key (default: "id") + --dry-run, -d Preview output without writing files + --help, -h Show this help + +Field Types: + string String field + number Numeric field + boolean Boolean field + date Date field (ISO string) + datetime DateTime field (ISO string with time) + enum(a,b,c) Enum with values a, b, c + string[] Array of strings + number[] Array of numbers + +Examples: + pnpm gen:module --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --partition userId + pnpm gen:module --name alerts --fields "message:string,level:enum(info,warn,error),metadata:object" --partition userId +`); +} + +// Parse field definitions +function parseFields(fieldsStr: string): Array<{ + name: string; + type: string; + optional: boolean; + zodType: string; + tsType: string; + enumValues: string[] | null; +}> { + const fields = []; + const fieldDefs = fieldsStr.split(',').map(f => f.trim()).filter(Boolean); + + for (const def of fieldDefs) { + const match = def.match(/^([a-zA-Z_][a-zA-Z0-9_]*):([a-z]+(?:\([^)]+\))?|\[?\w+\]?)\??$/); + if (!match) { + throw new Error(`Invalid field definition: ${def}`); + } + + const [, name, typeSpec] = match; + const optional = def.endsWith('?'); + + let type = typeSpec.replace('?', ''); + let zodType = ''; + let tsType = ''; + let enumValues = null; + + if (type.startsWith('enum(')) { + enumValues = type.slice(5, -1).split('|').map((v: string) => v.trim()); + zodType = `z.enum([${enumValues.map(v => `"${v}"`).join(', ')}])`; + tsType = enumValues.map(v => `"${v}"`).join(' | '); + } else if (type === 'string') { + zodType = 'z.string()'; + tsType = 'string'; + } else if (type === 'number') { + zodType = 'z.number()'; + tsType = 'number'; + } else if (type === 'boolean') { + zodType = 'z.boolean()'; + tsType = 'boolean'; + } else if (type === 'date' || type === 'datetime') { + zodType = 'z.string().datetime()'; + tsType = 'string'; + } else if (type === 'string[]') { + zodType = 'z.array(z.string())'; + tsType = 'string[]'; + } else if (type === 'number[]') { + zodType = 'z.array(z.number())'; + tsType = 'number[]'; + } else { + zodType = 'z.any()'; + tsType = 'unknown'; + } + + fields.push({ + name, + type, + optional, + zodType, + tsType, + enumValues, + }); + } + + return fields; +} + +// Generate types.ts content +function generateTypes(name: string, fields: ReturnType, partitionKey: string) { + const pascalName = name.charAt(0).toUpperCase() + name.slice(1); + const fieldSchemas = fields.map((f: ReturnType[0]) => + ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''}` + ).join(',\n'); + + const interfaceFields = fields.map((f: ReturnType[0]) => + ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};` + ).join('\n'); + + return `/** + * ${pascalName} Types + * + * @module platform-service/src/modules/${name}/types + */ + +import { z } from 'zod'; + +// ── MongoDB Document ID ───────────────────────────────────── +export const ObjectIdSchema = z.string().regex(/^[0-9a-fA-F]{24}$/, { + message: 'Invalid ObjectId format', +}); + +// ── Core ${pascalName} Schema ───────────────────────────────────── +export const ${pascalName}Schema = z.object({ + id: ObjectIdSchema, + productId: z.string(), + ${partitionKey}: z.string(), +${fieldSchemas}, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export type ${pascalName} = z.infer; + +// ── Create Input ──────────────────────────────────────────── +export const Create${pascalName}Schema = z.object({ +${fieldSchemas}, +}); + +export type Create${pascalName}Input = z.infer; + +// ── Update Input ──────────────────────────────────────────── +export const Update${pascalName}Schema = z.object({ +${fields.filter(f => f.name !== partitionKey).map(f => ` ${f.name}: ${f.zodType}.optional()`).join(',\n')}, +}); + +export type Update${pascalName}Input = z.infer; + +// ── Query Params ───────────────────────────────────────────── +export const Query${pascalName}Schema = z.object({ + ${partitionKey}: z.string().optional(), + limit: z.number().min(1).max(100).default(20).optional(), + offset: z.number().min(0).default(0).optional(), +}); + +export type Query${pascalName}Params = z.infer; +`; +} + +// Generate repository.ts content +function generateRepository(name: string, fields: ReturnType, partitionKey: string) { + const pascalName = name.charAt(0).toUpperCase() + name.slice(1); + const camelName = name.charAt(0).toLowerCase() + name.slice(1); + + return `/** + * ${pascalName} Repository + * + * @module platform-service/src/modules/${name}/repository + */ + +import { getContainer } from '../../lib/cosmos.js'; +import { + type ${pascalName}, + type Create${pascalName}Input, + type Update${pascalName}Input, + type Query${pascalName}Params, +} from './types.js'; + +const CONTAINER_NAME = '${camelName}'; + +// ── Create ──────────────────────────────────────────────────── +export async function create${pascalName}( + productId: string, + input: Create${pascalName}Input & { ${partitionKey}: string } +): Promise<${pascalName}> { + const container = await getContainer(CONTAINER_NAME); + + const doc = { + id: crypto.randomUUID(), + productId, + ...input, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const { resource } = await container.items.create(doc); + return resource as ${pascalName}; +} + +// ── Read ───────────────────────────────────────────────────── +export async function get${pascalName}( + id: string, + productId: string +): Promise<${pascalName} | null> { + const container = await getContainer(CONTAINER_NAME); + + try { + const { resource } = await container.item(id, productId).read(); + return resource as ${pascalName} | null; + } catch { + return null; + } +} + +// ── List ──────────────────────────────────────────────────── +export async function list${pascalName}s( + productId: string, + params: Query${pascalName}Params +): Promise<{ items: ${pascalName}[]; total: number }> { + const container = await getContainer(CONTAINER_NAME); + + const query = { + query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC', + parameters: [{ name: '@productId', value: productId }], + }; + + const { resources: items } = await container.items.query(query).fetchAll(); + + const countQuery = { + query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId', + parameters: [{ name: '@productId', value: productId }], + }; + const { resources: [total] } = await container.items.query(countQuery).fetchAll(); + + return { items: items as ${pascalName}[], total: total as number }; +} + +// ── Update ────────────────────────────────────────────────── +export async function update${pascalName}( + id: string, + productId: string, + input: Update${pascalName}Input +): Promise<${pascalName} | null> { + const container = await getContainer(CONTAINER_NAME); + + const existing = await get${pascalName}(id, productId); + if (!existing) return null; + + const doc = { + ...existing, + ...input, + updatedAt: new Date().toISOString(), + }; + + const { resource } = await container.items.upsert(doc); + return resource as ${pascalName}; +} + +// ── Delete ──────────────────────────────────────────────────── +export async function delete${pascalName}( + id: string, + productId: string +): Promise { + const container = await getContainer(CONTAINER_NAME); + + try { + await container.item(id, productId).delete(); + return true; + } catch { + return false; + } +} +`; +} + +// Generate routes.ts content +function generateRoutes(name: string, fields: ReturnType, partitionKey: string) { + const pascalName = name.charAt(0).toUpperCase() + name.slice(1); + const camelName = name.charAt(0).toLowerCase() + name.slice(1); + + return `/** + * ${pascalName} Routes + * + * @module platform-service/src/modules/${name}/routes + */ + +import type { FastifyInstance } from 'fastify'; +import { NotFoundError, BadRequestError } from '../../lib/errors.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { + Create${pascalName}Schema, + Update${pascalName}Schema, + Query${pascalName}Schema, +} from './types.js'; +import { + create${pascalName}, + get${pascalName}, + list${pascalName}s, + update${pascalName}, + delete${pascalName}, +} from './repository.js'; + +export async function ${camelName}Routes(app: FastifyInstance) { + // List ${camelName}s + app.get('/', async (req) => { + const productId = getRequestProductId(req); + const params = Query${pascalName}Schema.parse(req.query); + return list${pascalName}s(productId, params); + }); + + // Get ${camelName} by ID + app.get<{ Params: { id: string } }>('/:id', async (req) => { + const productId = getRequestProductId(req); + const item = await get${pascalName}(req.params.id, productId); + if (!item) throw new NotFoundError('${pascalName} not found'); + return item; + }); + + // Create ${camelName} + app.post('/', async (req) => { + const productId = getRequestProductId(req); + const body = Create${pascalName}Schema.parse(req.body); + return create${pascalName}(productId, { ...body, ${partitionKey}: req.user?.id || 'unknown' }); + }); + + // Update ${camelName} + app.patch<{ Params: { id: string } }>('/:id', async (req) => { + const productId = getRequestProductId(req); + const body = Update${pascalName}Schema.parse(req.body); + const item = await update${pascalName}(req.params.id, productId, body); + if (!item) throw new NotFoundError('${pascalName} not found'); + return item; + }); + + // Delete ${camelName} + app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => { + const productId = getRequestProductId(req); + const deleted = await delete${pascalName}(req.params.id, productId); + if (!deleted) throw new NotFoundError('${pascalName} not found'); + reply.status(204); + }); +} +`; +} + +// Generate test.ts content +function generateTest(name: string, fields: ReturnType, partitionKey: string) { + const pascalName = name.charAt(0).toUpperCase() + name.slice(1); + const camelName = name.charAt(0).toLowerCase() + name.slice(1); + + return `/** + * ${pascalName} Module Tests + * + * @module platform-service/src/modules/${name}/${name}.test + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { createTestApp } from '../../test-helpers.js'; +import { + Create${pascalName}Schema, + Update${pascalName}Schema, +} from './types.js'; + +describe('${pascalName} module', () => { + const app = createTestApp(); + + describe('Create${pascalName}Schema', () => { + it('accepts valid input', () => { +${fields.filter(f => !f.optional).map(f => ` const result = Create${pascalName}Schema.parse({ + ${f.name}: ${f.type === 'string' ? "'test'" : f.type === 'number' ? '123' : f.type === 'boolean' ? 'true' : "'value'"} + }); + expect(result).toBeDefined();`).join('\n')} + }); + + it('rejects invalid input', () => { + expect(() => Create${pascalName}Schema.parse({})).toThrow(); + }); + }); + + describe('Update${pascalName}Schema', () => { + it('accepts partial updates', () => { + const result = Update${pascalName}Schema.parse({}); + expect(result).toEqual({}); + }); + }); + + describe('API Routes', () => { + it('POST /api/${camelName} creates item', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/${camelName}', + payload: { +${fields.filter(f => !f.optional).slice(0, 2).map(f => ` ${f.name}: ${f.type === 'string' ? "'Test ${f.name}'" : f.type === 'number' ? '42' : 'true'}`).join(',\n')} + }, + }); + expect(res.statusCode).toBe(201); + }); + + it('GET /api/${camelName} lists items', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/${camelName}', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.items).toBeDefined(); + expect(body.total).toBeDefined(); + }); + }); +}); +`; +} + +// Main generator function +async function generateModule() { + const options = parseArgs(); + const { name, fields, partition, dryRun } = options; + + console.log(`\\n🚀 Generating module: ${name}`); + console.log(` Fields: ${fields}`); + console.log(` Partition: ${partition}`); + if (dryRun) console.log(' ⚠️ DRY RUN - No files will be written'); + + try { + const parsedFields = parseFields(fields); + console.log(` Parsed ${parsedFields.length} fields`); + + const files = { + 'types.ts': generateTypes(name, parsedFields, partition), + 'repository.ts': generateRepository(name, parsedFields, partition), + 'routes.ts': generateRoutes(name, parsedFields, partition), + [`${name}.test.ts`]: generateTest(name, parsedFields, partition), + }; + + if (dryRun) { + console.log('\\n📄 Generated files (preview):'); + for (const [filename, content] of Object.entries(files)) { + console.log(`\\n--- ${filename} ---`); + console.log(content.slice(0, 500) + (content.length > 500 ? '\\n...' : '')); + } + } else { + // Create module directory + const moduleDir = path.join(__dirname, '..', 'src', 'modules', name); + await fs.mkdir(moduleDir, { recursive: true }); + + // Write files + for (const [filename, content] of Object.entries(files)) { + const filepath = path.join(moduleDir, filename); + await fs.writeFile(filepath, content, 'utf-8'); + console.log(` ✅ ${filename}`); + } + + console.log(`\\n✨ Module created: ${moduleDir}`); + console.log(`\\nNext steps:`); + console.log(` 1. Add container to cosmos-init.ts: { name: '${name}', partitionKey: '/${partition}' }`); + console.log(` 2. Import and register routes in server.ts`); + console.log(` 3. Run tests: pnpm --filter @lysnrai/platform-service test`); + } + + } catch (error) { + console.error('\\n❌ Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +generateModule();