UNPKG

@proofkit/better-auth

Version:

FileMaker adapter for Better Auth

265 lines (242 loc) 8.35 kB
import type { Database, Field, Metadata } from "@proofkit/fmodata"; import { isFMODataError, isODataError } from "@proofkit/fmodata"; import type { DBFieldAttribute } from "better-auth/db"; import chalk from "chalk"; /** Schema type returned by better-auth's getSchema function */ type BetterAuthSchema = Record<string, { fields: Record<string, DBFieldAttribute>; order: number }>; function normalizeBetterAuthFieldType(fieldType: unknown): string { if (typeof fieldType === "string") { return fieldType; } if (Array.isArray(fieldType)) { return fieldType.map(String).join("|"); } return String(fieldType); } export async function getMetadata(db: Database): Promise<Metadata> { const metadata = await db.getMetadata({ format: "json" }); return metadata; } /** Map a better-auth field type string to an fmodata Field type */ function mapFieldType(t: string): "string" | "numeric" | "timestamp" { if (t.includes("boolean") || t.includes("number")) { return "numeric"; } if (t.includes("date")) { return "timestamp"; } return "string"; } export async function planMigration(db: Database, betterAuthSchema: BetterAuthSchema): Promise<MigrationPlan> { const metadata = await getMetadata(db); // Build a map from entity set name to entity type key const entitySetToType: Record<string, string> = {}; for (const [key, value] of Object.entries(metadata)) { if (value.$Kind === "EntitySet" && value.$Type) { // $Type is like 'betterauth_test.fmp12.proofkit_user_' const typeKey = value.$Type.split(".").pop(); // e.g., 'proofkit_user_' entitySetToType[key] = typeKey || key; } } const existingTables = Object.entries(entitySetToType).reduce( (acc, [entitySetName, entityTypeKey]) => { const entityType = metadata[entityTypeKey]; if (!entityType) { return acc; } const fields = Object.entries(entityType) .filter( ([_fieldKey, fieldValue]) => typeof fieldValue === "object" && fieldValue !== null && "$Type" in fieldValue, ) .map(([fieldKey, fieldValue]) => { let type = "string"; if (fieldValue.$Type === "Edm.String") { type = "string"; } else if (fieldValue.$Type === "Edm.DateTimeOffset") { type = "timestamp"; } else if ( fieldValue.$Type === "Edm.Decimal" || fieldValue.$Type === "Edm.Int32" || fieldValue.$Type === "Edm.Int64" ) { type = "numeric"; } return { name: fieldKey, type, }; }); acc[entitySetName] = fields; return acc; }, {} as Record<string, { name: string; type: string }[]>, ); const baTables = Object.entries(betterAuthSchema) .sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0)) .map(([key, value]) => ({ ...value, modelName: key, })); const migrationPlan: MigrationPlan = []; for (const baTable of baTables) { const fields: FmField[] = Object.entries(baTable.fields).map(([key, field]) => { const t = normalizeBetterAuthFieldType(field.type); const type = mapFieldType(t); return { name: field.fieldName ?? key, type, }; }); const tableExists = baTable.modelName in existingTables; if (tableExists) { const existingFields = (existingTables[baTable.modelName] || []).map((f) => f.name); const existingFieldMap = (existingTables[baTable.modelName] || []).reduce( (acc, f) => { acc[f.name] = f.type; return acc; }, {} as Record<string, string>, ); for (const field of fields) { if (existingFields.includes(field.name) && existingFieldMap[field.name] !== field.type) { console.warn( `⚠️ WARNING: Field '${field.name}' in table '${baTable.modelName}' exists but has type '${existingFieldMap[field.name]}' (expected '${field.type}'). Change the field type in FileMaker to avoid potential errors.`, ); } } const fieldsToAdd = fields.filter((f) => !existingFields.includes(f.name)); if (fieldsToAdd.length > 0) { migrationPlan.push({ tableName: baTable.modelName, operation: "update", fields: fieldsToAdd, }); } } else { migrationPlan.push({ tableName: baTable.modelName, operation: "create", fields: [ { name: "id", type: "string", primary: true, unique: true, }, ...fields, ], }); } } return migrationPlan; } export async function executeMigration(db: Database, migrationPlan: MigrationPlan) { for (const step of migrationPlan) { // Convert plan fields to fmodata Field type const fmodataFields: Field[] = step.fields.map((f) => ({ name: f.name, type: f.type, ...(f.primary ? { primary: true } : {}), ...(f.unique ? { unique: true } : {}), })); if (step.operation === "create") { console.log("Creating table:", step.tableName); try { await db.schema.createTable(step.tableName, fmodataFields); } catch (error) { throw migrationError("create", step.tableName, error); } } else if (step.operation === "update") { console.log("Adding fields to table:", step.tableName); try { await db.schema.addFields(step.tableName, fmodataFields); } catch (error) { throw migrationError("update", step.tableName, error); } } } } interface FmField { name: string; type: "string" | "numeric" | "timestamp"; primary?: boolean; unique?: boolean; } const migrationStepTypes = ["create", "update"] as const; interface MigrationStep { tableName: string; operation: (typeof migrationStepTypes)[number]; fields: FmField[]; } export type MigrationPlan = MigrationStep[]; function formatError(error: unknown): string { if (isODataError(error)) { const code = error.code ? ` (${error.code})` : ""; return `${error.message}${code}`; } if (isFMODataError(error)) { return error.message; } if (error instanceof Error) { return error.message; } return String(error); } function migrationError(operation: string, tableName: string, error: unknown): Error { const action = operation === "create" ? "create table" : "update table"; const base = `Failed to ${action} "${tableName}"`; if (isODataError(error) && error.code === "207") { console.error( chalk.red(`\n${base}: Cannot modify schema.`), chalk.yellow("\nThe account used does not have schema modification privileges."), chalk.gray( "\nUse --username and --password to provide Full Access credentials, or grant schema modification privileges to the current account.", ), ); } else { console.error(chalk.red(`\n${base}:`), formatError(error)); } return new Error(`Migration failed: ${formatError(error)}`); } export function prettyPrintMigrationPlan( migrationPlan: MigrationPlan, target?: { serverUrl?: string; fileName?: string }, ) { if (!migrationPlan.length) { console.log("No changes to apply. Database is up to date."); return; } console.log(chalk.bold.green("Migration plan:")); if (target?.serverUrl || target?.fileName) { const parts: string[] = []; if (target.fileName) { parts.push(chalk.cyan(target.fileName)); } if (target.serverUrl) { parts.push(chalk.gray(target.serverUrl)); } console.log(` Target: ${parts.join(" @ ")}`); } for (const step of migrationPlan) { const emoji = step.operation === "create" ? "✅" : "✏️"; console.log( `\n${emoji} ${step.operation === "create" ? chalk.bold.green("Create table") : chalk.bold.yellow("Update table")}: ${step.tableName}`, ); if (step.fields.length) { for (const field of step.fields) { let fieldDesc = ` - ${field.name} (${field.type}`; if (field.primary) { fieldDesc += ", primary"; } if (field.unique) { fieldDesc += ", unique"; } fieldDesc += ")"; console.log(fieldDesc); } } else { console.log(" (No fields to add)"); } } console.log(""); }