Files
lifeforge/scripts/generateCollectionsSchemas.ts
melvinchia3636 fcb709325a refactor(server): standardize schema keys to use snake_case naming convention
Former-commit-id: c0b02f8db8de1ae902d7c25ef987fa9adba44118 [formerly 5f2a9cae47c57e2cf76d874dcae2fe5a608df5e2] [formerly 07148818886b9a8d4ccd16cf8d4291b689611e18 [formerly 6b983167d2180ae3ae78940b59c3ca7025d8247d]]
Former-commit-id: c6833453d43e6f11e095cd817f62b6e24d1ed074 [formerly 7acad0eb19eed6d37c8b235a76b7650297e3a713]
Former-commit-id: 2e59c0b6b3c3b22a03b75656dc24e81af3295f09
2025-09-11 22:08:34 +08:00

380 lines
9.6 KiB
TypeScript

import { LoggingService } from '@server/core/functions/logging/loggingService'
import chalk from 'chalk'
import dotenv from 'dotenv'
import fs from 'fs/promises'
import _ from 'lodash'
import path from 'path'
import PocketBase, { type CollectionModel } from 'pocketbase'
import prettier from 'prettier'
// Types
interface Environment {
PB_HOST: string
PB_EMAIL: string
PB_PASSWORD: string
}
interface ModuleCollectionsMap {
[moduleName: string]: CollectionModel[]
}
interface SchemaGenerationResult {
moduleSchemas: Record<string, string>
mainSchemaContent: string
}
interface PocketBaseField {
name: string
type: string
required?: boolean
maxSelect?: number
values?: string[]
[key: string]: unknown
}
interface FieldTypeMapping {
[fieldType: string]: (field: PocketBaseField) => string
}
// Constants
const PATHS = {
ENV_FILE: path.resolve(__dirname, '../server/env/.env.local'),
MODULES_DIR: path.resolve(__dirname, '../server/src/lib'),
CORE_SCHEMA: path.resolve(__dirname, '../server/src/core/schema.ts')
} as const
const FIELD_TYPE_MAPPING: FieldTypeMapping = {
text: () => 'z.string()',
richtext: () => 'z.string()',
number: () => 'z.number()',
bool: () => 'z.boolean()',
email: () => 'z.email()',
url: () => 'z.url()',
date: () => 'z.string()',
autodate: () => 'z.string()',
password: () => 'z.string()',
json: () => 'z.any()',
geoPoint: () => 'z.object({ lat: z.number(), lon: z.number() })',
select: field => {
const values = [...(field.values ?? []), ...(field.required ? [] : [''])]
const enumSchema = `z.enum(${JSON.stringify(values)})`
return (field.maxSelect ?? 1) > 1 ? `z.array(${enumSchema})` : enumSchema
},
file: field => {
const baseSchema = 'z.string()'
return (field.maxSelect ?? 1) > 1 ? `z.array(${baseSchema})` : baseSchema
},
relation: field => {
const baseSchema = 'z.string()'
return (field.maxSelect ?? 1) > 1 ? `z.array(${baseSchema})` : baseSchema
}
}
// Environment validation
function validateEnvironment(): Environment {
dotenv.config({ path: PATHS.ENV_FILE })
const { PB_HOST, PB_EMAIL, PB_PASSWORD } = process.env
if (!PB_HOST || !PB_EMAIL || !PB_PASSWORD) {
LoggingService.error(
'Missing required environment variables: PB_HOST, PB_EMAIL, and PB_PASSWORD'
)
process.exit(1)
}
return { PB_HOST, PB_EMAIL, PB_PASSWORD }
}
// PocketBase authentication
async function authenticatePocketBase(env: Environment): Promise<PocketBase> {
const pb = new PocketBase(env.PB_HOST)
try {
await pb
.collection('_superusers')
.authWithPassword(env.PB_EMAIL, env.PB_PASSWORD)
if (!pb.authStore.isSuperuser || !pb.authStore.isValid) {
throw new Error('Invalid credentials or insufficient permissions')
}
return pb
} catch (error) {
LoggingService.error(
`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
)
process.exit(1)
}
}
// Convert field to Zod schema
function convertFieldToZodSchema(field: PocketBaseField): string | null {
if (field.name === 'id') {
return null // Skip auto-generated fields
}
const converter = FIELD_TYPE_MAPPING[field.type]
if (!converter) {
LoggingService.warn(
`Unknown field type '${field.type}' for field '${field.name}'. Skipping.`
)
return null
}
return converter(field)
}
// Generate Zod schema for a collection
function generateCollectionSchema(
collection: CollectionModel
): Record<string, string> {
const zodSchemaObject: Record<string, string> = {}
for (const field of collection.fields) {
const zodSchema = convertFieldToZodSchema(field as PocketBaseField)
if (zodSchema) {
zodSchemaObject[field.name] = zodSchema
}
}
return zodSchemaObject
}
// Build module collections mapping
async function buildModuleCollectionsMap(
collections: CollectionModel[]
): Promise<ModuleCollectionsMap> {
const moduleCollectionsMap: ModuleCollectionsMap = {}
let allModules: Array<{ name: string; isDirectory(): boolean }>
try {
const moduleEntries = await fs.readdir(PATHS.MODULES_DIR, {
withFileTypes: true
})
allModules = moduleEntries.filter(entry => entry.isDirectory())
} catch (error) {
LoggingService.error(`Failed to read modules directory: ${error}`)
process.exit(1)
}
for (const collection of collections) {
const matchingModule = allModules.find(module =>
collection.name.startsWith(_.snakeCase(module.name))
)
if (!matchingModule) {
LoggingService.warn(
`Collection '${collection.name}' has no corresponding module`
)
continue
}
if (!moduleCollectionsMap[matchingModule.name]) {
moduleCollectionsMap[matchingModule.name] = []
}
moduleCollectionsMap[matchingModule.name].push(collection)
}
const totalCollections = Object.values(moduleCollectionsMap).flat().length
const moduleCount = Object.keys(moduleCollectionsMap).length
LoggingService.info(
`Found ${totalCollections} collections across ${moduleCount} modules`
)
return moduleCollectionsMap
}
// Generate module schema content
function generateModuleSchemaContent(
moduleName: string,
collections: CollectionModel[]
): string {
const schemaEntries: string[] = []
for (const collection of collections) {
LoggingService.debug(
`Processing collection ${chalk.bold(collection.name)} with ${collection.fields.length} fields`
)
const zodSchemaObject = generateCollectionSchema(collection)
const schemaObjectString = Object.entries(zodSchemaObject)
.map(([key, value]) => ` ${key}: ${value},`)
.join('\n')
const collectionName = collection.name.split('__').pop()
const zodSchemaString = `z.object({\n${schemaObjectString}\n})`
schemaEntries.push(` ${collectionName}: ${zodSchemaString},`)
LoggingService.info(
`Generated schema for collection ${chalk.bold(collection.name)}`
)
}
return `import { z } from 'zod/v4'
const ${_.camelCase(moduleName)}Schemas = {
${schemaEntries.join('\n')}
}
export default ${_.camelCase(moduleName)}Schemas
`
}
// Generate main schema content
function generateMainSchemaContent(moduleNames: string[]): string {
const imports = moduleNames
.map(
moduleName =>
` ${moduleName}: (await import('@lib/${moduleName === 'users' ? 'user' : _.camelCase(moduleName)}/schema')).default,`
)
.join('\n')
return `import flattenSchemas from '@functions/utils/flattenSchema'
export const SCHEMAS = {
${imports}
}
const COLLECTION_SCHEMAS = flattenSchemas(SCHEMAS)
export default COLLECTION_SCHEMAS
`
}
// Write formatted file
async function writeFormattedFile(
filePath: string,
content: string
): Promise<void> {
try {
const formattedContent = await prettier.format(content, {
parser: 'typescript'
})
await fs.writeFile(filePath, formattedContent)
} catch (error) {
LoggingService.error(`Failed to write file ${filePath}: ${error}`)
throw error
}
}
// Generate schemas for all modules
async function generateSchemas(
moduleCollectionsMap: ModuleCollectionsMap
): Promise<SchemaGenerationResult> {
const moduleSchemas: Record<string, string> = {}
const moduleNames: string[] = []
for (const [moduleDirName, collections] of Object.entries(
moduleCollectionsMap
)) {
if (!collections.length) {
LoggingService.warn(
`No collections found for module ${chalk.bold(moduleDirName)}`
)
continue
}
const moduleName = collections[0].name.split('__')[0]
moduleNames.push(moduleName)
const moduleSchemaContent = generateModuleSchemaContent(
moduleName,
collections
)
moduleSchemas[moduleDirName] = moduleSchemaContent
// Write individual module schema file
const moduleSchemaPath = path.join(
PATHS.MODULES_DIR,
moduleDirName,
'schema.ts'
)
await writeFormattedFile(moduleSchemaPath, moduleSchemaContent)
LoggingService.debug(
`Created schema file for module ${chalk.bold(moduleDirName)} at ${chalk.bold(`lib/${moduleDirName}/schema.ts`)}`
)
}
const mainSchemaContent = generateMainSchemaContent(moduleNames)
return { moduleSchemas, mainSchemaContent }
}
// Main execution function
async function main(): Promise<void> {
try {
LoggingService.info('Starting schema generation process...')
// Setup
const env = validateEnvironment()
const pb = await authenticatePocketBase(env)
// Fetch collections
LoggingService.debug('Fetching collections from PocketBase...')
const allCollections = await pb.collections.getFullList()
const userCollections = allCollections.filter(
collection => !collection.system
)
LoggingService.info(
`Found ${userCollections.length} user-defined collections`
)
// Build module mapping
const moduleCollectionsMap =
await buildModuleCollectionsMap(userCollections)
// Generate schemas
const { moduleSchemas, mainSchemaContent } =
await generateSchemas(moduleCollectionsMap)
// Write main schema file
await writeFormattedFile(PATHS.CORE_SCHEMA, mainSchemaContent)
LoggingService.debug(
`Updated main schema file at ${chalk.bold('core/schema.ts')}`
)
// Summary
const moduleCount = Object.keys(moduleSchemas).length
LoggingService.info(
`Schema generation completed! Created ${moduleCount} module schema files.`
)
} catch (error) {
LoggingService.error(`Schema generation failed: ${error}`)
process.exit(1)
}
}
// Execute if this file is run directly
if (require.main === module) {
main().catch(error => {
LoggingService.error(`Unhandled error: ${error}`)
process.exit(1)
})
}