Skip to content

Commit d9970b3

Browse files
run create immediately (#23)
Co-authored-by: Shawn Erquhart <shawn@erquh.art>
1 parent 2063b99 commit d9970b3

6 files changed

Lines changed: 148 additions & 129 deletions

File tree

.cursor/rules/convex_rules.mdc

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,6 @@ export const exampleQuery = query({
212212
- Always use `as const` for string literals in discriminated union types.
213213
- When using the `Array` type, make sure to always define your arrays as `const array: Array<T> = [...];`
214214
- When using the `Record` type, make sure to always define your records as `const record: Record<KeyType, ValueType> = {...};`
215-
- Always add `@types/node` to your `package.json` when using any Node.js built-in modules.
216215

217216
## Full text search guidelines
218217
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:

example/convex/_generated/api.d.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,6 @@ export declare const internal: FilterApi<
5151
export declare const components: {
5252
workOSAuthKit: {
5353
lib: {
54-
enqueueWebhookEvent: FunctionReference<
55-
"mutation",
56-
"internal",
57-
{
58-
apiKey: string;
59-
event: string;
60-
eventId: string;
61-
eventTypes?: Array<string>;
62-
logLevel?: "DEBUG";
63-
onEventHandle?: string;
64-
updatedAt?: string;
65-
},
66-
any
67-
>;
6854
getAuthUser: FunctionReference<
6955
"query",
7056
"internal",
@@ -84,6 +70,24 @@ export declare const components: {
8470
updatedAt: string;
8571
} | null
8672
>;
73+
onWebhookEvent: FunctionReference<
74+
"mutation",
75+
"internal",
76+
{
77+
apiKey: string;
78+
event: {
79+
context?: Record<string, any>;
80+
createdAt: string;
81+
data: Record<string, any>;
82+
event: string;
83+
id: string;
84+
};
85+
eventTypes?: Array<string>;
86+
logLevel?: "DEBUG";
87+
onEventHandle?: string;
88+
},
89+
null
90+
>;
8791
};
8892
};
8993
};

example/convex/tsconfig.json

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,24 @@
11
{
22
/* This TypeScript project config describes the environment that
33
* Convex functions run in and is used to typecheck them.
4-
* You can modify it, but some settings required to use Convex.
4+
* You can modify it, but some settings are required to use Convex.
55
*/
66
"compilerOptions": {
77
/* These settings are not required by Convex and can be modified. */
88
"allowJs": true,
99
"strict": true,
10+
"moduleResolution": "Bundler",
11+
"jsx": "react-jsx",
1012
"skipLibCheck": true,
11-
"verbatimModuleSyntax": true,
13+
"allowSyntheticDefaultImports": true,
1214

1315
/* These compiler options are required by Convex */
1416
"target": "ESNext",
1517
"lib": ["ES2021", "dom", "ESNext.Array"],
1618
"forceConsistentCasingInFileNames": true,
17-
"allowSyntheticDefaultImports": true,
1819
"module": "ESNext",
19-
"moduleResolution": "Bundler",
2020
"isolatedModules": true,
2121
"noEmit": true
22-
23-
/* This should only be used in this example. Real apps should not attempt
24-
* to compile TypeScript because differences between tsconfig.json files can
25-
* cause the code to be compiled differently.
26-
*/
27-
// Un-comment this to get instant types between your component and example.
28-
// However, if you're willing to wait for a build before the types update,
29-
// it's better to leave this commented out to catch build errors faster.
30-
// "customConditions": ["@convex-dev/component-source"]
3122
},
3223
"include": ["./**/*"],
3324
"exclude": ["./_generated"]

src/client/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
} from "@workos-inc/node";
1919
import type { SetRequired } from "type-fest";
2020
import { v } from "convex/values";
21+
import { parse } from "convex-helpers/validators";
2122
import type { ComponentApi } from "../component/_generated/component.js";
23+
import { vEvent } from "../component/lib.js";
2224

2325
type WorkOSResponsePayload =
2426
| AuthenticationActionResponseData
@@ -226,15 +228,12 @@ export class AuthKit<DataModel extends GenericDataModel> {
226228
if (this.config.logLevel === "DEBUG") {
227229
console.log("received event", event);
228230
}
229-
await ctx.runMutation(this.component.lib.enqueueWebhookEvent, {
231+
await ctx.runMutation(this.component.lib.onWebhookEvent, {
230232
apiKey: this.config.apiKey,
231-
eventId: event.id,
232-
event: event.event,
233+
event: parse(vEvent, event),
233234
onEventHandle: this.config.authFunctions?.authKitEvent
234235
? await createFunctionHandle(this.config.authFunctions.authKitEvent)
235236
: undefined,
236-
updatedAt:
237-
"updated_at" in event ? (event.updated_at as string) : undefined,
238237
eventTypes: this.config.additionalEventTypes,
239238
logLevel: this.config.logLevel,
240239
});

src/component/_generated/component.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,6 @@ import type { FunctionReference } from "convex/server";
2424
export type ComponentApi<Name extends string | undefined = string | undefined> =
2525
{
2626
lib: {
27-
enqueueWebhookEvent: FunctionReference<
28-
"mutation",
29-
"internal",
30-
{
31-
apiKey: string;
32-
event: string;
33-
eventId: string;
34-
eventTypes?: Array<string>;
35-
logLevel?: "DEBUG";
36-
onEventHandle?: string;
37-
updatedAt?: string;
38-
},
39-
any,
40-
Name
41-
>;
4227
getAuthUser: FunctionReference<
4328
"query",
4429
"internal",
@@ -59,5 +44,24 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
5944
} | null,
6045
Name
6146
>;
47+
onWebhookEvent: FunctionReference<
48+
"mutation",
49+
"internal",
50+
{
51+
apiKey: string;
52+
event: {
53+
context?: Record<string, any>;
54+
createdAt: string;
55+
data: Record<string, any>;
56+
event: string;
57+
id: string;
58+
};
59+
eventTypes?: Array<string>;
60+
logLevel?: "DEBUG";
61+
onEventHandle?: string;
62+
},
63+
null,
64+
Name
65+
>;
6266
};
6367
};

src/component/lib.ts

Lines changed: 103 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { v } from "convex/values";
1+
import { type Infer, v } from "convex/values";
22
import {
33
internalAction,
44
internalMutation,
55
internalQuery,
66
mutation,
77
query,
88
} from "./_generated/server.js";
9+
import type { MutationCtx } from "./_generated/server.js";
910
import { components, internal } from "./_generated/api.js";
1011
import { omit, withoutSystemFields } from "convex-helpers";
1112
import { WorkOS, type Event as WorkOSEvent } from "@workos-inc/node";
@@ -18,32 +19,117 @@ const eventWorkpool = new Workpool(components.eventWorkpool, {
1819
maxParallelism: 1,
1920
});
2021

21-
const vEvent = v.object({
22+
export const vEvent = v.object({
2223
id: v.string(),
2324
createdAt: v.string(),
2425
event: v.string(),
2526
data: v.record(v.string(), v.any()),
2627
context: v.optional(v.record(v.string(), v.any())),
2728
});
2829

29-
export const enqueueWebhookEvent = mutation({
30+
async function processEventHandler(
31+
ctx: MutationCtx,
32+
args: {
33+
event: Infer<typeof vEvent>;
34+
logLevel?: "DEBUG";
35+
onEventHandle?: string;
36+
}
37+
) {
38+
if (args.logLevel === "DEBUG") {
39+
console.log("processing event", args.event);
40+
}
41+
const dbEvent = await ctx.db
42+
.query("events")
43+
.withIndex("eventId", (q) => q.eq("eventId", args.event.id))
44+
.unique();
45+
if (dbEvent) {
46+
console.log("event already processed", args.event.id);
47+
return;
48+
}
49+
await ctx.db.insert("events", {
50+
eventId: args.event.id,
51+
event: args.event.event,
52+
updatedAt: args.event.data.updatedAt as string | undefined,
53+
});
54+
const event = args.event as WorkOSEvent;
55+
switch (event.event) {
56+
case "user.created": {
57+
const data = omit(event.data, ["object"]);
58+
const existingUser = await ctx.db
59+
.query("users")
60+
.withIndex("id", (q) => q.eq("id", data.id))
61+
.unique();
62+
if (existingUser) {
63+
console.warn("user already exists", data.id);
64+
break;
65+
}
66+
await ctx.db.insert("users", data);
67+
break;
68+
}
69+
case "user.updated": {
70+
const data = omit(event.data, ["object"]);
71+
const user = await ctx.db
72+
.query("users")
73+
.withIndex("id", (q) => q.eq("id", data.id))
74+
.unique();
75+
if (!user) {
76+
console.error("user not found", data.id);
77+
break;
78+
}
79+
if (user.updatedAt >= data.updatedAt) {
80+
console.warn(`user already updated for event ${event.id}, skipping`);
81+
break;
82+
}
83+
await ctx.db.patch(user._id, data);
84+
break;
85+
}
86+
case "user.deleted": {
87+
const data = omit(event.data, ["object"]);
88+
const user = await ctx.db
89+
.query("users")
90+
.withIndex("id", (q) => q.eq("id", data.id))
91+
.unique();
92+
if (!user) {
93+
console.warn("user not found", data.id);
94+
break;
95+
}
96+
await ctx.db.delete(user._id);
97+
break;
98+
}
99+
}
100+
if (args.onEventHandle) {
101+
await ctx.runMutation(args.onEventHandle as FunctionHandle<"mutation">, {
102+
event: args.event.event,
103+
data: args.event.data,
104+
});
105+
}
106+
}
107+
108+
export const onWebhookEvent = mutation({
30109
args: {
31110
apiKey: v.string(),
32-
eventId: v.string(),
33-
event: v.string(),
34-
updatedAt: v.optional(v.string()),
111+
event: vEvent,
35112
onEventHandle: v.optional(v.string()),
36113
eventTypes: v.optional(v.array(v.string())),
37114
logLevel: v.optional(v.literal("DEBUG")),
38115
},
116+
returns: v.null(),
39117
handler: async (ctx, args) => {
40-
await eventWorkpool.cancelAll(ctx);
41-
await eventWorkpool.enqueueAction(ctx, internal.lib.updateEvents, {
42-
apiKey: args.apiKey,
43-
onEventHandle: args.onEventHandle,
44-
eventTypes: args.eventTypes,
45-
logLevel: args.logLevel,
46-
});
118+
const isCreateEvent = args.event.event.endsWith(".created");
119+
120+
if (isCreateEvent) {
121+
// Process create events immediately
122+
await processEventHandler(ctx, args);
123+
} else {
124+
// Enqueue update/delete events to workpool
125+
await eventWorkpool.enqueueAction(ctx, internal.lib.updateEvents, {
126+
apiKey: args.apiKey,
127+
onEventHandle: args.onEventHandle,
128+
eventTypes: args.eventTypes,
129+
logLevel: args.logLevel,
130+
});
131+
}
132+
return null;
47133
},
48134
});
49135

@@ -67,6 +153,9 @@ export const updateEvents = internalAction({
67153
logLevel: v.optional(v.literal("DEBUG")),
68154
},
69155
handler: async (ctx, args) => {
156+
// Cancel other pending workpool jobs since this run will
157+
// process all available events from the WorkOS API.
158+
await eventWorkpool.cancelAll(ctx);
70159
const workos = new WorkOS(args.apiKey);
71160
const cursor = await ctx.runQuery(internal.lib.getCursor);
72161
let nextCursor = cursor ?? undefined;
@@ -107,74 +196,7 @@ export const processEvent = internalMutation({
107196
onEventHandle: v.optional(v.string()),
108197
},
109198
handler: async (ctx, args) => {
110-
if (args.logLevel === "DEBUG") {
111-
console.log("processing event", args.event);
112-
}
113-
const dbEvent = await ctx.db
114-
.query("events")
115-
.withIndex("eventId", (q) => q.eq("eventId", args.event.id))
116-
.unique();
117-
if (dbEvent) {
118-
console.log("event already processed", args.event.id);
119-
return;
120-
}
121-
await ctx.db.insert("events", {
122-
eventId: args.event.id,
123-
event: args.event.event,
124-
updatedAt: args.event.data.updatedAt,
125-
});
126-
const event = args.event as WorkOSEvent;
127-
switch (event.event) {
128-
case "user.created": {
129-
const data = omit(event.data, ["object"]);
130-
const existingUser = await ctx.db
131-
.query("users")
132-
.withIndex("id", (q) => q.eq("id", data.id))
133-
.unique();
134-
if (existingUser) {
135-
console.warn("user already exists", data.id);
136-
break;
137-
}
138-
await ctx.db.insert("users", data);
139-
break;
140-
}
141-
case "user.updated": {
142-
const data = omit(event.data, ["object"]);
143-
const user = await ctx.db
144-
.query("users")
145-
.withIndex("id", (q) => q.eq("id", data.id))
146-
.unique();
147-
if (!user) {
148-
console.error("user not found", data.id);
149-
break;
150-
}
151-
if (user.updatedAt >= data.updatedAt) {
152-
console.warn(`user already updated for event ${event.id}, skipping`);
153-
break;
154-
}
155-
await ctx.db.patch(user._id, data);
156-
break;
157-
}
158-
case "user.deleted": {
159-
const data = omit(event.data, ["object"]);
160-
const user = await ctx.db
161-
.query("users")
162-
.withIndex("id", (q) => q.eq("id", data.id))
163-
.unique();
164-
if (!user) {
165-
console.warn("user not found", data.id);
166-
break;
167-
}
168-
await ctx.db.delete(user._id);
169-
break;
170-
}
171-
}
172-
if (args.onEventHandle) {
173-
await ctx.runMutation(args.onEventHandle as FunctionHandle<"mutation">, {
174-
event: args.event.event,
175-
data: args.event.data,
176-
});
177-
}
199+
await processEventHandler(ctx, args);
178200
},
179201
});
180202

0 commit comments

Comments
 (0)