UNPKG

@proofkit/better-auth

Version:

FileMaker adapter for Better Auth

363 lines (323 loc) 11.4 kB
/** 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; };