UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

1,073 lines (1,029 loc) 41.1 kB
import axios from "axios"; import * as fs from "fs/promises"; import * as path from "path"; import cliProgress from "cli-progress"; import Handlebars from "handlebars"; import _ from "lodash-es"; import { configInstance } from "../../config.js"; // Global config singleton import { loadConfigFromFile } from "../configFileLoader.js"; // -------------------- // JSDoc Type Definitions // -------------------- /** * @typedef {Object} GenerateArgs * // Additional arguments for generation if needed. */ /** * @typedef {Object} SchemaProperty * @property {string} type * @property {string} [format] * @property {SchemaProperty} [items] * @property {Object.<string, SchemaProperty>} [properties] * @property {string[]} [required] * @property {string[]} [enum] * @property {string} [description] * @property {boolean} [nullable] * @property {any} [default] * @property {string} [ref] */ /** * @typedef {Object} RelationshipField * @property {string} field - Name of the relationship field * @property {string} ModelClass - The class name of the related model * @property {string} relationshipType - Type of relationship (e.g., "many-to-many", "foreign-key") */ /** * @typedef {Object} RelationshipData * @property {string} type * @property {string} model - e.g. "django_app.deepmodellevel1" * @property {string} class_name - e.g. "DeepModelLevel1" * @property {string} primary_key_field */ /** * @typedef {Object} SchemaDefinition * @property {string} type * @property {Object.<string, SchemaProperty>} properties * @property {string[]} [required] * @property {string} [description] * @property {Object.<string, SchemaDefinition>} [definitions] * @property {string} model_name * @property {string} class_name * @property {string} [primary_key_field] * @property {Object.<string, RelationshipData>} [relationships] */ /** * @typedef {Object} PropertyDefinition * @property {string} name * @property {string} type * @property {boolean} required * @property {string} defaultValue * @property {boolean} [isRelationship] * @property {string} [relationshipClassName] * @property {boolean} [isArrayRelationship] * @property {string} [relationshipPrimaryKeyField] * @property {boolean} [isString] * @property {boolean} [isNumber] * @property {boolean} [isBoolean] * @property {boolean} [isDate] * @property {boolean} [isPrimaryKey] */ /** * @typedef {Object} TemplateData * @property {string} modulePath - Dynamic module path for imports. * @property {string} className - Exported full model class name (from schema.class_name). * @property {string} interfaceName - Full model fields interface name (e.g. DeepModelLevel1Fields). * @property {string} modelName - Raw schema.model_name (including app label path). * @property {PropertyDefinition[]} properties * @property {RelationshipField[]} relationshipFields - List of relationship fields for the model * @property {string} [description] * @property {string[]} [definitions] * @property {string[]} [jsImports] - For JS generation: full class imports. * @property {string[]} [tsImports] - For TS generation: type imports (Fields). * @property {string} configKey - The backend config key. * @property {string} primaryKeyField - Primary key field from schema. */ /** * @typedef {Object} BackendConfig * @property {string} NAME * @property {string} API_URL * @property {string} GENERATED_TYPES_DIR */ /** * @typedef {Object} SelectedModel * @property {BackendConfig} backend * @property {string} model */ // -------------------- // Fallback Selection for Inquirer Errors // -------------------- /** * Simple fallback that generates all models when inquirer fails * @param {Array} choices - Array of choice objects with {name, value, checked} properties * @param {string} message - Selection message * @returns {Promise<Array>} - All model values */ async function fallbackSelectAll(choices, message) { console.log(`\n${message}`); console.log("Interactive selection not available - generating ALL models:"); const allModels = []; for (const choice of choices) { // Skip separators (they don't have a 'value' property) if (!choice.value) { console.log(choice.name); // Print separator text continue; } // Add ALL models, regardless of checked status allModels.push(choice.value); console.log(` ✓ ${choice.name}`); } console.log(`\nGenerating ALL ${allModels.length} models.`); return allModels; } /** * Model selection with inquirer fallback * @param {Array} choices - Array of choice objects * @param {string} message - Selection message * @returns {Promise<Array>} - Selected model objects */ async function selectModels(choices, message) { try { // Try to use inquirer first const inquirer = (await import("inquirer")).default; const { selectedModels } = await inquirer.prompt([ { type: "checkbox", name: "selectedModels", message, choices, pageSize: 20, }, ]); return selectedModels; } catch (error) { // Fall back to generating all models if inquirer fails for any reason console.warn("Interactive selection failed, generating all models:", error.message); return await fallbackSelectAll(choices, message); } } // -------------------- // Handlebars Templates & Helpers // -------------------- // Updated JS_MODEL_TEMPLATE with getters and setters const JS_MODEL_TEMPLATE = `/** * This file was auto-generated. Do not make direct changes to the file. {{#if description}} * {{description}} {{/if}} */ import { Model, Manager, QuerySet, getModelClass } from '{{modulePath}}'; import { wrapReactiveModel } from '{{modulePath}}'; import schemaData from './{{toLowerCase className}}.schema.json'; /** * Model-specific QuerySet implementation */ export class {{className}}QuerySet extends QuerySet { // QuerySet implementation with model-specific typing } /** * Model-specific Manager implementation */ export class {{className}}Manager extends Manager { constructor(ModelClass) { super(ModelClass, {{className}}QuerySet); } newQuerySet() { return new {{className}}QuerySet(this.ModelClass); } } /** * Implementation of the {{className}} model */ export class {{className}} extends Model { // Bind this model to its backend static configKey = '{{configKey}}'; static modelName = '{{modelName}}'; static primaryKeyField = '{{primaryKeyField}}'; static objects = new {{className}}Manager({{className}}); static fields = [{{#each properties}}'{{name}}'{{#unless @last}}, {{/unless}}{{/each}}]; static schema = schemaData; static relationshipFields = new Map([ {{#each relationshipFields}} ['{{field}}', { 'ModelClass': () => getModelClass('{{modelName}}', '{{../configKey}}'), 'relationshipType': '{{relationshipType}}' }]{{#unless @last}},{{/unless}} {{/each}} ]); constructor(data) { {{className}}.validateFields(data); super(data); // Define getters and setters for all fields this._defineProperties(); return wrapReactiveModel(this); } /** * Define property getters and setters for all model fields * @private */ _defineProperties() { // For each field, define a property that gets/sets from internal storage {{className}}.fields.forEach(field => { Object.defineProperty(this, field, { get: function() { return this.getField(field); }, set: function(value) { this.setField(field, value); }, enumerable: true, // Make sure fields are enumerable for serialization configurable: true }); }); } } `; // Updated TS_DECLARATION_TEMPLATE with improved relationship handling const TS_DECLARATION_TEMPLATE = `/** * This file was auto-generated. Do not make direct changes to the file. {{#if description}} * {{description}} {{/if}} */ import { Model, Manager } from '{{modulePath}}'; import { StringOperators, NumberOperators, BooleanOperators, DateOperators } from '{{modulePath}}'; import { QuerySet, LiveQuerySet, LiveQuerySetOptions, MetricResult, ResultTuple, SerializerOptions, NestedPaths } from '{{modulePath}}'; // Re-export the real Manager for runtime use import { Manager as RuntimeManager } from '{{modulePath}}'; {{#if tsImports}} {{#each tsImports}} {{{this}}} {{/each}} {{/if}} /** * Base fields interface - defines the shape of a model instance * This is the single source of truth for the model's data structure */ export interface {{interfaceName}} { {{#each properties}} {{name}}{{#unless required}}?{{/unless}}: {{{type}}}; {{/each}} // Read-only representation field readonly repr: { str: string; img: string | null; }; } /** * Relationship field structure */ export interface RelationshipField { ModelClass: any; relationshipType: string; } /** * Relationship fields map type */ export type RelationshipFieldsMap = Map<string, RelationshipField>; /** * Type for creating new instances * Similar to base fields but makes ID fields optional */ export type {{className}}CreateData = { {{#each properties}} {{name}}{{#unless isPrimaryKey}}{{#unless required}}?{{/unless}}{{else}}?{{/unless}}: {{{type}}}; {{/each}} }; /** * Type for updating instances * All fields are optional since updates can be partial */ export type {{className}}UpdateData = Partial<{{interfaceName}}>; /** * Type for filtering with field lookups * Supports advanced filtering with operators like __gte, __contains, etc. */ export interface {{className}}FilterData { {{#each properties}} {{#if isRelationship}} {{#if isArrayRelationship}} // Many-to-many relationship field {{name}}?: number; // Exact match by ID {{name}}__in?: number[]; // Match any of these IDs {{name}}__isnull?: boolean; // Check if relation exists {{else}} // Foreign key relationship field {{name}}?: number; // Exact match by ID {{name}}__isnull?: boolean; // Check if relation exists {{/if}} {{else}} {{#if isString}} {{name}}?: string | StringOperators; {{name}}__contains?: string; {{name}}__icontains?: string; {{name}}__startswith?: string; {{name}}__istartswith?: string; {{name}}__endswith?: string; {{name}}__iendswith?: string; {{name}}__exact?: string; {{name}}__iexact?: string; {{name}}__in?: string[]; {{name}}__isnull?: boolean; {{/if}} {{#if isNumber}} {{name}}?: number | NumberOperators; {{name}}__gt?: number; {{name}}__gte?: number; {{name}}__lt?: number; {{name}}__lte?: number; {{name}}__exact?: number; {{name}}__in?: number[]; {{name}}__isnull?: boolean; {{/if}} {{#if isBoolean}} {{name}}?: boolean | BooleanOperators; {{name}}__exact?: boolean; {{name}}__isnull?: boolean; {{/if}} {{#if isDate}} {{name}}?: Date | DateOperators; {{name}}__gt?: Date; {{name}}__gte?: Date; {{name}}__lt?: Date; {{name}}__lte?: Date; {{name}}__exact?: Date; {{name}}__in?: Date[]; {{name}}__isnull?: boolean; {{/if}} {{/if}} {{/each}} // Support for nested filtering on related fields [key: string]: any; // Support for Q objects Q?: Array<any>; } /** * Model-specific QuerySet with strictly typed methods */ export declare class {{className}}QuerySet extends QuerySet<any> { // Chain methods filter(conditions: {{className}}FilterData): {{className}}QuerySet; exclude(conditions: {{className}}FilterData): {{className}}QuerySet; orderBy(...fields: Array<keyof {{interfaceName}} | string>): {{className}}QuerySet; search(searchQuery: string, searchFields?: Array<string>): {{className}}QuerySet; // Terminal methods get(filters?: {{className}}FilterData, serializerOptions?: SerializerOptions): Promise<{{className}}>; first(serializerOptions?: SerializerOptions): Promise<{{className}} | null>; last(serializerOptions?: SerializerOptions): Promise<{{className}} | null>; all(): {{className}}QuerySet; count(field?: string): Promise<number>; update(updates: {{className}}UpdateData): Promise<[number, Record<string, number>]>; delete(): Promise<[number, Record<string, number>]>; exists(): Promise<boolean>; fetch(serializerOptions?: SerializerOptions): Promise<{{className}}[]>; } /** * Model-specific Manager with strictly typed methods */ export declare class {{className}}Manager extends Manager { newQuerySet(): {{className}}QuerySet; filter(conditions: {{className}}FilterData): {{className}}QuerySet; exclude(conditions: {{className}}FilterData): {{className}}QuerySet; all(): {{className}}QuerySet; get(filters?: {{className}}FilterData, serializerOptions?: SerializerOptions): Promise<{{className}}>; create(data: {{className}}CreateData): Promise<{{className}}>; delete(): Promise<[number, Record<string, number>]>; } /** * Model-specific LiveQuerySet with strictly typed methods */ export declare class {{className}}LiveQuerySet extends LiveQuerySet { // Data access get data(): {{className}}[]; // Chain methods filter(conditions: {{className}}FilterData): {{className}}LiveQuerySet; // Terminal methods fetch(serializerOptions?: SerializerOptions): Promise<{{className}}[]>; get(filters?: {{className}}FilterData, serializerOptions?: SerializerOptions): Promise<{{className}}>; create(item: {{className}}CreateData): Promise<{{className}}>; update(updates: {{className}}UpdateData): Promise<{{className}}[]>; delete(): Promise<void>; count(field?: string): Promise<MetricResult<number>>; sum(field: string): Promise<MetricResult<number>>; avg(field: string): Promise<MetricResult<number>>; min(field: string): Promise<MetricResult<any>>; max(field: string): Promise<MetricResult<any>>; } /** * Enhanced RuntimeManager to provide TypeScript typings * This creates a concrete class that both extends RuntimeManager and matches type expectations */ export class {{className}}Manager extends RuntimeManager { filter(conditions: {{className}}FilterData): ReturnType<RuntimeManager['filter']> { return super.filter(conditions as any); } get(filters?: {{className}}FilterData, serializerOptions?: SerializerOptions): Promise<{{className}}> { return super.get(filters as any, serializerOptions); } all() { return super.all(); } create(data: {{className}}CreateData): Promise<{{className}}> { return super.create(data); } update(data: {{className}}UpdateData): Promise<any> { return super.update(data); } } // Class declarations export declare class {{className}} extends Model implements {{interfaceName}} { {{#each properties}} {{name}}{{#unless required}}?:{{else}}:{{/unless}} {{{type}}}; {{/each}} readonly repr: { str: string; img: string | null; }; static configKey: string; static modelName: string; static primaryKeyField: string; static relationshipFields: RelationshipFieldsMap; // Use model-specific manager class instead of generic manager static objects: {{className}}Manager; constructor(data: Partial<{{interfaceName}}>); serialize(): Partial<{{interfaceName}}>; } /** * Runtime initialization */ {{className}}.objects = new {{className}}Manager({{className}}); `; // -------------------- // Handlebars Helpers // -------------------- Handlebars.registerHelper("ifDefaultProvided", function (defaultValue, options) { if (defaultValue !== "null") { return options.fn(this); } else { return options.inverse(this); } }); Handlebars.registerHelper("isRequired", function (required) { return required ? "" : "?"; }); Handlebars.registerHelper("toLowerCase", function (str) { return str.toLowerCase(); }); const jsTemplate = Handlebars.compile(JS_MODEL_TEMPLATE); const dtsTemplate = Handlebars.compile(TS_DECLARATION_TEMPLATE); // -------------------- // Core Generation Functions // -------------------- /** * Generates the schema for a given model. * @param {BackendConfig} backend * @param {string} model * @returns {Promise<{model: string, relativePath: string}>} */ async function generateSchemaForModel(backend, model) { const schemaUrl = `${backend.API_URL}/${model}/get-schema/`; const schemaResponse = await axios.get(schemaUrl); /** @type {SchemaDefinition} */ let schema; if (schemaResponse.data.components?.schemas?.[model]) { schema = schemaResponse.data.components.schemas[model]; } else if (schemaResponse.data.properties) { schema = schemaResponse.data; } else { console.error("Unexpected schema structure for model:", model); throw new Error(`Invalid schema structure for model: ${model}`); } if (!schema.model_name) { console.error(`Missing model_name attribute in schema for model: ${model}`); process.exit(1); } const rawModelName = schema.model_name; const className = schema.class_name; const interfaceName = `${className}Fields`; const parts = model.split("."); const currentApp = parts.length > 1 ? parts[0] : ""; const modulePath = process.env.NODE_ENV === "test" ? "../../../src" : "@statezero/core"; const templateData = prepareTemplateData(modulePath, className, interfaceName, rawModelName, schema, currentApp, backend.NAME); let outDir = backend.GENERATED_TYPES_DIR; if (parts.length > 1) { outDir = path.join(outDir, ...parts.slice(0, -1).map((p) => p.toLowerCase())); } await fs.mkdir(outDir, { recursive: true }); const schemaFilePath = path.join(outDir, `${className.toLowerCase()}.schema.json`); await fs.writeFile(schemaFilePath, JSON.stringify(schema, null, 2)); const jsContent = jsTemplate(templateData); const baseName = parts[parts.length - 1].toLowerCase(); const jsFilePath = path.join(outDir, `${baseName}.js`); await fs.writeFile(jsFilePath, jsContent); const dtsContent = dtsTemplate(templateData); const dtsFilePath = path.join(outDir, `${baseName}.d.ts`); await fs.writeFile(dtsFilePath, dtsContent); const relativePath = "./" + path .relative(backend.GENERATED_TYPES_DIR, jsFilePath) .replace(/\\/g, "/") .replace(/\.js$/, ""); return { model, relativePath, className }; } /** * Given a related model string (e.g. "django_app.deepmodellevel1"), * extract the app label and model name to construct an import path. * @param {string} currentApp * @param {string} relModel * @returns {string} */ function getImportPath(currentApp, relModel) { const parts = relModel.split("."); const appLabel = parts[0]; const fileName = parts[parts.length - 1].toLowerCase(); return currentApp === appLabel ? `./${fileName}` : `../${appLabel}/${fileName}`; } /** * Prepares template data for Handlebars. * @param {string} modulePath * @param {string} className * @param {string} interfaceName * @param {string} rawModelName * @param {SchemaDefinition} schema * @param {string} currentApp * @param {string} configKey * @returns {TemplateData} */ function prepareTemplateData(modulePath, className, interfaceName, rawModelName, schema, currentApp, configKey) { /** @type {PropertyDefinition[]} */ const properties = []; /** @type {RelationshipField[]} */ const relationshipFields = []; const usedDefs = new Set(); for (const [propName, prop] of Object.entries(schema.properties)) { const propType = generateTypeForProperty(prop, schema.definitions, schema.relationships, propName); const isRelationship = schema.relationships && schema.relationships[propName] !== undefined; const isString = prop.type === "string"; const isNumber = prop.type === "integer" || prop.type === "number"; const isBoolean = prop.type === "boolean"; const isDate = prop.type === "string" && prop.format === "date-time"; const isPrimaryKey = schema.primary_key_field === propName; const propDef = { name: propName, type: propType, required: schema.required?.includes(propName) ?? false, defaultValue: getDefaultValueForType(prop), isRelationship, isArrayRelationship: isRelationship ? propType.startsWith("Array<") : false, isString, isNumber, isBoolean, isDate, isPrimaryKey, }; if (isRelationship) { const relData = schema.relationships[propName]; propDef.relationshipClassName = relData.class_name; propDef.relationshipPrimaryKeyField = relData.primary_key_field; // Add to relationshipFields array with modelName for dynamic loading relationshipFields.push({ field: propName, ModelClass: relData.class_name, relationshipType: prop.format, // Use the format value directly modelName: relData.model, // Add the full model name for getModelClass }); } properties.push(propDef); const match = propType.match(/^(\w+)Fields$/); if (match && schema.definitions && schema.definitions[match[1]]) { usedDefs.add(match[1]); } const arrMatch = propType.match(/^Array<(\w+)Fields>$/); if (arrMatch && schema.definitions && schema.definitions[arrMatch[1]]) { usedDefs.add(arrMatch[1]); } } const definitionsTs = []; if (schema.definitions) { for (const [defKey, defSchema] of Object.entries(schema.definitions)) { if (!usedDefs.has(defKey)) continue; let tsInterface = `export interface ${defKey}Fields {`; const req = defSchema.required || []; for (const [propName, prop] of Object.entries(defSchema.properties)) { tsInterface += `\n ${propName}${req.includes(propName) ? "" : "?"}: ${generateTypeForProperty(prop, schema.definitions)};`; } tsInterface += `\n}`; definitionsTs.push(tsInterface); } } // Only add TypeScript definition imports - JS imports are handled dynamically const tsImportSet = new Set(); if (schema.relationships) { for (const [propName, rel] of Object.entries(schema.relationships)) { const importPath = getImportPath(currentApp, rel.model); tsImportSet.add(`import { ${rel.class_name}Fields, ${rel.class_name}QuerySet, ${rel.class_name}LiveQuerySet } from '${importPath}';`); } } // Convert Sets to Arrays const jsImports = []; // No static JS imports needed anymore const tsImports = Array.from(tsImportSet); let primaryKeyField = "id"; if (schema.primary_key_field !== undefined) { primaryKeyField = schema.primary_key_field; } return { modulePath, className, interfaceName, modelName: rawModelName, properties, relationshipFields, description: schema.description, definitions: definitionsTs.length > 0 ? definitionsTs : undefined, jsImports, tsImports, configKey, primaryKeyField, }; } /** * Generates a TypeScript type for a property. * @param {SchemaProperty} prop * @param {Object.<string, SchemaDefinition>} [definitions] * @param {Object.<string, RelationshipData>} [relationships] * @param {string} [propName] * @returns {string} */ function generateTypeForProperty(prop, definitions, relationships, propName) { if (relationships && propName && relationships[propName]) { const relData = relationships[propName]; const idType = prop.type === "integer" || prop.type === "number" ? "number" : prop.type === "string" ? "string" : "any"; return prop.format === "many-to-many" ? `Array<${relData.class_name}Fields | ${idType}>` : `${relData.class_name}Fields | ${idType}`; } if (prop.ref && prop.ref.startsWith("#/components/schemas/")) { const defName = prop.ref.split("/").pop() || prop.ref; return `${defName}Fields`; } if (prop.ref) { return prop.format === "many-to-many" ? `Array<${prop.ref}Fields>` : `${prop.ref}Fields`; } let tsType; switch (prop.type) { case "string": tsType = prop.enum ? prop.enum.map((v) => `'${v}'`).join(" | ") : "string"; break; case "number": case "integer": tsType = "number"; break; case "boolean": tsType = "boolean"; break; case "array": tsType = prop.items ? `Array<${generateTypeForProperty(prop.items, definitions)}>` : "any[]"; break; case "object": if (prop.format === "json") { tsType = "any"; } else if (prop.properties) { const nestedProps = Object.entries(prop.properties).map(([key, value]) => { const isRequired = prop.required?.includes(key) ? "" : "?"; return `${key}${isRequired}: ${generateTypeForProperty(value, definitions)}`; }); tsType = `{ ${nestedProps.join("; ")} }`; } else { tsType = "Record<string, any>"; } break; default: tsType = "any"; break; } if (prop.nullable) { tsType = `${tsType} | null`; } return tsType; } /** * Gets the default value for a property. * @param {SchemaProperty} prop * @returns {string} */ function getDefaultValueForType(prop) { return prop.default !== undefined ? JSON.stringify(prop.default) : "null"; } /** * Creates a unique alias for importing a model class based on backend, model path, and class name. * Example: ('default', 'django_app.level1.deepmodel', 'DeepModel') => 'Default__django_app__level1__DeepModel' * Ensures uniqueness by including sanitized path components. * * @param {string} backendKey - The backend configuration key (e.g., 'default', 'microservice'). * @param {string} modelName - The full model name string (e.g., 'django_app.level1.deepmodel'). * @param {string} className - The base class name (e.g., 'DeepModel'). * @returns {string} - A unique alias (e.g., 'Default__django_app__level1__DeepModel'). */ function generateImportAlias(backendKey, modelName, className) { // 1. Sanitize Backend Key const sanitizedBackendKey = (backendKey.charAt(0).toUpperCase() + backendKey.slice(1)).replace(/[^a-zA-Z0-9_]/g, ""); // Allow underscore // 2. Sanitize Model Path Parts (all parts except the last, which relates to className) const modelPathParts = modelName.split(".").slice(0, -1); // Get path parts like ['django_app', 'level1'] const sanitizedModelPath = modelPathParts .map((part) => part.replace(/[^a-zA-Z0-9_]/g, "_")) // Replace invalid chars with underscore .join("__"); // Join parts with double underscore -> "django_app__level1" // 3. Combine: Backend__Path__ClassName // Ensure className itself is sanitized just in case, although usually it should be a valid identifier const sanitizedClassName = className.replace(/[^a-zA-Z0-9_]/g, "_"); if (sanitizedModelPath) { // If path parts exist, include them: Default__django_app__level1__DeepModel return `${sanitizedBackendKey}__${sanitizedModelPath}__${sanitizedClassName}`; } else { // If no path parts (e.g., modelName is just 'simplemodel'), use: Default__SimpleModel return `${sanitizedBackendKey}__${sanitizedClassName}`; } } /** * Generates a model registry file... * * @param {Array<{model: string, relativePath: string, backend: string, className: string}>} generatedFiles * @param {Object.<string, BackendConfig>} backendConfigs * @returns {Promise<void>} */ async function generateModelRegistry(generatedFiles, backendConfigs) { const registryByBackend = {}; const allUniqueImports = new Set(); for (const file of generatedFiles) { const backendKey = file.backend; const modelName = file.model; // e.g., 'django_app.dummymodel' const originalClassName = file.className; // e.g., 'DummyModel' const typesDir = backendConfigs[backendKey].GENERATED_TYPES_DIR; const importPath = "./" + path .relative("./", path.join(typesDir, file.relativePath)) .replace(/\\/g, "/"); const importAlias = generateImportAlias(backendKey, modelName, originalClassName); // Example result: 'Default__django_app__DummyModel' registryByBackend[backendKey] = registryByBackend[backendKey] || { imports: [], models: {}, }; const importStatement = `import { ${originalClassName} as ${importAlias} } from '${importPath}.js';`; registryByBackend[backendKey].imports.push(importStatement); allUniqueImports.add(importStatement); registryByBackend[backendKey].models[modelName] = importAlias; // Store mapping: 'django_app.dummymodel': 'Default__django_app__DummyModel' } // --- Generate registry content --- let registryContent = `/** * This file was auto-generated. Do not make direct changes to the file. * It provides a registry of all models organized by config key and model name. * Uses import aliases incorporating model paths to ensure uniqueness. */ // --- Imports --- `; // Add all unique import statements collected registryContent += Array.from(allUniqueImports).sort().join("\n"); registryContent += "\n\n"; // --- Create the registry object --- registryContent += `/** * Model registry mapped by configKey and modelName. * Values are the aliased imported classes. * @type {Object.<string, Object.<string, Function>>} */ export const MODEL_REGISTRY = { `; // Add entries for each backend const backendEntries = Object.entries(registryByBackend); backendEntries.forEach(([backendKey, data], backendIndex) => { registryContent += ` '${backendKey}': {\n`; // Add model entries: 'model.name': Alias (e.g., 'django_app.dummymodel': Default__django_app__DummyModel) const modelEntries = Object.entries(data.models); modelEntries.forEach(([modelName, alias], modelIndex) => { registryContent += ` '${modelName}': ${alias}`; // Use the generated alias here if (modelIndex < modelEntries.length - 1) { registryContent += ","; } registryContent += "\n"; }); registryContent += ` }`; if (backendIndex < backendEntries.length - 1) { registryContent += ","; } registryContent += "\n"; }); registryContent += `}; /** * Get a model class by name. * This remains synchronous. * * @param {string} modelName - The model name (e.g., 'django_app.dummymodel') * @param {string} configKey - The config key (backend name, e.g., 'default') * @returns {Function|null} - The model class (via its alias) or null if not found. */ export function getModelClass(modelName, configKey) { if (MODEL_REGISTRY[configKey] && MODEL_REGISTRY[configKey][modelName]) { // Returns the specific aliased class for that backend/model combination return MODEL_REGISTRY[configKey][modelName]; } console.warn(\`Model class not found for '\${modelName}' in config '\${configKey}'\`); return null; } `; // --- Write the file --- const rootDir = process.cwd(); const registryFilePath = path.join(rootDir, "model-registry.js"); try { await fs.writeFile(registryFilePath, registryContent); console.log(`✨ Generated model registry with path-based aliases at ${registryFilePath}`); } catch (err) { console.error(`❌ Failed to write model registry at ${registryFilePath}:`, err); } } /** * Generates app-level index files and a root index file that imports from app indexes. * @param {Array<{model: string, relativePath: string, backend: string}>} generatedFiles * @param {Object.<string, BackendConfig>} backendConfigs * @returns {Promise<void>} */ async function generateAppLevelIndexFiles(generatedFiles, backendConfigs) { // Group files by backend and app const filesByBackendAndApp = generatedFiles.reduce((acc, file) => { const backend = file.backend; const parts = file.model.split("."); const app = parts.length > 1 ? parts[0] : "root"; // Use 'root' for models without an app acc[backend] = acc[backend] || {}; acc[backend][app] = acc[backend][app] || []; acc[backend][app].push(file); return acc; }, {}); const indexTemplate = Handlebars.compile(`{{#each files}} export * from '{{this.relativePath}}'; {{/each}}`); // Generate app-level index files for each backend and app for (const [backendName, appGroups] of Object.entries(filesByBackendAndApp)) { const backend = backendConfigs[backendName]; const rootExports = []; for (const [app, files] of Object.entries(appGroups)) { if (app === "root") { // Handle models without an app prefix for (const file of files) { rootExports.push(`export * from '${file.relativePath}';`); } } else { // Create app-level index files with proper relative paths const appDir = path.join(backend.GENERATED_TYPES_DIR, app.toLowerCase()); // Create relative paths for imports within the app directory // These should be relative to the app directory, not the backend root const appFiles = files.map((file) => { // Get the last part of the path (the actual file name without extension) const fileName = path.basename(file.relativePath); return { ...file, relativePath: "./" + fileName, }; }); const indexContent = indexTemplate({ files: appFiles }); await fs.writeFile(path.join(appDir, "index.js"), indexContent.trim()); await fs.writeFile(path.join(appDir, "index.d.ts"), indexContent.trim()); // Add an export for this app's index to the backend root index rootExports.push(`export * from './${app.toLowerCase()}/index';`); } } // Write the backend root index file (add FileObject export) const backendIndexContent = [ "export * from './fileobject';", // Add this line ...rootExports, ].join("\n"); await fs.writeFile(path.join(backend.GENERATED_TYPES_DIR, "index.js"), backendIndexContent); await fs.writeFile(path.join(backend.GENERATED_TYPES_DIR, "index.d.ts"), backendIndexContent); } } // FileObject template const FILEOBJECT_TEMPLATE = `/** * This file was auto-generated. Do not make direct changes to the file. * Backend-specific FileObject class for {{backendName}} */ import { FileObject as BaseFileObject } from '{{modulePath}}'; export class {{backendName}}FileObject extends BaseFileObject { static configKey = '{{backendName}}'; } export const FileObject = {{backendName}}FileObject; `; const fileObjectTemplate = Handlebars.compile(FILEOBJECT_TEMPLATE); /** * Generates a FileObject class for a backend. * @param {BackendConfig} backend * @returns {Promise<void>} */ async function generateFileObjectForBackend(backend) { const modulePath = process.env.NODE_ENV === "test" ? "../../../src" : "@statezero/core"; const templateData = { backendName: backend.NAME, modulePath: modulePath, }; const fileObjectContent = fileObjectTemplate(templateData); const fileObjectPath = path.join(backend.GENERATED_TYPES_DIR, "fileobject.js"); await fs.writeFile(fileObjectPath, fileObjectContent); // Also generate TypeScript declaration const dtsContent = `/** * This file was auto-generated. Do not make direct changes to the file. * Backend-specific FileObject class for ${backend.NAME} */ import { FileObject as BaseFileObject } from '${modulePath}'; export declare class ${backend.NAME}FileObject extends BaseFileObject { static configKey: string; } export declare const FileObject: typeof ${backend.NAME}FileObject; `; const dtsPath = path.join(backend.GENERATED_TYPES_DIR, "fileobject.d.ts"); await fs.writeFile(dtsPath, dtsContent); } // -------------------- // Main Runner: Fetch models and prompt selection // -------------------- async function main() { // Load configuration from file (CLI-only or tests) before any other operations. loadConfigFromFile(); // Retrieve the validated configuration from the global config singleton. const configData = configInstance.getConfig(); const backendConfigs = configData.backendConfigs; const fetchPromises = Object.keys(backendConfigs).map(async (key) => { const backend = backendConfigs[key]; backend.NAME = key; try { const response = await axios.get(`${backend.API_URL}/models/`); return { backend, models: response.data }; } catch (error) { console.error(`Error fetching models from backend ${backend.NAME}:`, error.message); return { backend, models: [] }; } }); const backendModels = await Promise.all(fetchPromises); const choices = []; // Create a simple separator object for environments where inquirer.Separator isn't available const createSeparator = (text) => ({ name: text, value: null }); for (const { backend, models } of backendModels) { choices.push(createSeparator(`\n=== ${backend.NAME} ===\n`)); for (const model of models) { choices.push({ name: model, value: { backend, model }, checked: true, }); } } if (choices.length === 0) { console.log("No models to synchronise"); process.exit(0); } const selectedModels = await selectModels(choices, "Select models to synchronise:"); if (!selectedModels || selectedModels.length === 0) { console.log("No models selected. Exiting."); process.exit(0); } const modelsByBackend = selectedModels.reduce((acc, item) => { const key = item.backend.NAME; acc[key] = acc[key] || { backend: item.backend, models: [] }; acc[key].models.push(item.model); return acc; }, {}); const allGeneratedFiles = []; for (const group of Object.values(modelsByBackend)) { console.log(`\nProcessing backend: ${group.backend.NAME}`); const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); progressBar.start(group.models.length, 0); for (const model of group.models) { try { const result = await generateSchemaForModel(group.backend, model); allGeneratedFiles.push({ ...result, backend: group.backend.NAME }); } catch (error) { console.error(`Error generating schema for model ${model} from backend ${group.backend.NAME}:`, error.message); } progressBar.increment(); } progressBar.stop(); // Generate FileObject for this backend try { await generateFileObjectForBackend(group.backend); console.log(`✨ Generated FileObject for backend: ${group.backend.NAME}`); } catch (error) { console.error(`Error generating FileObject for backend ${group.backend.NAME}:`, error.message); } } // Generate an index file per app await generateAppLevelIndexFiles(allGeneratedFiles, backendConfigs); // Generate model registry await generateModelRegistry(allGeneratedFiles, backendConfigs); console.log(`✨ Generated JavaScript files with TypeScript declarations for ${selectedModels.length} models across ${Object.keys(backendConfigs).length} backends.`); } /** * Main exported function to generate schema. * @param {GenerateArgs} args * @returns {Promise<void>} */ export async function generateSchema(args) { await main(); }