Files
lifeforge/scripts/generateCollectionSchemas.ts
Melvin Chia bf194566d0 25w29
Former-commit-id: 0d53fe99b4446b5be8e5d2014fc6298abb559360 [formerly 6034c63543ef4540da2c245403712d28bfd84562] [formerly c153e0450a9deba2a853795e6d82ec9c372a8283 [formerly bce97093d38f55436b6fdec3685839c680f51e3e]]
Former-commit-id: a3069fefd801580dd0b61f5d79fcd450ef05c85e [formerly 5564c5736093efd12d7703de56d6f2dd65cf1852]
Former-commit-id: a04c0164d4cf19dd38765cca6c5a05ce5d65d872
2025-07-18 21:56:43 +08:00

296 lines
8.9 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");
}