Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/custom-mutation-builder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'better-auth-convex': patch
---

Add support for custom mutation builders in `createClient` and `createApi`. Both functions now accept an optional `internalMutation` parameter, allowing you to wrap internal mutations with custom context (e.g., triggers, aggregates, middleware).

**Usage:**

```ts
const internalMutation = customMutation(
internalMutationGeneric,
customCtx(async (ctx) => ({
db: triggers.wrapDB(ctx).db,
}))
);

// Pass to createClient
createClient({
authFunctions,
schema,
internalMutation,
triggers,
});

// Pass to createApi
createApi(schema, {
...auth.options,
internalMutation,
});
```
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ export const {
updateMany,
updateOne,
} = createApi(schema, auth.options);

// Optional: If you need custom mutation builders (e.g., for custom context)
// Pass internalMutation to both createClient and createApi
// export const authClient = createClient<DataModel, typeof schema>({
// authFunctions,
// schema,
// internalMutation: myCustomInternalMutation,
// triggers: { ... }
// });
//
// export const { create, ... } = createApi(schema, {
// ...auth.options,
// internalMutation: myCustomInternalMutation,
// });
```

The trigger API exposes both `before*` and `on*` hooks. The `before` variants run inside the same Convex transaction just ahead of the database write, letting you normalize input, enforce invariants, or perform cleanup and return any transformed payload that should be persisted.
Expand Down Expand Up @@ -240,6 +254,51 @@ const session = await getSession(ctx);
const headers = await getHeaders(ctx);
```

## Custom Mutation Builders

Both `createClient` and `createApi` accept an optional `internalMutation` parameter, allowing you to wrap internal mutations with custom context or behavior.

### Use Cases

This is useful when you need to:
- Wrap database operations with custom context (e.g., triggers, logging)
- Apply middleware to all auth mutations
- Inject dependencies or configuration

### Example with Triggers

```ts
import { customMutation, customCtx } from 'convex-helpers/server/customFunctions';
import { internalMutationGeneric } from 'convex/server';
import { registerTriggers } from '@convex/triggers';

const triggers = registerTriggers();

// Wrap mutations to include trigger-wrapped database
const internalMutation = customMutation(
internalMutationGeneric,
customCtx(async (ctx) => ({
db: triggers.wrapDB(ctx).db,
}))
);

// Pass to createClient
export const authClient = createClient<DataModel, typeof schema>({
authFunctions,
schema,
internalMutation, // Use custom mutation builder
triggers: { ... }
});

// Pass to createApi
export const { create, updateOne, ... } = createApi(schema, {
...auth.options,
internalMutation, // Use same custom mutation builder
});
```

This ensures all auth operations (CRUD + triggers) use your wrapped database context.

## Updating the Schema

Better Auth configuration changes may require schema updates. The Better Auth docs will often note when this is the case. To regenerate the schema (it's generally safe to do), run:
Expand Down
18 changes: 12 additions & 6 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,12 +395,18 @@ export const deleteManyHandler = async (

export const createApi = <Schema extends SchemaDefinition<any, any>>(
schema: Schema,
authOptions: BetterAuthOptions
{
internalMutation,
...authOptions
}: BetterAuthOptions & {
internalMutation?: typeof internalMutationGeneric;
}
) => {
const betterAuthSchema = getAuthTables(authOptions);
const mutationBuilder = internalMutation ?? internalMutationGeneric;

return {
create: internalMutationGeneric({
create: mutationBuilder({
args: {
beforeCreateHandle: v.optional(v.string()),
input: v.union(
Expand All @@ -419,7 +425,7 @@ export const createApi = <Schema extends SchemaDefinition<any, any>>(
handler: async (ctx, args) =>
createHandler(ctx, args, schema, betterAuthSchema),
}),
deleteMany: internalMutationGeneric({
deleteMany: mutationBuilder({
args: {
beforeDeleteHandle: v.optional(v.string()),
input: v.union(
Expand All @@ -440,7 +446,7 @@ export const createApi = <Schema extends SchemaDefinition<any, any>>(
handler: async (ctx, args) =>
deleteManyHandler(ctx, args, schema, betterAuthSchema),
}),
deleteOne: internalMutationGeneric({
deleteOne: mutationBuilder({
args: {
beforeDeleteHandle: v.optional(v.string()),
input: v.union(
Expand Down Expand Up @@ -490,7 +496,7 @@ export const createApi = <Schema extends SchemaDefinition<any, any>>(
handler: async (ctx, args) =>
findOneHandler(ctx, args, schema, betterAuthSchema),
}),
updateMany: internalMutationGeneric({
updateMany: mutationBuilder({
args: {
beforeUpdateHandle: v.optional(v.string()),
input: v.union(
Expand All @@ -512,7 +518,7 @@ export const createApi = <Schema extends SchemaDefinition<any, any>>(
handler: async (ctx, args) =>
updateManyHandler(ctx, args, schema, betterAuthSchema),
}),
updateOne: internalMutationGeneric({
updateOne: mutationBuilder({
args: {
beforeUpdateHandle: v.optional(v.string()),
input: v.union(
Expand Down
160 changes: 83 additions & 77 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const createClient = <
>(config: {
authFunctions: AuthFunctions;
schema: Schema;
internalMutation?: typeof internalMutationGeneric;
triggers?: Triggers<DataModel, Schema>;
}) => {
return {
Expand All @@ -87,83 +88,88 @@ export const createClient = <
adapter: (ctx: GenericCtx<DataModel>, options: BetterAuthOptions) =>
dbAdapter(ctx, options, config),
httpAdapter: (ctx: GenericCtx<DataModel>) => httpAdapter(ctx, config),
triggersApi: () => ({
beforeCreate: internalMutationGeneric({
args: {
data: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
return (
(await config?.triggers?.[args.model]?.beforeCreate?.(
ctx,
args.data
)) ?? args.data
);
},
}),
beforeDelete: internalMutationGeneric({
args: {
doc: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
return (
(await config?.triggers?.[args.model]?.beforeDelete?.(
ctx,
args.doc
)) ?? args.doc
);
},
}),
beforeUpdate: internalMutationGeneric({
args: {
doc: v.any(),
model: v.string(),
update: v.any(),
},
handler: async (ctx, args) => {
return (
(await config?.triggers?.[args.model]?.beforeUpdate?.(
triggersApi: () => {
const mutationBuilder =
config.internalMutation ?? internalMutationGeneric;

return {
beforeCreate: mutationBuilder({
args: {
data: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
return (
(await config?.triggers?.[args.model]?.beforeCreate?.(
ctx,
args.data
)) ?? args.data
);
},
}),
beforeDelete: mutationBuilder({
args: {
doc: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
return (
(await config?.triggers?.[args.model]?.beforeDelete?.(
ctx,
args.doc
)) ?? args.doc
);
},
}),
beforeUpdate: mutationBuilder({
args: {
doc: v.any(),
model: v.string(),
update: v.any(),
},
handler: async (ctx, args) => {
return (
(await config?.triggers?.[args.model]?.beforeUpdate?.(
ctx,
args.doc,
args.update
)) ?? args.update
);
},
}),
onCreate: mutationBuilder({
args: {
doc: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
await config?.triggers?.[args.model]?.onCreate?.(ctx, args.doc);
},
}),
onDelete: mutationBuilder({
args: {
doc: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
await config?.triggers?.[args.model]?.onDelete?.(ctx, args.doc);
},
}),
onUpdate: mutationBuilder({
args: {
model: v.string(),
newDoc: v.any(),
oldDoc: v.any(),
},
handler: async (ctx, args) => {
await config?.triggers?.[args.model]?.onUpdate?.(
ctx,
args.doc,
args.update
)) ?? args.update
);
},
}),
onCreate: internalMutationGeneric({
args: {
doc: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
await config?.triggers?.[args.model]?.onCreate?.(ctx, args.doc);
},
}),
onDelete: internalMutationGeneric({
args: {
doc: v.any(),
model: v.string(),
},
handler: async (ctx, args) => {
await config?.triggers?.[args.model]?.onDelete?.(ctx, args.doc);
},
}),
onUpdate: internalMutationGeneric({
args: {
model: v.string(),
newDoc: v.any(),
oldDoc: v.any(),
},
handler: async (ctx, args) => {
await config?.triggers?.[args.model]?.onUpdate?.(
ctx,
args.newDoc,
args.oldDoc
);
},
}),
}),
args.newDoc,
args.oldDoc
);
},
}),
};
},
};
};