@kodeme-io/next-core-codegen
Version:
TypeScript code generation utilities for next-core framework with React Query hooks and API client generation
1,565 lines (1,558 loc) • 107 kB
JavaScript
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
}) : x)(function(x) {
if (typeof require !== "undefined")
return require.apply(this, arguments);
throw Error('Dynamic require of "' + x + '" is not supported');
});
// src/introspectors/odoo-introspector.ts
var OdooIntrospector = class {
constructor(odoo, options = {}) {
this.odoo = odoo;
this.options = options;
this.defaultAttributes = [
"string",
"type",
"required",
"readonly",
"relation",
"selection",
"help",
"size",
"digits",
"store",
"compute",
"related",
"company_dependent"
];
}
/**
* Introspect models using native fields_get()
* This works with ANY Odoo model out of the box!
*/
async introspectModels(modelNames, options = {}) {
const schemas = {};
const mergedOptions = { ...this.options, ...options };
console.log(`\u{1F50D} Introspecting ${modelNames.length} models...`);
for (const modelName of modelNames) {
try {
console.log(` \u2022 ${modelName}`);
const schema = await this.introspectModel(modelName, mergedOptions);
schemas[modelName] = schema;
} catch (error) {
console.warn(` \u26A0\uFE0F Failed to introspect ${modelName}: ${error}`);
}
}
console.log(`\u2705 Introspected ${Object.keys(schemas).length} models`);
return schemas;
}
/**
* Introspect a single model
*/
async introspectModel(modelName, options = {}) {
const attributes = options.fieldAttributes || this.defaultAttributes;
const fields = await this.odoo.callMethod(
modelName,
"fields_get",
[],
{ attributes }
);
const modelInfo = await this.getModelInfo(modelName);
return {
name: modelName,
description: modelInfo?.description || this.formatModelName(modelName),
fields: this.parseFields(fields, options)
};
}
/**
* Get basic information about a model
*/
async getModelInfo(modelName) {
try {
const models = await this.odoo.searchRead(
"ir.model",
[["model", "=", modelName]],
["name", "info"],
{ limit: 1 }
);
if (models.length > 0) {
return {
description: models[0].info
};
}
} catch (error) {
}
return null;
}
/**
* Parse fields from Odoo fields_get() response
*/
parseFields(rawFields, options) {
const fields = {};
for (const [fieldName, fieldDef] of Object.entries(rawFields)) {
if (this.shouldSkipField(fieldName, fieldDef, options)) {
continue;
}
fields[fieldName] = {
type: fieldDef.type,
string: fieldDef.string || fieldName,
required: fieldDef.required || false,
readonly: fieldDef.readonly || false,
relation: fieldDef.relation,
selection: fieldDef.selection,
help: fieldDef.help,
size: fieldDef.size,
digits: fieldDef.digits,
store: fieldDef.store,
compute: fieldDef.compute,
related: fieldDef.related,
company_dependent: fieldDef.company_dependent
};
}
return fields;
}
/**
* Determine if a field should be skipped based on options
*/
shouldSkipField(fieldName, fieldDef, options) {
if (fieldName.startsWith("__")) {
return true;
}
if (!options.includeInternal) {
const internalFields = [
"create_uid",
"write_uid",
"create_date",
"write_date",
"display_name"
];
if (internalFields.includes(fieldName)) {
return true;
}
if (/^(in_group_|sel_groups_)/.test(fieldName)) {
return true;
}
}
if (!options.includeComputed && fieldDef.compute) {
return true;
}
if (!options.includeRelated && fieldDef.related) {
return true;
}
return false;
}
/**
* Discover all available models in the Odoo instance
*/
async discoverModels(options = {}) {
try {
const models = await this.discoverModelsFromIrModel(options);
return models;
} catch (error) {
console.log("\u26A0\uFE0F Cannot access ir.model, using fallback method...");
return this.discoverModelsFromFallback(options);
}
}
/**
* Discover models using ir.model table
*/
async discoverModelsFromIrModel(options) {
const domain = [];
if (!options.includeTransient) {
domain.push(["transient", "=", false]);
}
if (!options.includeAbstract) {
domain.push(["abstract", "=", false]);
}
const models = await this.odoo.searchRead(
"ir.model",
domain,
["model"],
{ order: "model" }
);
let modelNames = models.map((m) => m.model);
if (options.excludePatterns) {
modelNames = modelNames.filter((name) => {
return !options.excludePatterns.some(
(pattern) => this.matchesPattern(name, pattern)
);
});
}
console.log(`\u{1F4E6} Discovered ${modelNames.length} models`);
return modelNames;
}
/**
* Fallback model discovery using common Odoo models
*/
async discoverModelsFromFallback(options) {
const commonModels = [
"res.partner",
"res.users",
"res.company",
"product.product",
"product.template",
"product.category",
"sale.order",
"sale.order.line",
"purchase.order",
"purchase.order.line",
"account.invoice",
"account.invoice.line",
"stock.picking",
"stock.move",
"mrp.production",
"mrp.bom",
"project.project",
"project.task",
"hr.employee",
"crm.lead",
"crm.team",
"mail.thread",
"ir.attachment",
"ir.ui.view"
];
let availableModels = [];
for (const modelName of commonModels) {
try {
const fieldInfo = await this.odoo.callMethod(modelName, "fields_get", [[]], {});
if (fieldInfo && typeof fieldInfo === "object") {
availableModels.push(modelName);
}
} catch (error) {
}
}
if (options.excludePatterns) {
availableModels = availableModels.filter((name) => {
return !options.excludePatterns.some(
(pattern) => this.matchesPattern(name, pattern)
);
});
}
console.log(`\u{1F4E6} Found ${availableModels.length} accessible models (via fallback)`);
return availableModels;
}
/**
* Check if model name matches a pattern
*/
matchesPattern(name, pattern) {
if (pattern.includes("*")) {
const regex = new RegExp(pattern.replace(/\*/g, ".*"));
return regex.test(name);
}
return name === pattern;
}
/**
* Format model name to a readable description
*/
formatModelName(modelName) {
return modelName.split(".").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
}
/**
* Get field statistics for a model
*/
async getFieldStatistics(modelName) {
const schema = await this.introspectModel(modelName);
const fields = Object.values(schema.fields);
const stats = {
totalFields: fields.length,
requiredFields: fields.filter((f) => f.required).length,
readonlyFields: fields.filter((f) => f.readonly).length,
relationFields: fields.filter((f) => f.relation).length,
computedFields: fields.filter((f) => f.compute).length,
typeDistribution: {}
};
for (const field of fields) {
stats.typeDistribution[field.type] = (stats.typeDistribution[field.type] || 0) + 1;
}
return stats;
}
/**
* Validate that generated types would match current Odoo schema
*/
async validateGeneratedTypes(modelNames, generatedTypesPath) {
const issues = [];
return {
valid: issues.length === 0,
issues
};
}
};
// src/generators/typescript-generator.ts
var TypeScriptGenerator = class {
constructor(options = {}) {
this.options = options;
this.defaultOptions = {
includeComments: true,
includeJSDoc: true,
includeTimestamp: true,
optionalFields: true,
strictNulls: false,
exportTypes: true,
interfacePrefix: "",
interfaceSuffix: "",
enumForSelection: false
};
}
/**
* Generate TypeScript code from model schemas
*/
generate(schemas) {
const opts = { ...this.defaultOptions, ...this.options };
const lines = [];
if (opts.includeComments) {
lines.push("/**");
lines.push(" * Auto-generated TypeScript types from Odoo models");
if (opts.includeTimestamp) {
lines.push(` * Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
}
lines.push(" * ");
lines.push(" * DO NOT EDIT MANUALLY!");
lines.push(" * Regenerate with: npx next-core-codegen types");
lines.push(" */");
lines.push("");
}
if (opts.exportTypes) {
lines.push("// Odoo-specific types");
lines.push("export type Many2one<T = any> = [number, string] | false");
lines.push("export type One2many = number[]");
lines.push("export type Many2many = number[]");
lines.push("export type Reference = string");
lines.push("");
}
if (opts.enumForSelection) {
lines.push(...this.generateSelectionEnums(schemas));
}
for (const [modelName, schema] of Object.entries(schemas)) {
lines.push(...this.generateInterface(modelName, schema, opts));
lines.push("");
}
return lines.join("\n");
}
/**
* Generate TypeScript interface for a model
*/
generateInterface(modelName, schema, options) {
const interfaceName = this.toInterfaceName(modelName, options);
const lines = [];
if (options.includeJSDoc) {
lines.push("/**");
lines.push(` * ${schema.description}`);
lines.push(` * Odoo Model: ${modelName}`);
lines.push(" */");
}
lines.push(`export interface ${interfaceName} {`);
for (const [fieldName, field] of Object.entries(schema.fields)) {
const fieldLines = this.generateField(fieldName, field, options);
lines.push(...fieldLines.map((line) => " " + line));
}
lines.push("}");
return lines;
}
/**
* Generate TypeScript field definition
*/
generateField(fieldName, field, options) {
const lines = [];
if (options.includeJSDoc && (field.help || field.required)) {
lines.push("/**");
if (field.help) {
lines.push(` * ${field.help}`);
}
if (field.required) {
lines.push(" * @required");
}
lines.push(" */");
}
const tsType = this.toTypeScriptType(field, options);
const optional = options.optionalFields && !field.required ? "?" : "";
const nullish = options.strictNulls && !field.required ? " | null" : "";
lines.push(`${fieldName}${optional}: ${tsType}${nullish}`);
return lines;
}
/**
* Convert Odoo field type to TypeScript type
*/
toTypeScriptType(field, options) {
const typeMap = {
"char": "string",
"text": "string",
"html": "string",
"integer": "number",
"float": "number",
"monetary": "number",
"boolean": "boolean",
"date": "string",
"datetime": "string",
"binary": "string",
"selection": this.getSelectionType(field, options),
"many2one": "Many2one",
"one2many": "One2many",
"many2many": "Many2many",
"reference": "Reference"
};
if (field.related) {
return "string";
}
if (field.compute && !field.store) {
return "any";
}
return typeMap[field.type] || "any";
}
/**
* Get TypeScript type for selection field
*/
getSelectionType(field, options) {
if (!field.selection) {
return "string";
}
if (options.enumForSelection && field.selection.length > 0) {
const enumName = this.toEnumName(field.string || "Selection");
return enumName;
}
const values = field.selection.map(([key]) => `'${key}'`);
return values.join(" | ");
}
/**
* Generate selection enums
*/
generateSelectionEnums(schemas) {
const lines = ["// Selection field enums", ""];
const generatedEnums = /* @__PURE__ */ new Set();
for (const schema of Object.values(schemas)) {
for (const [fieldName, field] of Object.entries(schema.fields)) {
if (field.type === "selection" && field.selection) {
const enumName = this.toEnumName(field.string || fieldName);
if (!generatedEnums.has(enumName)) {
generatedEnums.add(enumName);
lines.push("/**");
lines.push(` * Selection options for ${field.string || fieldName}`);
lines.push(" */");
lines.push(`export enum ${enumName} {`);
for (const [key, label] of field.selection) {
const enumKey = this.toEnumKey(key);
lines.push(` /** ${label} */`);
lines.push(` ${enumKey} = '${key}',`);
}
lines.push("}");
lines.push("");
}
}
}
}
return lines;
}
/**
* Convert model name to interface name
*/
toInterfaceName(modelName, options) {
const name = modelName.split(".").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
return `${options.interfacePrefix}${name}${options.interfaceSuffix}`;
}
/**
* Convert selection string to enum name
*/
toEnumName(selectionName) {
return selectionName.replace(/[^a-zA-Z0-9\s]/g, "").split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
}
/**
* Convert selection key to enum key
*/
toEnumKey(key) {
return key.toUpperCase().replace(/[^A-Z0-9_]/g, "_").replace(/^[0-9]/, "_$&");
}
/**
* Generate field statistics as comments
*/
generateStatistics(schemas) {
const lines = [
"/**",
" * Generation Statistics",
" */"
];
let totalFields = 0;
let totalModels = Object.keys(schemas).length;
const typeCounts = {};
for (const schema of Object.values(schemas)) {
totalFields += Object.keys(schema.fields).length;
for (const field of Object.values(schema.fields)) {
typeCounts[field.type] = (typeCounts[field.type] || 0) + 1;
}
}
lines.push(`// Models: ${totalModels}`);
lines.push(`// Total Fields: ${totalFields}`);
lines.push("// Field Types:");
for (const [type, count] of Object.entries(typeCounts).sort()) {
lines.push(`// ${type}: ${count}`);
}
return lines;
}
/**
* Generate import statements for related models
*/
generateImports(schemas) {
const imports = /* @__PURE__ */ new Set();
for (const schema of Object.values(schemas)) {
for (const field of Object.values(schema.fields)) {
if (field.relation) {
const interfaceName = this.toInterfaceName(field.relation, this.defaultOptions);
imports.add(interfaceName);
}
}
}
if (imports.size === 0) {
return [];
}
const lines = [
"// Related model imports",
...Array.from(imports).map((name) => `import type { ${name} } from './${name.toLowerCase()}'`),
""
];
return lines;
}
};
// src/generators/hooks-generator.ts
var HooksGenerator = class {
constructor(options = {}) {
this.options = options;
this.defaultOptions = {
includeComments: true,
includeJSDoc: true,
includeTimestamp: true,
apiClientName: "OdooClient",
queryClientImport: "@tanstack/react-query",
includeOptimisticUpdates: true,
includeInfiniteQueries: false,
staleTime: 10 * 60 * 1e3,
// 10 minutes
cacheTime: 30 * 60 * 1e3
// 30 minutes
};
}
/**
* Generate React Query hooks from model schemas
*/
generate(schemas) {
const opts = { ...this.defaultOptions, ...this.options };
const lines = [];
if (opts.includeComments) {
lines.push("/**");
lines.push(" * Auto-generated React Query hooks from Odoo models");
if (opts.includeTimestamp) {
lines.push(` * Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
}
lines.push(" * ");
lines.push(" * DO NOT EDIT MANUALLY!");
lines.push(" * Regenerate with: npx next-core-codegen hooks");
lines.push(" */");
lines.push("");
}
lines.push(...this.generateImports(opts));
lines.push("");
for (const [modelName, schema] of Object.entries(schemas)) {
lines.push(...this.generateModelHooks(modelName, schema, opts));
lines.push("");
}
return lines.join("\n");
}
/**
* Generate imports section
*/
generateImports(options) {
return [
`import { useQuery, useMutation, useQueryClient, type UseQueryOptions, type UseMutationOptions } from '${options.queryClientImport}'`,
`import type { ${options.apiClientName} } from '@kodeme-io/next-core-odoo-api'`,
"",
"// Common types",
"export interface ApiQueryOptions {",
" domain?: any[]",
" fields?: string[]",
" limit?: number",
" offset?: number",
" order?: string",
"}",
"",
"export interface SearchOptions extends ApiQueryOptions {",
" context?: Record<string, any>",
"}"
];
}
/**
* Generate all hooks for a single model
*/
generateModelHooks(modelName, schema, options) {
const lines = [];
const interfaceName = this.toInterfaceName(modelName);
const modelKey = this.toModelKey(modelName);
if (options.includeJSDoc) {
lines.push("/**");
lines.push(` * Hooks for ${schema.description}`);
lines.push(` * Odoo Model: ${modelName}`);
lines.push(" */");
}
lines.push(...this.generateQueryKeysFactory(modelName, modelKey, interfaceName));
lines.push("");
lines.push(...this.generateListHook(modelName, modelKey, interfaceName, options));
lines.push("");
lines.push(...this.generateDetailHook(modelName, modelKey, interfaceName, options));
lines.push("");
lines.push(...this.generateSearchHook(modelName, modelKey, interfaceName, options));
lines.push("");
lines.push(...this.generateCreateHook(modelName, modelKey, interfaceName, options));
lines.push("");
lines.push(...this.generateUpdateHook(modelName, modelKey, interfaceName, options));
lines.push("");
lines.push(...this.generateDeleteHook(modelName, modelKey, interfaceName, options));
lines.push("");
if (options.includeInfiniteQueries) {
lines.push(...this.generateInfiniteQueryHook(modelName, modelKey, interfaceName, options));
lines.push("");
}
return lines;
}
/**
* Generate query keys factory for a model
*/
generateQueryKeysFactory(modelName, modelKey, interfaceName) {
return [
"/**",
` * Query keys factory for ${modelName}`,
" * Ensures type-safe query invalidation and cache management",
" */",
`export const ${modelKey}Keys = {`,
` all: ['${modelKey}'] as const,`,
` lists: () => [...${modelKey}Keys.all, 'list'] as const,`,
` list: (options?: ApiQueryOptions) => [...${modelKey}Keys.lists(), options || {}] as const,`,
` details: () => [...${modelKey}Keys.all, 'detail'] as const,`,
` detail: (id: number) => [...${modelKey}Keys.details(), id] as const,`,
` searches: () => [...${modelKey}Keys.all, 'search'] as const,`,
` search: (options?: SearchOptions) => [...${modelKey}Keys.searches(), options || {}] as const,`,
"}"
];
}
/**
* Generate list query hook (e.g., usePartners)
*/
generateListHook(modelName, modelKey, interfaceName, options) {
const hookName = `use${interfaceName}s`;
return [
"/**",
` * Query hook to fetch list of ${modelName} records`,
" * ",
" * @example",
" * ```tsx",
` * const { data, isLoading, error } = ${hookName}(api, {`,
` * domain: [['active', '=', true]],`,
` * limit: 10,`,
` * order: 'name ASC'`,
" * })",
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` queryOptions?: ApiQueryOptions,`,
` options?: Omit<UseQueryOptions<${interfaceName}[], Error>, 'queryKey' | 'queryFn'>`,
`) {`,
" return useQuery({",
` queryKey: ${modelKey}Keys.list(queryOptions),`,
` queryFn: async () => {`,
` const records = await api.searchRead(`,
` '${modelName}',`,
` queryOptions?.domain || [],`,
` queryOptions?.fields || [],`,
` {`,
` limit: queryOptions?.limit,`,
` offset: queryOptions?.offset,`,
` order: queryOptions?.order,`,
` }`,
` )`,
` return records as ${interfaceName}[]`,
` },`,
` staleTime: ${options.staleTime},`,
` ...options,`,
" })",
"}"
];
}
/**
* Generate detail query hook (e.g., usePartner)
*/
generateDetailHook(modelName, modelKey, interfaceName, options) {
const hookName = `use${interfaceName}`;
return [
"/**",
` * Query hook to fetch a single ${modelName} record by ID`,
" * ",
" * @example",
" * ```tsx",
` * const { data, isLoading } = ${hookName}(api, 123)`,
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` id: number,`,
` fields?: string[],`,
` options?: Omit<UseQueryOptions<${interfaceName} | null, Error>, 'queryKey' | 'queryFn'>`,
`) {`,
" return useQuery({",
` queryKey: ${modelKey}Keys.detail(id),`,
` queryFn: async () => {`,
` if (!id) return null`,
` const records = await api.searchRead(`,
` '${modelName}',`,
` [['id', '=', id]],`,
` fields || [],`,
` { limit: 1 }`,
` )`,
` return records.length > 0 ? (records[0] as ${interfaceName}) : null`,
` },`,
` staleTime: ${options.staleTime},`,
` enabled: !!id,`,
` ...options,`,
" })",
"}"
];
}
/**
* Generate search hook with custom domain
*/
generateSearchHook(modelName, modelKey, interfaceName, options) {
const hookName = `useSearch${interfaceName}s`;
return [
"/**",
` * Search hook with custom domain and context`,
" * ",
" * @example",
" * ```tsx",
` * const { data } = ${hookName}(api, {`,
` * domain: [['email', 'ilike', '@example.com']],`,
` * context: { active_test: false }`,
" * })",
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` searchOptions?: SearchOptions,`,
` options?: Omit<UseQueryOptions<${interfaceName}[], Error>, 'queryKey' | 'queryFn'>`,
`) {`,
" return useQuery({",
` queryKey: ${modelKey}Keys.search(searchOptions),`,
` queryFn: async () => {`,
` const records = await api.searchRead(`,
` '${modelName}',`,
` searchOptions?.domain || [],`,
` searchOptions?.fields || [],`,
` {`,
` limit: searchOptions?.limit,`,
` offset: searchOptions?.offset,`,
` order: searchOptions?.order,`,
` context: searchOptions?.context,`,
` }`,
` )`,
` return records as ${interfaceName}[]`,
` },`,
` staleTime: ${options.staleTime},`,
` ...options,`,
" })",
"}"
];
}
/**
* Generate create mutation hook
*/
generateCreateHook(modelName, modelKey, interfaceName, options) {
const hookName = `useCreate${interfaceName}`;
return [
"/**",
` * Mutation hook to create a new ${modelName} record`,
" * ",
" * @example",
" * ```tsx",
` * const { mutate, isPending } = ${hookName}(api)`,
` * mutate({ name: 'New Record', active: true })`,
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` options?: Omit<UseMutationOptions<number, Error, Partial<${interfaceName}>>, 'mutationFn'>`,
`) {`,
" const queryClient = useQueryClient()",
"",
" return useMutation({",
` mutationFn: async (data: Partial<${interfaceName}>) => {`,
` const id = await api.create('${modelName}', data)`,
` return id`,
` },`,
` onSuccess: (newId, variables, context) => {`,
` // Invalidate all ${modelName} queries`,
` queryClient.invalidateQueries({ queryKey: ${modelKey}Keys.all })`,
` options?.onSuccess?.(newId, variables, context)`,
` },`,
` ...options,`,
" })",
"}"
];
}
/**
* Generate update mutation hook
*/
generateUpdateHook(modelName, modelKey, interfaceName, options) {
const hookName = `useUpdate${interfaceName}`;
const optimisticUpdate = options.includeOptimisticUpdates ? [
` onMutate: async ({ id, data }) => {`,
` // Cancel outgoing queries`,
` await queryClient.cancelQueries({ queryKey: ${modelKey}Keys.detail(id) })`,
``,
` // Snapshot previous value`,
` const previous = queryClient.getQueryData(${modelKey}Keys.detail(id))`,
``,
` // Optimistically update`,
` if (previous) {`,
` queryClient.setQueryData(${modelKey}Keys.detail(id), {`,
` ...previous,`,
` ...data,`,
` })`,
` }`,
``,
` return { previous }`,
` },`,
` onError: (err, variables, context) => {`,
` // Rollback on error`,
` if (context?.previous) {`,
` queryClient.setQueryData(${modelKey}Keys.detail(variables.id), context.previous)`,
` }`,
` options?.onError?.(err, variables, context)`,
` },`
] : [];
return [
"/**",
` * Mutation hook to update an existing ${modelName} record`,
" * ",
" * @example",
" * ```tsx",
` * const { mutate } = ${hookName}(api)`,
` * mutate({ id: 123, data: { name: 'Updated Name' } })`,
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` options?: Omit<UseMutationOptions<boolean, Error, { id: number, data: Partial<${interfaceName}> }>, 'mutationFn'>`,
`) {`,
" const queryClient = useQueryClient()",
"",
" return useMutation({",
` mutationFn: async ({ id, data }: { id: number, data: Partial<${interfaceName}> }) => {`,
` const success = await api.write('${modelName}', [id], data)`,
` return success`,
` },`,
...optimisticUpdate,
` onSuccess: (result, { id }, context) => {`,
` // Invalidate queries`,
` queryClient.invalidateQueries({ queryKey: ${modelKey}Keys.detail(id) })`,
` queryClient.invalidateQueries({ queryKey: ${modelKey}Keys.lists() })`,
` options?.onSuccess?.(result, { id, data: {} as any }, context)`,
` },`,
` ...options,`,
" })",
"}"
];
}
/**
* Generate delete mutation hook
*/
generateDeleteHook(modelName, modelKey, interfaceName, options) {
const hookName = `useDelete${interfaceName}`;
return [
"/**",
` * Mutation hook to delete a ${modelName} record`,
" * ",
" * @example",
" * ```tsx",
` * const { mutate } = ${hookName}(api)`,
` * mutate(123)`,
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` options?: Omit<UseMutationOptions<boolean, Error, number>, 'mutationFn'>`,
`) {`,
" const queryClient = useQueryClient()",
"",
" return useMutation({",
` mutationFn: async (id: number) => {`,
` const success = await api.unlink('${modelName}', [id])`,
` return success`,
` },`,
` onSuccess: (result, id, context) => {`,
` // Remove from cache`,
` queryClient.removeQueries({ queryKey: ${modelKey}Keys.detail(id) })`,
` queryClient.invalidateQueries({ queryKey: ${modelKey}Keys.lists() })`,
` options?.onSuccess?.(result, id, context)`,
` },`,
` ...options,`,
" })",
"}"
];
}
/**
* Generate infinite query hook for pagination
*/
generateInfiniteQueryHook(modelName, modelKey, interfaceName, options) {
const hookName = `use${interfaceName}sInfinite`;
return [
"/**",
` * Infinite query hook for paginated ${modelName} list`,
" * ",
" * @example",
" * ```tsx",
` * const { data, fetchNextPage, hasNextPage } = ${hookName}(api, {`,
` * domain: [['active', '=', true]],`,
` * limit: 20`,
" * })",
" * ```",
" */",
`export function ${hookName}(`,
` api: ${options.apiClientName},`,
` queryOptions?: ApiQueryOptions & { limit?: number }`,
`) {`,
` const limit = queryOptions?.limit || 20`,
"",
` return useInfiniteQuery({`,
` queryKey: [...${modelKey}Keys.lists(), 'infinite', queryOptions],`,
` queryFn: async ({ pageParam = 0 }) => {`,
` const records = await api.searchRead(`,
` '${modelName}',`,
` queryOptions?.domain || [],`,
` queryOptions?.fields || [],`,
` {`,
` limit,`,
` offset: pageParam * limit,`,
` order: queryOptions?.order,`,
` }`,
` )`,
` return {`,
` records: records as ${interfaceName}[],`,
` nextPage: records.length === limit ? pageParam + 1 : undefined,`,
` }`,
` },`,
` getNextPageParam: (lastPage) => lastPage.nextPage,`,
` staleTime: ${options.staleTime},`,
` })`,
"}"
];
}
/**
* Convert model name to interface name
*/
toInterfaceName(modelName) {
return modelName.split(".").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
}
/**
* Convert model name to query key prefix
*/
toModelKey(modelName) {
return modelName.replace(/\./g, "");
}
};
// src/generators/api-client-generator.ts
var ApiClientGenerator = class {
constructor(options = {}) {
this.options = options;
this.defaultOptions = {
includeComments: true,
includeJSDoc: true,
includeTimestamp: true,
baseClientName: "OdooClient",
className: "",
includeAdvancedMethods: true,
includeFieldHelpers: true
};
}
/**
* Generate API client class from model schema
*/
generate(modelName, schema) {
const opts = { ...this.defaultOptions, ...this.options };
const lines = [];
const interfaceName = this.toInterfaceName(modelName);
const className = opts.className || `${interfaceName}API`;
if (opts.includeComments) {
lines.push("/**");
lines.push(` * Auto-generated API client for ${modelName}`);
if (opts.includeTimestamp) {
lines.push(` * Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
}
lines.push(" * ");
lines.push(" * DO NOT EDIT MANUALLY!");
lines.push(" * Regenerate with: npx next-core-codegen api-client");
lines.push(" */");
lines.push("");
}
lines.push(...this.generateImports(interfaceName, opts));
lines.push("");
lines.push(...this.generateClass(modelName, schema, className, interfaceName, opts));
return lines.join("\n");
}
/**
* Generate imports
*/
generateImports(interfaceName, options) {
return [
`import type { ${options.baseClientName} } from '@kodeme-io/next-core-odoo-api'`,
`import type { ${interfaceName} } from './types'`,
"",
"// Common query options",
"export interface QueryOptions {",
" domain?: any[]",
" fields?: string[]",
" limit?: number",
" offset?: number",
" order?: string",
" context?: Record<string, any>",
"}",
"",
"export interface CreateOptions {",
" context?: Record<string, any>",
"}",
"",
"export interface UpdateOptions {",
" context?: Record<string, any>",
"}",
"",
"export interface DeleteOptions {",
" context?: Record<string, any>",
"}"
];
}
/**
* Generate the API client class
*/
generateClass(modelName, schema, className, interfaceName, options) {
const lines = [];
if (options.includeJSDoc) {
lines.push("/**");
lines.push(` * API client for ${schema.description}`);
lines.push(` * Odoo Model: ${modelName}`);
lines.push(" * ");
lines.push(" * @example");
lines.push(" * ```typescript");
lines.push(` * const api = new ${className}(odooClient)`);
lines.push(` * const records = await api.getAll()`);
lines.push(` * const record = await api.getById(123)`);
lines.push(` * const id = await api.create({ name: 'New Record' })`);
lines.push(" * ```");
lines.push(" */");
}
lines.push(`export class ${className} {`);
lines.push(` private readonly modelName = '${modelName}'`);
lines.push("");
lines.push(" /**");
lines.push(` * Creates a new ${className} instance`);
lines.push(` * @param client - The Odoo client instance`);
lines.push(" */");
lines.push(` constructor(private client: ${options.baseClientName}) {}`);
lines.push("");
lines.push(...this.generateGetAllMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateGetByIdMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateSearchMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateCountMethod().map((l) => " " + l));
lines.push("");
lines.push(...this.generateCreateMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateCreateManyMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateUpdateMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateDeleteMethod().map((l) => " " + l));
lines.push("");
if (options.includeAdvancedMethods) {
lines.push(...this.generateExistsMethod().map((l) => " " + l));
lines.push("");
lines.push(...this.generateFindOneMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateUpsertMethod(interfaceName).map((l) => " " + l));
lines.push("");
lines.push(...this.generateBatchMethod(interfaceName).map((l) => " " + l));
lines.push("");
}
if (options.includeFieldHelpers) {
lines.push(...this.generateFieldHelpers(schema, interfaceName).map((l) => " " + l));
}
lines.push("}");
return lines;
}
/**
* Generate getAll method
*/
generateGetAllMethod(interfaceName) {
return [
"/**",
" * Get all records with optional filters",
" * ",
" * @example",
" * ```typescript",
" * const records = await api.getAll({",
` * domain: [['active', '=', true]],`,
" * limit: 10,",
" * order: 'name ASC'",
" * })",
" * ```",
" */",
`async getAll(options?: QueryOptions): Promise<${interfaceName}[]> {`,
" const records = await this.client.searchRead(",
" this.modelName,",
" options?.domain || [],",
" options?.fields || [],",
" {",
" limit: options?.limit,",
" offset: options?.offset,",
" order: options?.order,",
" context: options?.context,",
" }",
" )",
` return records as ${interfaceName}[]`,
"}"
];
}
/**
* Generate getById method
*/
generateGetByIdMethod(interfaceName) {
return [
"/**",
" * Get a single record by ID",
" * ",
" * @param id - Record ID",
" * @param fields - Optional fields to fetch",
" * @returns The record or null if not found",
" */",
`async getById(id: number, fields?: string[]): Promise<${interfaceName} | null> {`,
" const records = await this.client.searchRead(",
" this.modelName,",
" [['id', '=', id]],",
" fields || [],",
" { limit: 1 }",
" )",
` return records.length > 0 ? (records[0] as ${interfaceName}) : null`,
"}"
];
}
/**
* Generate search method
*/
generateSearchMethod(interfaceName) {
return [
"/**",
" * Search records with custom domain",
" * ",
" * @example",
" * ```typescript",
" * const results = await api.search({",
` * domain: [['email', 'ilike', '@example.com'], ['active', '=', true]],`,
" * limit: 50",
" * })",
" * ```",
" */",
`async search(options: QueryOptions): Promise<${interfaceName}[]> {`,
" const records = await this.client.searchRead(",
" this.modelName,",
" options.domain || [],",
" options.fields || [],",
" {",
" limit: options.limit,",
" offset: options.offset,",
" order: options.order,",
" context: options.context,",
" }",
" )",
` return records as ${interfaceName}[]`,
"}"
];
}
/**
* Generate count method
*/
generateCountMethod() {
return [
"/**",
" * Count records matching domain",
" * ",
" * @param domain - Search domain",
" * @returns Number of matching records",
" */",
"async count(domain?: any[]): Promise<number> {",
" return await this.client.searchCount(this.modelName, domain || [])",
"}"
];
}
/**
* Generate create method
*/
generateCreateMethod(interfaceName) {
return [
"/**",
" * Create a new record",
" * ",
" * @param data - Record data",
" * @param options - Create options",
" * @returns Created record ID",
" */",
`async create(data: Partial<${interfaceName}>, options?: CreateOptions): Promise<number> {`,
" return await this.client.create(this.modelName, data, options?.context)",
"}"
];
}
/**
* Generate createMany method
*/
generateCreateManyMethod(interfaceName) {
return [
"/**",
" * Create multiple records",
" * ",
" * @param dataList - Array of record data",
" * @param options - Create options",
" * @returns Array of created record IDs",
" */",
`async createMany(dataList: Partial<${interfaceName}>[], options?: CreateOptions): Promise<number[]> {`,
" const ids: number[] = []",
" for (const data of dataList) {",
" const id = await this.create(data, options)",
" ids.push(id)",
" }",
" return ids",
"}"
];
}
/**
* Generate update method
*/
generateUpdateMethod(interfaceName) {
return [
"/**",
" * Update record(s)",
" * ",
" * @param ids - Record ID(s) to update",
" * @param data - Update data",
" * @param options - Update options",
" * @returns True if successful",
" */",
`async update(ids: number | number[], data: Partial<${interfaceName}>, options?: UpdateOptions): Promise<boolean> {`,
" const idArray = Array.isArray(ids) ? ids : [ids]",
" return await this.client.write(this.modelName, idArray, data, options?.context)",
"}"
];
}
/**
* Generate delete method
*/
generateDeleteMethod() {
return [
"/**",
" * Delete record(s)",
" * ",
" * @param ids - Record ID(s) to delete",
" * @param options - Delete options",
" * @returns True if successful",
" */",
"async delete(ids: number | number[], options?: DeleteOptions): Promise<boolean> {",
" const idArray = Array.isArray(ids) ? ids : [ids]",
" return await this.client.unlink(this.modelName, idArray, options?.context)",
"}"
];
}
/**
* Generate exists method
*/
generateExistsMethod() {
return [
"/**",
" * Check if a record exists",
" * ",
" * @param id - Record ID",
" * @returns True if record exists",
" */",
"async exists(id: number): Promise<boolean> {",
" const count = await this.count([['id', '=', id]])",
" return count > 0",
"}"
];
}
/**
* Generate findOne method
*/
generateFindOneMethod(interfaceName) {
return [
"/**",
" * Find first record matching domain",
" * ",
" * @param domain - Search domain",
" * @param fields - Optional fields to fetch",
" * @returns First matching record or null",
" */",
`async findOne(domain: any[], fields?: string[]): Promise<${interfaceName} | null> {`,
" const records = await this.search({ domain, fields, limit: 1 })",
" return records.length > 0 ? records[0] : null",
"}"
];
}
/**
* Generate upsert method
*/
generateUpsertMethod(interfaceName) {
return [
"/**",
" * Update if exists, create if not",
" * ",
" * @param searchDomain - Domain to find existing record",
" * @param data - Record data",
" * @returns Record ID (existing or newly created)",
" */",
`async upsert(searchDomain: any[], data: Partial<${interfaceName}>): Promise<number> {`,
" const existing = await this.findOne(searchDomain, ['id'])",
" if (existing && existing.id) {",
" await this.update(existing.id, data)",
" return existing.id",
" }",
" return await this.create(data)",
"}"
];
}
/**
* Generate batch operation method
*/
generateBatchMethod(interfaceName) {
return [
"/**",
" * Perform batch operations",
" * ",
" * @param operations - Array of operations",
" * @returns Results of operations",
" */",
"async batch(operations: Array<{",
" type: 'create' | 'update' | 'delete'",
` data?: Partial<${interfaceName}>`,
" ids?: number[]",
"}>): Promise<Array<number | boolean>> {",
" const results: Array<number | boolean> = []",
"",
" for (const op of operations) {",
" try {",
" if (op.type === 'create' && op.data) {",
" const id = await this.create(op.data)",
" results.push(id)",
" } else if (op.type === 'update' && op.ids && op.data) {",
" const success = await this.update(op.ids, op.data)",
" results.push(success)",
" } else if (op.type === 'delete' && op.ids) {",
" const success = await this.delete(op.ids)",
" results.push(success)",
" }",
" } catch (error) {",
" console.error(`Batch operation failed:`, error)",
" results.push(false)",
" }",
" }",
"",
" return results",
"}"
];
}
/**
* Generate field-specific helper methods
*/
generateFieldHelpers(schema, interfaceName) {
const lines = [];
const relationFields = Object.entries(schema.fields).filter(([, field]) => field.relation);
const selectionFields = Object.entries(schema.fields).filter(([, field]) => field.type === "selection");
if (relationFields.length > 0 || selectionFields.length > 0) {
lines.push("// Field-specific helper methods");
lines.push("");
}
for (const [fieldName, field] of relationFields) {
if (field.type === "many2one") {
const methodName = `getBy${this.capitalize(fieldName)}`;
lines.push("/**");
lines.push(` * Find records by ${field.string || fieldName}`);
lines.push(" */");
lines.push(`async ${methodName}(${fieldName}Id: number, options?: QueryOptions): Promise<${interfaceName}[]> {`);
lines.push(` return await this.search({`);
lines.push(` ...options,`);
lines.push(` domain: [...(options?.domain || []), ['${fieldName}', '=', ${fieldName}Id]],`);
lines.push(` })`);
lines.push("}");
lines.push("");
}
}
for (const [fieldName, field] of selectionFields) {
if (field.selection && field.selection.length > 0) {
const methodName = `getBy${this.capitalize(fieldName)}`;
const valueType = field.selection.map(([key]) => `'${key}'`).join(" | ");
lines.push("/**");
lines.push(` * Find records by ${field.string || fieldName}`);
lines.push(" */");
lines.push(`async ${methodName}(${fieldName}: ${valueType}, options?: QueryOptions): Promise<${interfaceName}[]> {`);
lines.push(` return await this.search({`);
lines.push(` ...options,`);
lines.push(` domain: [...(options?.domain || []), ['${fieldName}', '=', ${fieldName}]],`);
lines.push(` })`);
lines.push("}");
lines.push("");
}
}
return lines;
}
/**
* Convert model name to interface name
*/
toInterfaceName(modelName) {
return modelName.split(".").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
}
/**
* Capitalize first letter
*/
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
};
// src/commands/generate-types.ts
import { Command } from "commander";
// src/utils/config.ts
import fs from "fs";
import path from "path";
function loadConfig(configPath) {
const possiblePaths = configPath ? [configPath] : [
".next-core.json",
".next-core.js",
"next-core.config.json",
"next-core.config.js"
];
for (const filePath of possiblePaths) {
try {
const fullPath = path.resolve(process.cwd(), filePath);
if (fs.existsSync(fullPath)) {
if (filePath.endsWith(".js")) {
const configModule = __require(fullPath);
const config2 = typeof configModule === "function" ? configModule() : configModule;
return validateConfig(config2);
} else {
const content = fs.readFileSync(fullPath, "utf-8");
const config2 = JSON.parse(content);
return validateConfig(config2);
}
}
} catch (error) {
throw new Error(`Failed to load config from ${filePath}: ${error}`);
}
}
throw new Error(
"No configuration file found. Create one of:\n \u2022 .next-core.json\n \u2022 .next-core.js\n \u2022 next-core.config.json\n \u2022 next-core.config.js"
);
}
function validateConfig(config2) {
if (!config2 || typeof config2 !== "object") {
throw new Error("Configuration must be an object");
}
if (!config2.odoo || typeof config2.odoo !== "object") {
throw new Error('Configuration must include an "odoo" object');
}
const requiredOdooFields = ["url", "database", "username", "password"];
for (const field of requiredOdooFields) {
if (!config2.odoo[field]) {
throw new Error(`Odoo configuration missing required field: ${field}`);
}
}
if (!config2.models || !Array.isArray(config2.models)) {
config2.models = [];
}
if (!config2.output || typeof config2.output !== "string") {
config2.output = "src/types/odoo.ts";
}
if (!config2.exclude || !Array.isArray(config2.exclude)) {
config2.exclude = [];
}
return config2;
}
function createDefaultConfig() {
return {
odoo: {
url: "${ODOO_URL}",
database: "${ODOO_DB}",
username: "${ODOO_USERNAME}",
password: "${ODOO_PASSWORD}"
},
models: [
"res.partner",
"res.users",
"product.product",
"product.category",
"sale.order",
"sale.order.line"
],
output: "src/types/odoo.ts",
exclude: [
"ir.*",
"mail.*",
"base.*"
]
};
}
async function saveConfig(config2, filePath) {
const fullPath = path.resolve(process.cwd(), filePath);
const dir = path.dirname(fullPath);
await fs.promises.mkdir(dir, { recursive: true });
if (filePath.endsWith(".js")) {
const content = `module.exports = ${JSON.stringify(config2, null, 2)}`;
await fs.promises.writeFile(fullPath, content, "utf-8");
} else {
const content = JSON.stringify(config2, null, 2);
await fs.promises.writeFile(fullPath, content, "utf-8");
}
}
// src/utils/security.ts
var SENSITIVE_FIELDS = [
"password",
"password_hash",
"api_key",
"apikey",
"secret",
"token",
"auth",
"authorization",
"credentials"
];
function isSensitiveField(fieldName) {
const lowerField = fieldName.toLowerCase();
return SENSITIVE_FIELDS.some(
(sensitive) => lowerField.includes(sensitive) || sensitive.includes(lowerField)
);
}
function redactValue(value, fieldName) {
if (typeof value !== "string") {
return value;
}
if (fieldName && isSensitiveField(fieldName)) {
return maskSensitiveValue(value);
}
if (looksLikeSensitiveValue(value)) {
return maskSensitiveValue(value);
}
return value;
}
function maskSensitiveValue(value) {
if (!value || value.length === 0) {
return "[EMPTY]";
}
if (value === "${ODOO_PASSWORD}" || value.startsWith("${")) {
return "[ENV_VAR]";
}
if (value.length <= 4) {
return value.split("").map(() => "*").join("");
}
return `${value.substring(0, 2)}${"*".repeat(value.length - 4)}${value.substring(value.length - 2)}`;
}
function looksLikeSensitiveValue(value) {
const patterns = [
/^[A-Za-z0-9+/]{40,}={0,