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:
parent
41cd821fab
commit
fcbae17866
@ -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",
|
||||
|
||||
518
services/platform-service/scripts/gen-module.ts
Normal file
518
services/platform-service/scripts/gen-module.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user