UNPKG

@convex-dev/better-auth

Version:
364 lines (355 loc) 12.1 kB
import { createAdapterFactory } from "better-auth/adapters"; import type { DBAdapterDebugLogOption } from "better-auth/adapters"; import { createFunctionHandle } from "convex/server"; import type { FunctionHandle, GenericActionCtx, GenericDataModel, PaginationOptions, PaginationResult, SchemaDefinition, WithoutSystemFields, } from "convex/server"; import type { SetOptional } from "type-fest"; import type defaultSchema from "../component/schema.js"; import type { Where } from "better-auth/types"; import { asyncMap } from "convex-helpers"; import { prop, sortBy, unique } from "remeda"; import { isRunMutationCtx } from "../utils/index.js"; import type { Doc, TableNames } from "../component/_generated/dataModel.js"; import type { ComponentApi } from "../component/_generated/component.js"; import type { AuthFunctions, GenericCtx, Triggers } from "./index.js"; const handlePagination = async ( next: ({ paginationOpts, }: { paginationOpts: PaginationOptions; }) => Promise< SetOptional<PaginationResult<any>, "page"> & { count?: number } >, { limit, numItems }: { limit?: number; numItems?: number } = {} ) => { const state: { isDone: boolean; cursor: string | null; docs: any[]; count: number; } = { isDone: false, cursor: null, docs: [], count: 0, }; const onResult = ( result: SetOptional<PaginationResult<any>, "page"> & { count?: number } ) => { state.cursor = result.pageStatus === "SplitRecommended" || result.pageStatus === "SplitRequired" ? (result.splitCursor ?? result.continueCursor) : result.continueCursor; if (result.page) { state.docs.push(...result.page); state.isDone = (limit && state.docs.length >= limit) || result.isDone; return; } // Update and delete only return a count if (result.count) { state.count += result.count; state.isDone = (limit && state.count >= limit) || result.isDone; return; } state.isDone = result.isDone; }; do { const result = await next({ paginationOpts: { numItems: Math.min( numItems ?? 200, (limit ?? 200) - state.docs.length, 200 ), cursor: state.cursor, }, }); onResult(result); } while (!state.isDone); return state; }; type ConvexCleanedWhere<TableName extends TableNames = TableNames> = Where & { value: string | number | boolean | string[] | number[] | null; field: keyof WithoutSystemFields<Doc<TableName>> & string; }; const parseWhere = ( where?: (Where & { join?: undefined }) | (Where & { join?: undefined })[] ): ConvexCleanedWhere[] => { if (!where) { return []; } const whereArray = Array.isArray(where) ? where : [where]; return whereArray.map((w) => { if (w.value instanceof Date) { return { ...w, value: w.value.getTime(), }; } return w; }) as ConvexCleanedWhere[]; }; export const convexAdapter = < DataModel extends GenericDataModel, Ctx extends GenericCtx<DataModel> = GenericActionCtx<DataModel>, Schema extends SchemaDefinition<any, any> = typeof defaultSchema, >( ctx: Ctx, api: { adapter: ComponentApi["adapter"]; adapterTest?: ComponentApi["adapterTest"]; }, config: { debugLogs?: DBAdapterDebugLogOption; authFunctions?: AuthFunctions; triggers?: Triggers<DataModel, Schema>; } = {} ) => { return createAdapterFactory({ config: { adapterId: "convex", adapterName: "Convex Adapter", debugLogs: config.debugLogs || false, disableIdGeneration: true, transaction: false, supportsNumericIds: false, supportsJSON: false, supportsDates: false, supportsArrays: true, usePlural: false, mapKeysTransformInput: { id: "_id", }, mapKeysTransformOutput: { _id: "id", }, // Convert dates to numbers. This aligns with how // Convex stores _creationTime and avoids a breaking change. customTransformInput: ({ data, fieldAttributes }) => { if (data && fieldAttributes.type === "date") { return new Date(data).getTime(); } return data; }, customTransformOutput: ({ data, fieldAttributes }) => { if (data && fieldAttributes.type === "date") { return new Date(data).getTime(); } return data; }, }, adapter: ({ options }) => { // Disable telemetry in all cases because it requires Node options.telemetry = { enabled: false }; return { id: "convex", options: { isRunMutationCtx: isRunMutationCtx(ctx), }, createSchema: async ({ file, tables }) => { const { createSchema } = await import("./create-schema.js"); return createSchema({ file, tables }); }, create: async ({ model, data, select }): Promise<any> => { if (!("runMutation" in ctx)) { throw new Error("ctx is not a mutation ctx"); } const onCreateHandle = config.authFunctions?.onCreate && config.triggers?.[model]?.onCreate ? ((await createFunctionHandle( config.authFunctions.onCreate )) as FunctionHandle<"mutation">) : undefined; return ctx.runMutation(api.adapter.create, { input: { model: model as any, data }, select, onCreateHandle: onCreateHandle, }); }, findOne: async (data): Promise<any> => { if (data.where?.every((w) => w.connector === "OR")) { for (const w of data.where) { const result = await ctx.runQuery(api.adapter.findOne, { ...data, model: data.model as TableNames, where: parseWhere(w), }); if (result) { return result; } } } return await ctx.runQuery(api.adapter.findOne, { ...data, model: data.model as TableNames, where: parseWhere(data.where), }); }, findMany: async (data): Promise<any[]> => { if (data.offset) { throw new Error("offset not supported"); } if (data.where?.some((w) => w.connector === "OR")) { const results = await asyncMap(data.where, async (w) => handlePagination( async ({ paginationOpts }) => { return await ctx.runQuery(api.adapter.findMany, { ...data, model: data.model as TableNames, where: parseWhere(w), paginationOpts, }); }, { limit: data.limit } ) ); const docs = unique(results.flatMap((r) => r.docs)); if (data.sortBy) { return sortBy(docs, [ prop(data.sortBy.field), data.sortBy.direction, ]); } return docs; } const result = await handlePagination( async ({ paginationOpts }) => { return await ctx.runQuery(api.adapter.findMany, { ...data, model: data.model as TableNames, where: parseWhere(data.where), paginationOpts, }); }, { limit: data.limit } ); return result.docs; }, count: async (data) => { // Yes, count is just findMany returning a number. if (data.where?.some((w) => w.connector === "OR")) { const results = await asyncMap(data.where, async (w) => handlePagination(async ({ paginationOpts }) => { return await ctx.runQuery(api.adapter.findMany, { ...data, model: data.model as TableNames, where: parseWhere(w), paginationOpts, }); }) ); const docs = unique(results.flatMap((r) => r.docs)); return docs.length; } const result = await handlePagination(async ({ paginationOpts }) => { return await ctx.runQuery(api.adapter.findMany, { ...data, model: data.model as TableNames, where: parseWhere(data.where), paginationOpts, }); }); return result.docs.length; }, update: async (data): Promise<any> => { if (!("runMutation" in ctx)) { throw new Error("ctx is not a mutation ctx"); } if (data.where?.length === 1 && data.where[0].operator === "eq") { const onUpdateHandle = config.authFunctions?.onUpdate && config.triggers?.[data.model]?.onUpdate ? ((await createFunctionHandle( config.authFunctions.onUpdate )) as FunctionHandle<"mutation">) : undefined; return ctx.runMutation(api.adapter.updateOne, { input: { model: data.model as TableNames, where: parseWhere(data.where), update: data.update as any, }, onUpdateHandle: onUpdateHandle, }); } throw new Error("where clause not supported"); }, delete: async (data) => { if (!("runMutation" in ctx)) { throw new Error("ctx is not a mutation ctx"); } const onDeleteHandle = config.authFunctions?.onDelete && config.triggers?.[data.model]?.onDelete ? ((await createFunctionHandle( config.authFunctions.onDelete )) as FunctionHandle<"mutation">) : undefined; await ctx.runMutation(api.adapter.deleteOne, { input: { model: data.model as TableNames, where: parseWhere(data.where), }, onDeleteHandle: onDeleteHandle, }); }, deleteMany: async (data) => { if (!("runMutation" in ctx)) { throw new Error("ctx is not a mutation ctx"); } const onDeleteHandle = config.authFunctions?.onDelete && config.triggers?.[data.model as TableNames]?.onDelete ? ((await createFunctionHandle( config.authFunctions.onDelete )) as FunctionHandle<"mutation">) : undefined; const result = await handlePagination(async ({ paginationOpts }) => { return await ctx.runMutation(api.adapter.deleteMany, { input: { ...data, model: data.model as TableNames, where: parseWhere(data.where), }, paginationOpts, onDeleteHandle: onDeleteHandle, }); }); return result.count; }, updateMany: async (data) => { if (!("runMutation" in ctx)) { throw new Error("ctx is not a mutation ctx"); } const onUpdateHandle = config.authFunctions?.onUpdate && config.triggers?.[data.model]?.onUpdate ? ((await createFunctionHandle( config.authFunctions.onUpdate )) as FunctionHandle<"mutation">) : undefined; const result = await handlePagination(async ({ paginationOpts }) => { return await ctx.runMutation(api.adapter.updateMany, { input: { ...data, model: data.model as TableNames, where: parseWhere(data.where), }, paginationOpts, onUpdateHandle: onUpdateHandle, }); }); return result.count; }, }; }, }); };