@proofkit/better-auth
Version:
FileMaker adapter for Better Auth
265 lines (242 loc) • 8.35 kB
text/typescript
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("");
}