@proofkit/better-auth
Version:
FileMaker adapter for Better Auth
363 lines (323 loc) • 11.4 kB
text/typescript
/** biome-ignore-all lint/suspicious/noExplicitAny: library code */
import type { Database } from "@proofkit/fmodata";
import { logger } from "better-auth";
import { type CleanedWhere, createAdapterFactory, type DBAdapterDebugLogOption } from "better-auth/adapters";
export interface FileMakerAdapterConfig {
/**
* Helps you debug issues with the adapter.
*/
debugLogs?: DBAdapterDebugLogOption;
/**
* If the table names in the schema are plural.
*/
usePlural?: boolean;
/**
* The fmodata Database instance to use for all OData requests.
*/
database: Database;
}
// Regex patterns for field validation and ISO date detection
const FIELD_SPECIAL_CHARS_REGEX = /[\s_]/;
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
/**
* Parse the where clause to an OData filter string.
* @param where - The where clause to parse.
* @returns The OData filter string.
* @internal
*/
export function parseWhere(where?: CleanedWhere[]): string {
if (!where || where.length === 0) {
return "";
}
// Helper to quote field names with special chars or if field is 'id'
function quoteField(field: string, value?: any) {
// Never quote for null or date values (per test expectations)
if (value === null || value instanceof Date) {
return field;
}
// Always quote if field is 'id' or has space or underscore
if (field === "id" || FIELD_SPECIAL_CHARS_REGEX.test(field)) {
return `"${field}"`;
}
return field;
}
// Helper to format values for OData
function formatValue(value: any): string {
if (value === null) {
return "null";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return `(${value.map(formatValue).join(",")})`;
}
// Handle strings - check if it's an ISO date string first
if (typeof value === "string") {
// Check if it's an ISO date string (YYYY-MM-DDTHH:mm:ss.sssZ format)
if (ISO_DATE_REGEX.test(value)) {
return value; // Return ISO date strings without quotes
}
return `'${value.replace(/'/g, "''")}'`; // Regular strings get quotes
}
return value?.toString() ?? "";
}
// Map our operators to OData
const opMap: Record<string, string> = {
eq: "eq",
ne: "ne",
lt: "lt",
lte: "le",
gt: "gt",
gte: "ge",
};
// Build each clause
const clauses: string[] = [];
for (let i = 0; i < where.length; i++) {
const cond = where[i];
if (!cond) {
continue;
}
const field = quoteField(cond.field, cond.value);
let clause = "";
switch (cond.operator) {
case "eq":
case "ne":
case "lt":
case "lte":
case "gt":
case "gte":
clause = `${field} ${opMap[cond.operator]} ${formatValue(cond.value)}`;
break;
case "in":
if (Array.isArray(cond.value)) {
clause = cond.value.map((v) => `${field} eq ${formatValue(v)}`).join(" or ");
clause = `(${clause})`;
}
break;
case "contains":
clause = `contains(${field}, ${formatValue(cond.value)})`;
break;
case "starts_with":
clause = `startswith(${field}, ${formatValue(cond.value)})`;
break;
case "ends_with":
clause = `endswith(${field}, ${formatValue(cond.value)})`;
break;
default:
clause = `${field} eq ${formatValue(cond.value)}`;
}
clauses.push(clause);
// Add connector if not last
if (i < where.length - 1) {
clauses.push((cond.connector || "and").toLowerCase());
}
}
return clauses.join(" ");
}
/**
* Build an OData query string from parameters.
*/
function buildQueryString(params: {
top?: number;
skip?: number;
filter?: string;
orderBy?: string;
select?: string[];
}): string {
const parts: string[] = [];
if (params.top !== undefined) {
parts.push(`$top=${params.top}`);
}
if (params.skip !== undefined) {
parts.push(`$skip=${params.skip}`);
}
if (params.filter) {
parts.push(`$filter=${encodeURIComponent(params.filter)}`);
}
if (params.orderBy) {
parts.push(`$orderby=${encodeURIComponent(params.orderBy)}`);
}
if (params.select?.length) {
parts.push(`$select=${params.select.map(encodeURIComponent).join(",")}`);
}
return parts.length > 0 ? `?${parts.join("&")}` : "";
}
export const FileMakerAdapter = (config: FileMakerAdapterConfig) => {
if (!config.database || typeof config.database !== "object") {
throw new Error("FileMakerAdapter requires a `database` (fmodata Database instance).");
}
const db = config.database;
const adapterFactory = createAdapterFactory({
config: {
adapterId: "filemaker",
adapterName: "FileMaker",
usePlural: config.usePlural ?? false,
debugLogs: config.debugLogs ?? false,
supportsJSON: false,
supportsDates: false,
supportsBooleans: false,
supportsNumericIds: false,
},
adapter: () => {
return {
create: async ({ data, model }) => {
const result = await db._makeRequest<Record<string, any>>(`/${model}`, {
method: "POST",
body: JSON.stringify(data),
});
if (result.error) {
throw new Error("Failed to create record");
}
return result.data as any;
},
count: async ({ model, where }) => {
const filter = parseWhere(where);
logger.debug("$filter", filter);
const query = buildQueryString({
filter: filter.length > 0 ? filter : undefined,
});
const result = await db._makeRequest<{ value: number }>(`/${model}/$count${query}`);
if (result.error) {
throw new Error("Failed to count records");
}
return (result.data?.value as any) ?? 0;
},
findOne: async ({ model, where }) => {
const filter = parseWhere(where);
logger.debug("$filter", filter);
const query = buildQueryString({
top: 1,
filter: filter.length > 0 ? filter : undefined,
});
const result = await db._makeRequest<{ value: any[] }>(`/${model}${query}`);
if (result.error) {
throw new Error("Failed to find record");
}
return (result.data?.value?.[0] as any) ?? null;
},
findMany: async ({ model, where, limit, offset, sortBy }) => {
const filter = parseWhere(where);
logger.debug("FIND MANY", { where, filter });
const query = buildQueryString({
top: limit,
skip: offset,
orderBy: sortBy ? `${sortBy.field} ${sortBy.direction ?? "asc"}` : undefined,
filter: filter.length > 0 ? filter : undefined,
});
logger.debug("QUERY", query);
const result = await db._makeRequest<{ value: any[] }>(`/${model}${query}`);
logger.debug("RESULT", result);
if (result.error) {
throw new Error("Failed to find records");
}
return (result.data?.value as any) ?? [];
},
delete: async ({ model, where }) => {
const filter = parseWhere(where);
logger.debug("$filter", filter);
// Find a single id matching the filter
const query = buildQueryString({
top: 1,
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});
const toDelete = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
const id = toDelete.data?.value?.[0]?.id;
if (!id) {
return;
}
const result = await db._makeRequest(`/${model}('${id}')`, {
method: "DELETE",
});
if (result.error) {
throw new Error("Failed to delete record");
}
},
deleteMany: async ({ model, where }) => {
const filter = parseWhere(where);
// Find all ids matching the filter
const query = buildQueryString({
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});
const rows = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
let deleted = 0;
for (const id of ids) {
const res = await db._makeRequest(`/${model}('${id}')`, {
method: "DELETE",
});
if (!res.error) {
deleted++;
}
}
return deleted;
},
update: async ({ model, where, update }) => {
const filter = parseWhere(where);
logger.debug("UPDATE", { model, where, update });
logger.debug("$filter", filter);
// Find one id to update
const query = buildQueryString({
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});
const existing = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
logger.debug("EXISTING", existing.data);
const id = existing.data?.value?.[0]?.id;
if (!id) {
return null;
}
const patchRes = await db._makeRequest(`/${model}('${id}')`, {
method: "PATCH",
body: JSON.stringify(update),
});
logger.debug("PATCH RES", patchRes.data);
if (patchRes.error) {
return null;
}
// Read back the updated record
const readBack = await db._makeRequest<Record<string, unknown>>(`/${model}('${id}')`);
logger.debug("READ BACK", readBack.data);
return (readBack.data as any) ?? null;
},
updateMany: async ({ model, where, update }) => {
const filter = parseWhere(where);
// Find all ids matching the filter
const query = buildQueryString({
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});
const rows = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
let updated = 0;
for (const id of ids) {
const res = await db._makeRequest(`/${model}('${id}')`, {
method: "PATCH",
body: JSON.stringify(update),
});
if (!res.error) {
updated++;
}
}
return updated as any;
},
};
},
});
// Expose the Database instance for CLI access.
// Set on both the factory function (for pre-getAdapter extraction)
// and the returned adapter (for post-getAdapter extraction).
const originalFactory = adapterFactory;
const wrappedFactory = ((options: unknown) => {
const adapter = (originalFactory as (opts: unknown) => Record<string, unknown>)(options);
adapter.database = db;
return adapter;
}) as typeof adapterFactory;
(wrappedFactory as unknown as { database: Database }).database = db;
return wrappedFactory;
};