@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
JavaScript
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();
}