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
This commit is contained in:
saravanakumardb1 2026-03-03 10:05:55 -08:00
parent 41cd821fab
commit fcbae17866
2 changed files with 520 additions and 1 deletions

View File

@ -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",

View File

@ -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 <name> --fields "<field:type,...>" --partition <key> [--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<typeof parseFields>, partitionKey: string) {
const pascalName = name.charAt(0).toUpperCase() + name.slice(1);
const fieldSchemas = fields.map((f: ReturnType<typeof parseFields>[0]) =>
` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''}`
).join(',\n');
const interfaceFields = fields.map((f: ReturnType<typeof parseFields>[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<typeof ${pascalName}Schema>;
// ── Create Input ────────────────────────────────────────────
export const Create${pascalName}Schema = z.object({
${fieldSchemas},
});
export type Create${pascalName}Input = z.infer<typeof Create${pascalName}Schema>;
// ── 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<typeof Update${pascalName}Schema>;
// ── 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<typeof Query${pascalName}Schema>;
`;
}
// Generate repository.ts content
function generateRepository(name: string, fields: ReturnType<typeof parseFields>, 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<boolean> {
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<typeof parseFields>, 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<typeof parseFields>, 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();