Skip to content

Commit c28e84e

Browse files
committed
add finally callback
1 parent d1f6b86 commit c28e84e

File tree

3 files changed

+181
-8
lines changed

3 files changed

+181
-8
lines changed

packages/convex-helpers/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ define custom behavior, allowing you to:
4747
- Consume arguments from the client that are not passed to the action, such
4848
as taking in an authentication parameter like an API key or session ID.
4949
These arguments must be sent up by the client along with each request.
50+
- Execute finalization logic after function execution using the `onSuccess`
51+
callback, which has access to the function's result.
5052

5153
See the associated [Stack Post](https://stack.convex.dev/custom-functions)
5254

@@ -60,7 +62,15 @@ const myQueryBuilder = customQuery(query, {
6062
input: async (ctx, args) => {
6163
const apiUser = await getApiUser(args.apiToken);
6264
const db = wrapDatabaseReader({ apiUser }, ctx.db, rlsRules);
63-
return { ctx: { db, apiUser }, args: {} };
65+
return {
66+
ctx: { db, apiUser },
67+
args: {},
68+
onSuccess: (result) => {
69+
// Optional callback that runs after the function executes
70+
// Has access to resources created during input processing
71+
console.log(`Query for ${apiUser.name} completed:`, result);
72+
},
73+
};
6474
},
6575
});
6676

packages/convex-helpers/server/customFunctions.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
describe,
3737
expect,
3838
test,
39+
vi,
3940
} from "vitest";
4041
import { modules } from "./setup.test.js";
4142

@@ -621,3 +622,116 @@ describe("extra args", () => {
621622
});
622623
});
623624
});
625+
626+
describe("finally callback", () => {
627+
test("finally callback is called with result and context", async () => {
628+
const t = convexTest(schema, modules);
629+
const finallyMock = vi.fn();
630+
631+
const withFinally = customQuery(query, {
632+
args: {},
633+
input: async () => ({
634+
ctx: { foo: "bar" },
635+
args: {},
636+
onSuccess: (params) => {
637+
finallyMock(params);
638+
},
639+
}),
640+
});
641+
642+
const successFn = withFinally({
643+
args: {},
644+
handler: async (ctx) => {
645+
return { success: true, foo: ctx.foo };
646+
},
647+
});
648+
649+
await t.run(async (ctx) => {
650+
const result = await (successFn as any)._handler(ctx, {});
651+
expect(result).toEqual({ success: true, foo: "bar" });
652+
653+
expect(finallyMock).toHaveBeenCalledWith({ success: true, foo: "bar" });
654+
});
655+
656+
finallyMock.mockClear();
657+
658+
const errorFn = withFinally({
659+
args: {},
660+
handler: async () => {
661+
throw new Error("Test error");
662+
},
663+
});
664+
665+
await t.run(async (ctx) => {
666+
try {
667+
await (errorFn as any)._handler(ctx, {});
668+
expect.fail("Should have thrown an error");
669+
} catch (e: unknown) {
670+
const error = e as Error;
671+
expect(error.message).toContain("Test error");
672+
}
673+
674+
expect(finallyMock).not.toHaveBeenCalled();
675+
});
676+
});
677+
678+
test("finally callback with mutation", async () => {
679+
const t = convexTest(schema, modules);
680+
const finallyMock = vi.fn();
681+
682+
const withFinally = customMutation(mutation, {
683+
args: {},
684+
input: async () => ({
685+
ctx: { foo: "bar" },
686+
args: {},
687+
onSuccess: (params) => {
688+
finallyMock(params);
689+
},
690+
}),
691+
});
692+
693+
const mutationFn = withFinally({
694+
args: {},
695+
handler: async (ctx) => {
696+
return { updated: true, foo: ctx.foo };
697+
},
698+
});
699+
700+
await t.run(async (ctx) => {
701+
const result = await (mutationFn as any)._handler(ctx, {});
702+
expect(result).toEqual({ updated: true, foo: "bar" });
703+
704+
expect(finallyMock).toHaveBeenCalledWith({ updated: true, foo: "bar" });
705+
});
706+
});
707+
708+
test("finally callback with action", async () => {
709+
const t = convexTest(schema, modules);
710+
const finallyMock = vi.fn();
711+
712+
const withFinally = customAction(action, {
713+
args: {},
714+
input: async () => ({
715+
ctx: { foo: "bar" },
716+
args: {},
717+
onSuccess: (params) => {
718+
finallyMock(params);
719+
},
720+
}),
721+
});
722+
723+
const actionFn = withFinally({
724+
args: {},
725+
handler: async (ctx) => {
726+
return { executed: true, foo: ctx.foo };
727+
},
728+
});
729+
730+
await t.run(async (ctx) => {
731+
const result = await (actionFn as any)._handler(ctx, {});
732+
expect(result).toEqual({ executed: true, foo: "bar" });
733+
734+
expect(finallyMock).toHaveBeenCalledWith({ executed: true, foo: "bar" });
735+
});
736+
});
737+
});

packages/convex-helpers/server/customFunctions.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ObjectType,
1616
PropertyValidators,
1717
Validator,
18+
Value,
1819
} from "convex/values";
1920
import { asObjectValidator, v } from "convex/values";
2021
import type {
@@ -70,6 +71,10 @@ import { omit, pick } from "../index.js";
7071
* modified function. All returned ctx and args will show up in the type
7172
* signature for the modified function. To remove something from `ctx`, you
7273
* can return it as `undefined`.
74+
75+
* The `input` function can also return an `onSuccess` callback that will be
76+
* called after the function executes successfully. The `onSuccess` callback
77+
* has access to resources created during input processing via closure.
7378
*/
7479
export type Customization<
7580
Ctx extends Record<string, any>,
@@ -84,8 +89,16 @@ export type Customization<
8489
args: ObjectType<CustomArgsValidator>,
8590
extra: ExtraArgs,
8691
) =>
87-
| Promise<{ ctx: CustomCtx; args: CustomMadeArgs }>
88-
| { ctx: CustomCtx; args: CustomMadeArgs };
92+
| Promise<{
93+
ctx: CustomCtx;
94+
args: CustomMadeArgs;
95+
onSuccess?: (result: Value) => void | Promise<void>;
96+
}>
97+
| {
98+
ctx: CustomCtx;
99+
args: CustomMadeArgs;
100+
onSuccess?: (result: Value) => void | Promise<void>;
101+
};
89102
};
90103

91104
/**
@@ -185,7 +198,15 @@ export const NoOp = {
185198
* const user = await getUserOrNull(ctx);
186199
* const session = await db.get(sessionId);
187200
* const db = wrapDatabaseReader({ user }, ctx.db, rlsRules);
188-
* return { ctx: { db, user, session }, args: {} };
201+
* return {
202+
* ctx: { db, user, session },
203+
* args: {},
204+
* onSuccess: (result) => {
205+
* // Optional callback that runs after the function executes
206+
* // Has access to resources created during input processing
207+
* console.log(`Query for ${user.name} returned:`, result);
208+
* }
209+
* };
189210
* },
190211
* });
191212
*
@@ -265,7 +286,15 @@ export function customQuery<
265286
* const user = await getUserOrNull(ctx);
266287
* const session = await db.get(sessionId);
267288
* const db = wrapDatabaseReader({ user }, ctx.db, rlsRules);
268-
* return { ctx: { db, user, session }, args: {} };
289+
* return {
290+
* ctx: { db, user, session },
291+
* args: {},
292+
* onSuccess: (result) => {
293+
* // Optional callback that runs after the function executes
294+
* // Has access to resources created during input processing
295+
* console.log(`User ${user.name} returned:`, result);
296+
* }
297+
* };
269298
* },
270299
* });
271300
*
@@ -347,7 +376,17 @@ export function customMutation<
347376
* throw new Error("Invalid secret key");
348377
* }
349378
* const user = await ctx.runQuery(internal.users.getUser, {});
350-
* return { ctx: { user }, args: {} };
379+
* // Create resources that can be used in the onSuccess callback
380+
* const logger = createLogger();
381+
* return {
382+
* ctx: { user },
383+
* args: {},
384+
* onSuccess: (result) => {
385+
* // Optional callback that runs after the function executes
386+
* // Has access to resources created during input processing
387+
* logger.info(`Action for user ${user.name} returned:`, result);
388+
* }
389+
* };
351390
* },
352391
* });
353392
*
@@ -444,7 +483,12 @@ function customFnBuilder(
444483
extra,
445484
);
446485
const args = omit(allArgs, Object.keys(inputArgs));
447-
return handler({ ...ctx, ...added.ctx }, { ...args, ...added.args });
486+
const finalCtx = { ...ctx, ...added.ctx };
487+
const result = await handler(finalCtx, { ...args, ...added.args });
488+
if (added.onSuccess) {
489+
await added.onSuccess(result ?? null);
490+
}
491+
return result;
448492
},
449493
});
450494
}
@@ -458,7 +502,12 @@ function customFnBuilder(
458502
returns: fn.returns,
459503
handler: async (ctx: any, args: any) => {
460504
const added = await customInput(ctx, args, extra);
461-
return handler({ ...ctx, ...added.ctx }, { ...args, ...added.args });
505+
const finalCtx = { ...ctx, ...added.ctx };
506+
const result = await handler(finalCtx, { ...args, ...added.args });
507+
if (added.onSuccess) {
508+
await added.onSuccess(result ?? null);
509+
}
510+
return result;
462511
},
463512
});
464513
};

0 commit comments

Comments
 (0)