@payload-auth/better-auth-db-adapter
Version:
Payload CMS database adapter for BetterAuth
323 lines (320 loc) • 12.6 kB
JavaScript
/**
* This module generates PayloadCMS collection configurations based on the Better Auth schema.
*
* The generated collection configs include:
* - Users: For user management with proper auth configuration
* - Sessions: For maintaining user sessions
* - Accounts: For OAuth provider accounts
* - VerificationTokens: For email verification or social sign in
*
* Each collection is properly formatted with:
* - Appropriate field types (text, relationship, checkbox, etc.)
* - Admin configuration for better UI experience
* - Auth configuration for the Users collection
* - Proper field relationships between collections
*
* Users can copy these generated collection configs to their PayloadCMS project
* and add authentication strategies as needed.
*/ import { getAuthTables } from "better-auth/db";
import { format } from "prettier";
export const generateSchemaBuilderStage = async ({ BAOptions, code })=>{
const formattedCode = await format(code, {
filepath: "schema.ts"
});
const { post, collections } = parseExistingSchema(formattedCode);
const payloadSchemaStr = generatePayloadCollectionConfigs({
options: BAOptions,
collections,
exports: post
});
return await format(`${payloadSchemaStr}`, {
filepath: "schema.ts"
});
};
function parseExistingSchema(code) {
const collections = [];
let post = "";
// Extract collection definitions
const collectionRegex = /const\s+([A-Z][a-zA-Z0-9]*)\s*:\s*CollectionConfig\s*=\s*{[\s\S]*?}\s*as\s*const;/g;
let match;
while((match = collectionRegex.exec(code)) !== null){
const collectionName = match[1];
const collectionDef = match[0];
// Extract slug
const slugMatch = collectionDef.match(/slug:\s*"([^"]+)"/);
const slug = slugMatch ? slugMatch[1] : collectionName.toLowerCase();
// Extract useAsTitle
const titleMatch = collectionDef.match(/useAsTitle:\s*"([^"]+)"/);
const useAsTitle = titleMatch ? titleMatch[1] : "id";
// Extract fields
const fieldsRegex = /fields:\s*\[([\s\S]*?)\],/;
const fieldsMatch = collectionDef.match(fieldsRegex);
const fields = [];
if (fieldsMatch) {
const fieldBlockRegex = /{[\s\S]*?},/g;
const fieldBlocks = fieldsMatch[1].match(fieldBlockRegex) || [];
for (const fieldBlock of fieldBlocks){
const nameMatch = fieldBlock.match(/name:\s*"([^"]+)"/);
const typeMatch = fieldBlock.match(/type:\s*"([^"]+)"/);
const requiredMatch = fieldBlock.match(/required:\s*(true|false)/);
const uniqueMatch = fieldBlock.match(/unique:\s*(true|false)/);
const relationToMatch = fieldBlock.match(/relationTo:\s*"([^"]+)"/);
if (nameMatch && typeMatch) {
const field = {
name: nameMatch[1],
type: typeMatch[1]
};
if (requiredMatch) {
field.required = requiredMatch[1] === "true";
}
if (uniqueMatch) {
field.unique = uniqueMatch[1] === "true";
}
if (relationToMatch) {
field.relationTo = relationToMatch[1];
}
fields.push(field);
}
}
}
// Check for timestamps
const timestampsMatch = collectionDef.match(/timestamps:\s*(true|false)/);
const hasTimestamps = timestampsMatch ? timestampsMatch[1] === "true" : false;
collections.push({
slug,
admin: {
useAsTitle
},
fields,
...hasTimestamps && {
timestamps: true
}
});
}
// Extract export statement
const exportRegex = /export\s*{[\s\S]*?};/;
const exportMatch = code.match(exportRegex);
if (exportMatch) {
post = exportMatch[0];
}
return {
collections,
post
};
}
function generatePayloadCollectionConfigs({ options, collections, exports }) {
let result = "";
// add pre message and import statements
result += `/**
* EXAMPLE COLLECTIONS FOR BETTER AUTH
*
* Below is what your Payload collections should look like.
* Please copy these to your actual collection configs.
* Make sure to add an authStrategy for the users collection if there is one.
*
* Example auth strategy:
* auth: {
* disableLocalStrategy: true,
* strategies: [
* betterAuthStrategy(),
* // Add other strategies as needed
* ],
* },
*/
import type { CollectionConfig } from 'payload';
`;
// add the collections
let collectionsStr = "";
const tables = getAuthTables(options);
// Process existing collections first
const existingCollectionsBySlug = collections.reduce((acc, collection)=>{
if (collection.slug) {
acc[collection.slug] = collection;
}
return acc;
}, {});
for (const [tableKey, table] of Object.entries(tables)){
const modelName = table.modelName;
const capitalized = modelName.charAt(0).toUpperCase() + modelName.slice(1);
// Check if this collection already exists in the provided collections
const existingCollection = existingCollectionsBySlug[modelName];
if (existingCollection) {
// Convert existing collection to string representation
collectionsStr += `const ${capitalized}: CollectionConfig = ${JSON.stringify(existingCollection, null, 2).replace(/"([^"]+)":/g, "$1:")} as const;\n\n`;
continue;
}
// Create new collection if it doesn't exist
collectionsStr += `const ${capitalized}: CollectionConfig = {
slug: "${modelName}",
`;
// Add admin section for better UX
collectionsStr += ` admin: {
useAsTitle: "${getNameField(table.fields)}",
},
`;
// Add auth section ONLY for users collection
if (modelName === "users") {
collectionsStr += ` auth: {
disableLocalStrategy: true,
strategies: [],
// Add your auth strategies here if needed
},
`;
}
// Add fields
collectionsStr += ` fields: [
`;
for (const [fieldKey, field] of Object.entries(table.fields)){
const fieldName = field.fieldName || fieldKey;
// Skip id field as Payload handles it automatically
if (fieldName === "id") continue;
if (fieldName === "createdAt" || fieldName === "updatedAt") continue;
// Handle field type mapping with safer type approach
let fieldType = mapFieldType(field.type, fieldName);
const isRelationship = fieldName.endsWith("_id") || !!field.references?.model;
collectionsStr += ` {
name: "${isRelationship ? fieldName.replace("_id", "") : fieldName}",
`;
if (isRelationship) {
collectionsStr += ` type: "relationship",
relationTo: "${field.references?.model || fieldName.replace("_id", "")}",
`;
} else if (fieldType === "upload") {
collectionsStr += ` type: "upload",
relationTo: "media",
`;
} else {
collectionsStr += ` type: "${fieldType}",
`;
}
if (field.required) {
collectionsStr += ` required: true,
`;
}
if (field.unique) {
collectionsStr += ` unique: true,
`;
}
// Check if field has options and fieldType is select
if (fieldType === "select" && "options" in field && Array.isArray(field.options)) {
collectionsStr += ` options: ${JSON.stringify(field.options)},
`;
}
if (field.defaultValue !== undefined) {
if (typeof field.defaultValue === "string") {
collectionsStr += ` defaultValue: "${field.defaultValue}",
`;
} else if (typeof field.defaultValue === "boolean") {
collectionsStr += ` defaultValue: ${field.defaultValue ? "true" : "false"},
`;
} else if (field.defaultValue && typeof field.defaultValue === "function" && field.defaultValue.toString().includes("() => !1")) {
// Handle function-like default values with are false
collectionsStr += ` defaultValue: false,
`;
} else if (field.defaultValue && typeof field.defaultValue === "function" && field.defaultValue.toString().includes("() => !0")) {
// Handle function-like default values with are true
collectionsStr += ` defaultValue: true,
`;
} else {
collectionsStr += ` defaultValue: ${field.defaultValue},
`;
}
}
// Add admin section for fields if needed - safely check for admin property
if ("admin" in field && field.admin && typeof field.admin === "object" && "readOnly" in field.admin && field.admin.readOnly) {
collectionsStr += ` admin: {
readOnly: true,
},
`;
}
collectionsStr += ` },
`;
}
collectionsStr += ` ],
timestamps: true,
} as const;
`;
}
// Add any collections from the input that aren't in the tables
for (const collection of collections){
if (!collection.slug || tables[collection.slug]) {
continue; // Skip if no slug or already processed
}
const capitalized = collection.slug.charAt(0).toUpperCase() + collection.slug.slice(1);
collectionsStr += `const ${capitalized}: CollectionConfig = ${JSON.stringify(collection, null, 2).replace(/"([^"]+)":/g, "$1:")} as const;\n\n`;
}
result += collectionsStr;
// Add export statements for all collections
// Check if we have an existing export statement to adapt
if (exports && exports.trim()) {
// Parse the existing exports to get collection names
const existingExportMatch = exports.match(/export\s*{\s*(.*?)\s*}/);
const existingCollections = existingExportMatch ? existingExportMatch[1].split(",").map((name)=>name.trim()) : [];
// Get the collection names from our tables
const tableCollections = Object.keys(tables).map((model)=>{
return model.charAt(0).toUpperCase() + model.slice(1);
});
// Combine existing and new collections, removing duplicates
const allCollections = [
...new Set([
...existingCollections,
...tableCollections
])
];
// Create the new export statement
result += `export { ${allCollections.join(", ")} };
`;
} else if (Object.keys(tables).length > 0) {
const collectionNames = Object.keys(tables).map((model)=>{
// Convert model name to PascalCase for export
const pascalCase = model.charAt(0).toUpperCase() + model.slice(1);
return pascalCase;
});
result += `export { ${collectionNames.join(", ")} };
`;
} else {
result += `export {};
`;
}
return result;
}
// Helper functions
function mapFieldType(type, fieldName) {
if (fieldName.endsWith("_id")) return "relationship";
switch(type){
case "boolean":
return "checkbox";
case "number":
return "number";
case "string":
if (fieldName === "email") return "email";
if (fieldName.includes("password")) return "text";
if (fieldName.includes("image") || fieldName.includes("avatar")) return "upload";
return "text";
case "date":
return "date";
case "array":
return "array";
default:
return "text";
}
}
function getNameField(fields) {
// Try to find an appropriate field to use as title
const nameOptions = [
"name",
"title",
"label",
"email",
"username"
];
for (const option of nameOptions){
if (Object.keys(fields).includes(option)) {
return option;
}
}
// Default to first field that's not id
const firstNonIdField = Object.keys(fields).find((k)=>k !== "id");
return firstNonIdField || "id";
}
//# sourceMappingURL=generate-schema-builder.js.map