@proofkit/better-auth
Version:
FileMaker adapter for Better Auth
288 lines (260 loc) • 8.52 kB
text/typescript
import { type BetterAuthDbSchema } from "better-auth/db";
import { type Metadata } from "fm-odata-client";
import chalk from "chalk";
import z from "zod/v4";
import { createRawFetch } from "./odata";
export async function getMetadata(
fetch: ReturnType<typeof createRawFetch>["fetch"],
databaseName: string,
) {
console.log("getting metadata...");
const result = await fetch("/$metadata", {
method: "GET",
headers: { accept: "application/json" },
output: z
.looseObject({
$Version: z.string(),
"@ServerVersion": z.string(),
})
.or(z.null())
.catch(null),
});
if (result.error) {
console.error("Failed to get metadata:", result.error);
return null;
}
return (result.data?.[databaseName] ?? null) as Metadata | null;
}
export async function planMigration(
fetch: ReturnType<typeof createRawFetch>["fetch"],
betterAuthSchema: BetterAuthDbSchema,
databaseName: string,
): Promise<MigrationPlan> {
const metadata = await getMetadata(fetch, databaseName);
// Build a map from entity set name to entity type key
let entitySetToType: Record<string, string> = {};
if (metadata) {
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 = metadata
? 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]) => ({
name: fieldKey,
type:
fieldValue.$Type === "Edm.String"
? "varchar"
: fieldValue.$Type === "Edm.DateTimeOffset"
? "timestamp"
: fieldValue.$Type === "Edm.Decimal" ||
fieldValue.$Type === "Edm.Int32" ||
fieldValue.$Type === "Edm.Int64"
? "numeric"
: "varchar",
}));
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,
keyName: key,
}));
const migrationPlan: MigrationPlan = [];
for (const baTable of baTables) {
const fields: FmField[] = Object.entries(baTable.fields).map(
([key, field]) => ({
name: field.fieldName ?? key,
type:
field.type === "boolean" || field.type.includes("number")
? "numeric"
: field.type === "date"
? "timestamp"
: "varchar",
}),
);
// get existing table or create it
const tableExists = Object.prototype.hasOwnProperty.call(
existingTables,
baTable.modelName,
);
if (!tableExists) {
migrationPlan.push({
tableName: baTable.modelName,
operation: "create",
fields: [
{
name: "id",
type: "varchar",
primary: true,
unique: true,
},
...fields,
],
});
} else {
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>,
);
// Warn about type mismatches (optional, not in plan)
fields.forEach((field) => {
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,
});
}
}
}
return migrationPlan;
}
export async function executeMigration(
fetch: ReturnType<typeof createRawFetch>["fetch"],
migrationPlan: MigrationPlan,
) {
for (const step of migrationPlan) {
if (step.operation === "create") {
console.log("Creating table:", step.tableName);
const result = await fetch("/FileMaker_Tables", {
method: "POST",
body: {
tableName: step.tableName,
fields: step.fields,
},
});
if (result.error) {
console.error(
`Failed to create table ${step.tableName}:`,
result.error,
);
throw new Error(`Migration failed: ${result.error}`);
}
} else if (step.operation === "update") {
console.log("Adding fields to table:", step.tableName);
const result = await fetch(`/FileMaker_Tables/${step.tableName}`, {
method: "PATCH",
body: { fields: step.fields },
});
if (result.error) {
console.error(
`Failed to update table ${step.tableName}:`,
result.error,
);
throw new Error(`Migration failed: ${result.error}`);
}
}
}
}
const genericFieldSchema = z.object({
name: z.string(),
nullable: z.boolean().optional(),
primary: z.boolean().optional(),
unique: z.boolean().optional(),
global: z.boolean().optional(),
repetitions: z.number().optional(),
});
const stringFieldSchema = genericFieldSchema.extend({
type: z.literal("varchar"),
maxLength: z.number().optional(),
default: z.enum(["USER", "USERNAME", "CURRENT_USER"]).optional(),
});
const numericFieldSchema = genericFieldSchema.extend({
type: z.literal("numeric"),
});
const dateFieldSchema = genericFieldSchema.extend({
type: z.literal("date"),
default: z.enum(["CURRENT_DATE", "CURDATE"]).optional(),
});
const timeFieldSchema = genericFieldSchema.extend({
type: z.literal("time"),
default: z.enum(["CURRENT_TIME", "CURTIME"]).optional(),
});
const timestampFieldSchema = genericFieldSchema.extend({
type: z.literal("timestamp"),
default: z.enum(["CURRENT_TIMESTAMP", "CURTIMESTAMP"]).optional(),
});
const containerFieldSchema = genericFieldSchema.extend({
type: z.literal("container"),
externalSecurePath: z.string().optional(),
});
const fieldSchema = z.discriminatedUnion("type", [
stringFieldSchema,
numericFieldSchema,
dateFieldSchema,
timeFieldSchema,
timestampFieldSchema,
containerFieldSchema,
]);
type FmField = z.infer<typeof fieldSchema>;
const migrationPlanSchema = z
.object({
tableName: z.string(),
operation: z.enum(["create", "update"]),
fields: z.array(fieldSchema),
})
.array();
export type MigrationPlan = z.infer<typeof migrationPlanSchema>;
export function prettyPrintMigrationPlan(migrationPlan: MigrationPlan) {
if (!migrationPlan.length) {
console.log("No changes to apply. Database is up to date.");
return;
}
console.log(chalk.bold.green("Migration plan:"));
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("");
}