Files
lifeforge/scripts/generateCollectionsSchemas.ts
Melvin Chia 90266a564a 25w29
Former-commit-id: 94a4e04a6689a10743cebd3d7484806b8c295df6 [formerly f95ca1c6e302fa35ee64913e8208849586757d2d] [formerly 274bc78161b8dd8e200d2bc34690ecc8ba7b276d [formerly f36b9709a336fad9e278ec988fbd34b66b8c3a91]]
Former-commit-id: 75a321f570ab5c146a21f27c9b5a3e8aa21a1c40 [formerly e35d658f3fac7f0690cf7a4051d3124aac36f832]
Former-commit-id: 8094fe6dfd24022fe75927c9bd63bc9a5518bf19
2025-07-19 12:28:25 +08:00

293 lines
8.7 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 = `/**
* 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";
`
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 customCollectionsSchemas = originalContent
.split(CUSTOM_SCHEMAS_DELIMITER)
.pop()!
finalString += `\n${CUSTOM_SCHEMAS_DELIMITER}\n\n${customCollectionsSchemas
.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`
}
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')))}CollectionsSchemas 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')
}