Files
lifeforge/scripts/generateCollectionsSchemas.ts
melvinchia3636 7ceea8af82 25w29
Former-commit-id: df62c2162af00a17f7b54df676efef01785f4ca0 [formerly 0c495f363b5eba754510eb7c6aeca9fa248a2b13] [formerly 75c4d868e750ea66bf969c3a78f563e0691fe6cb [formerly d84ae7c14ccab294f7ca18a739cf0a5ca2842ef6]]
Former-commit-id: 7bbd5062997e25f31f6c8e7962b34491feceb76d [formerly f2a99dfbbd2d518406f5c82d2e32a7957ac6aeff]
Former-commit-id: 56b4dae767a99591aeec0840e4ad802b17d50ce1
2025-07-20 13:25:28 +08:00

296 lines
8.8 KiB
TypeScript

import chalk from 'chalk'
import dotenv from 'dotenv'
import fs from 'fs'
import _ from 'lodash'
import path from 'path'
import { singular } from 'pluralize'
import Pocketbase, { type CollectionModel } from 'pocketbase'
const toBeWritten: Record<string, string> = {}
const CUSTOM_SCHEMAS_DELIMITER =
'// -------------------- CUSTOM SCHEMAS --------------------'
const TARGET_PATH = path.resolve(__dirname, '../shared/src/types/collections')
dotenv.config({
path: path.resolve(__dirname, '../server/env/.env.local')
})
if (!process.env.PB_HOST || !process.env.PB_EMAIL || !process.env.PB_PASSWORD) {
console.error(
'Please provide PB_HOST, PB_EMAIL, and PB_PASSWORD in your environment variables.'
)
process.exit(1)
}
const pb = new Pocketbase(process.env.PB_HOST)
try {
await pb
.collection('_superusers')
.authWithPassword(process.env.PB_EMAIL, process.env.PB_PASSWORD)
if (!pb.authStore.isSuperuser || !pb.authStore.isValid) {
console.error('Invalid credentials.')
process.exit(1)
}
} catch {
console.error('Server is not reachable or credentials are invalid.')
process.exit(1)
}
const allModules = [
...fs.readdirSync('./server/src/apps', { withFileTypes: true }),
...fs.readdirSync('./server/src/core/lib', { withFileTypes: true })
]
const modulesMap: Record<string, CollectionModel[]> = {}
const allCollections = await pb.collections.getFullList()
const collections = allCollections.filter(e => !e.system)
for (const collection of collections) {
const module = allModules.find(e =>
collection.name.startsWith(_.snakeCase(e.name))
)
if (!module) {
console.log(
chalk.yellow('[WARNING]') +
` Collection ${collection.name} does not have a corresponding module.`
)
continue
}
if (!modulesMap[module.name]) {
modulesMap[module.name] = []
}
modulesMap[module.name]?.push(collection)
}
console.log(
chalk.green('[INFO]') +
` Found ${Object.values(modulesMap).flat().length} collections across ${Object.keys(modulesMap).length} modules.`
)
for (const module of allModules) {
if (!modulesMap[module.name]) {
continue
}
const moduleName = _.camelCase(module.name)
const collections = modulesMap[module.name]
let finalString = ``
for (const collection of collections ?? []) {
console.log(
chalk.blue('[INFO]') +
` Found ${collection.fields.length} fields in collection ${chalk.bold(
collection.name
)} in module ${chalk.bold(moduleName)}.`
)
const zodSchemaObject: Record<string, string> = {}
for (const field of collection.fields) {
if (['id', 'created', 'updated'].includes(field.name)) {
// Skip fields that are auto-generated by PocketBase
continue
}
switch (field.type) {
case 'text':
zodSchemaObject[field.name] = 'z.string()'
break
case 'richtext':
zodSchemaObject[field.name] = 'z.string()'
break
case 'number':
zodSchemaObject[field.name] = 'z.number()'
break
case 'bool':
zodSchemaObject[field.name] = 'z.boolean()'
break
case 'email':
zodSchemaObject[field.name] = 'z.email()'
break
case 'url':
zodSchemaObject[field.name] = 'z.url()'
break
case 'date':
zodSchemaObject[field.name] = 'z.string()'
break
case 'autodate':
zodSchemaObject[field.name] = 'z.string()'
break
case 'select':
const value = [...field.values, ...(field.required ? [] : [''])]
zodSchemaObject[field.name] =
field.maxSelect > 1
? `z.array(z.enum(${JSON.stringify(value)}))`
: `z.enum(${JSON.stringify(value)})`
break
case 'file':
zodSchemaObject[field.name] =
field.maxSelect > 1 ? 'z.array(z.string())' : 'z.string()'
break
case 'relation':
zodSchemaObject[field.name] =
field.maxSelect > 1 ? `z.array(z.string())` : `z.string()`
break
case 'json':
zodSchemaObject[field.name] = 'z.any()'
break
case 'geoPoint':
zodSchemaObject[field.name] =
'z.object({ lat: z.number(), lon: z.number() })'
break
case 'password':
zodSchemaObject[field.name] = 'z.string()'
break
default:
console.warn(
chalk.yellow('[WARNING]') +
` Unknown field type ${field.type} for field ${field.name} in collection ${collection.name}.`
)
continue
}
}
if (collection.name.endsWith('_aggregated')) {
collection.name =
singular(collection.name.replace(/_aggregated$/, '')) + '_aggregated'
} else {
collection.name = singular(collection.name)
}
collection.name = `${collection.name.split('__').pop() ?? collection.name}`
const zodSchemaString = `const ${_.upperFirst(
_.camelCase(collection.name)
)} = z.object({\n${Object.entries(zodSchemaObject)
.map(([key, value]) => ` ${key}: ${value},`)
.join('\n')}\n});`
finalString += `${zodSchemaString}\n\n`
console.log(
chalk.green('[INFO]') +
` Generated Zod schema for collection ${chalk.bold(
collection.name
)} in module ${chalk.bold(moduleName)}.`
)
}
if (!collections) {
console.warn(
chalk.yellow('[WARNING]') +
` No collections found for module ${chalk.bold(moduleName)}.`
)
continue
}
finalString += `${collections
.map(
e =>
`type I${_.upperFirst(_.camelCase(e.name))} = z.infer<typeof ${_.upperFirst(
_.camelCase(singular(e.name))
)}>;`
)
.join('\n')}\n\nexport {\n${collections
.map(e => ` ${_.upperFirst(_.camelCase(e.name))},`)
.join('\n')}\n};\n\nexport type {\n${collections
.map(e => ` I${_.upperFirst(_.camelCase(e.name))},`)
.join('\n')}\n};\n`
const outputPath = path.resolve(TARGET_PATH, `${moduleName}.schema.ts`)
const originalContent = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, 'utf-8')
: ''
if (originalContent.includes(CUSTOM_SCHEMAS_DELIMITER)) {
const CustomSchemas = originalContent.split(CUSTOM_SCHEMAS_DELIMITER).pop()!
finalString += `\n${CUSTOM_SCHEMAS_DELIMITER}\n\n${CustomSchemas.replace(
/\/\/\s*$/,
''
)
.replace(/^\n+/, '')
.replace(/\n+$/, '')}\n`
} else {
finalString += `\n${CUSTOM_SCHEMAS_DELIMITER}\n\n// Add your custom schemas here. They will not be overwritten by this script.\n`
}
finalString =
`/**
* This file is auto-generated. DO NOT EDIT IT MANUALLY.
* You may regenerate it by running \`bun run schema:generate:collection\` in the root directory.
* If you want to add custom schemas, you will find a dedicated space at the end of this file.
* Generated for module: ${moduleName}
* Generated at: ${new Date().toISOString()}
* Contains: ${collections?.map(e => e.name).join(', ')}
*/
import { z } from "zod/v4";
${finalString.includes('SchemaWithPB') ? 'import { SchemaWithPB } from "./schemaWithPB";\n' : ''}
` + finalString
toBeWritten[`${moduleName}.schema.ts`] = finalString
}
if (fs.existsSync(TARGET_PATH) && fs.lstatSync(TARGET_PATH).isDirectory()) {
const files = fs.readdirSync(TARGET_PATH)
for (const file of files) {
if (file.endsWith('.custom.schema.ts')) {
toBeWritten[file] = fs.readFileSync(path.join(TARGET_PATH, file), 'utf-8')
}
}
fs.rmdirSync(TARGET_PATH, { recursive: true })
await new Promise(resolve => setTimeout(resolve, 1000))
}
fs.mkdirSync(TARGET_PATH, { recursive: true })
const indexString = `
/**
* This file is auto-generated. DO NOT EDIT IT MANUALLY.
* You may regenerate it by running \`npm run generate:schema:collection\`.
* This is the entry point for all schemas in the shared library.
* Generated at: ${new Date().toISOString()}
* Contains schemas for all modules.
*/
${Object.keys(toBeWritten)
.map(
moduleName =>
`export * as ${_.upperFirst(_.camelCase(moduleName.replace(/(?:\.custom)?\.schema\.ts$/, moduleName.endsWith('.custom.schema.ts') ? 'Custom' : 'Collections')))}Schemas from './${moduleName.replace(/\.ts$/, '')}';`
)
.join('\n')}
export { SchemaWithPB } from './schemaWithPB'
export type { ISchemaWithPB } from './schemaWithPB'
`
toBeWritten['index.ts'] = indexString
toBeWritten['schemaWithPB.ts'] = `
import { z } from 'zod/v4'
const BasePBSchema = z.object({
id: z.string(),
collectionId: z.string(),
collectionName: z.string(),
created: z.string(),
updated: z.string()
})
export const SchemaWithPB = <T extends z.ZodTypeAny>(schema: T) => {
return z.intersection(schema, BasePBSchema)
}
export type ISchemaWithPB<T> = T & z.infer<typeof BasePBSchema>
`
for (const [fileName, content] of Object.entries(toBeWritten)) {
const filePath = path.resolve(TARGET_PATH, fileName)
fs.writeFileSync(filePath, content, 'utf-8')
}