@convex-dev/workos-authkit 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +23 -0
  2. package/dist/client/index.d.ts +3 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +24 -5
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +604 -6
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +18 -7
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/backfill.d.ts +60 -0
  12. package/dist/component/backfill.d.ts.map +1 -0
  13. package/dist/component/backfill.js +171 -0
  14. package/dist/component/backfill.js.map +1 -0
  15. package/dist/component/convex.config.d.ts.map +1 -1
  16. package/dist/component/convex.config.js +2 -0
  17. package/dist/component/convex.config.js.map +1 -1
  18. package/dist/component/lib.d.ts +27 -10
  19. package/dist/component/lib.d.ts.map +1 -1
  20. package/dist/component/lib.js +93 -80
  21. package/dist/component/lib.js.map +1 -1
  22. package/dist/component/schema.d.ts +7 -2
  23. package/dist/component/schema.d.ts.map +1 -1
  24. package/dist/component/schema.js +3 -0
  25. package/dist/component/schema.js.map +1 -1
  26. package/package.json +36 -34
  27. package/src/client/index.ts +24 -5
  28. package/src/component/_generated/api.ts +535 -6
  29. package/src/component/_generated/component.ts +25 -12
  30. package/src/component/backfill.test.ts +335 -0
  31. package/src/component/backfill.ts +217 -0
  32. package/src/component/convex.config.ts +2 -0
  33. package/src/component/lib.ts +103 -81
  34. package/src/component/schema.ts +3 -0
  35. package/src/test.ts +4 -0
@@ -0,0 +1,335 @@
1
+ /// <reference types="vite/client" />
2
+ import { convexTest } from "convex-test";
3
+ import { vi, describe, test, expect, beforeEach, afterEach } from "vitest";
4
+ import { modules } from "./setup.test.js";
5
+ import schema from "./schema.js";
6
+ import workpool from "@convex-dev/workpool/test";
7
+ import workflow from "@convex-dev/workflow/test";
8
+ import { api, internal } from "./_generated/api.js";
9
+
10
+ vi.mock("@workos-inc/node", () => {
11
+ return {
12
+ WorkOS: vi.fn().mockImplementation(function () {
13
+ return {
14
+ userManagement: {
15
+ listUsers: vi.fn(),
16
+ },
17
+ };
18
+ }),
19
+ };
20
+ });
21
+
22
+ /** Default test user values. */
23
+ const defaultUser = {
24
+ id: "user_01ABC",
25
+ email: "alice@example.com",
26
+ firstName: "Alice" as string | null,
27
+ lastName: "Smith" as string | null,
28
+ emailVerified: true,
29
+ profilePictureUrl: null as string | null,
30
+ lastSignInAt: null as string | null,
31
+ externalId: null as string | null,
32
+ metadata: {} as Record<string, string>,
33
+ locale: null as string | null,
34
+ createdAt: "2024-01-01T00:00:00.000Z",
35
+ updatedAt: "2024-01-01T00:00:00.000Z",
36
+ };
37
+
38
+ /** Create a test user fixture. */
39
+ function makeUser(overrides: Partial<typeof defaultUser> = {}) {
40
+ return { ...defaultUser, ...overrides };
41
+ }
42
+
43
+ /** Initialize a convex-test instance with sub-component registrations. */
44
+ function initConvexTest() {
45
+ const t = convexTest(schema, modules);
46
+ workpool.register(t, "eventWorkpool");
47
+ workflow.register(t, "backfillWorkflow");
48
+ return t;
49
+ }
50
+
51
+ describe("backfill", () => {
52
+ beforeEach(() => {
53
+ vi.useFakeTimers();
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.useRealTimers();
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ test("processUsersPage fetches and inserts users, returns only cursor", async () => {
62
+ const user = makeUser();
63
+ const { WorkOS } = await import("@workos-inc/node");
64
+ (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
65
+ function () {
66
+ return {
67
+ userManagement: {
68
+ listUsers: vi.fn().mockResolvedValue({
69
+ data: [{ object: "user", ...user }],
70
+ listMetadata: { after: null },
71
+ }),
72
+ },
73
+ };
74
+ }
75
+ );
76
+
77
+ const t = initConvexTest();
78
+ await t.run(async (ctx) => {
79
+ await ctx.db.insert("backfillState", { apiKey: "sk_test_123" });
80
+ });
81
+ const result = await t.action(internal.backfill.processUsersPage, {});
82
+
83
+ expect(result).toEqual({ nextCursor: undefined });
84
+
85
+ const dbUsers = await t.run(async (ctx) => {
86
+ return ctx.db.query("users").collect();
87
+ });
88
+ expect(dbUsers).toHaveLength(1);
89
+ expect(dbUsers[0].id).toBe(user.id);
90
+ });
91
+
92
+ test("processUsersPage passes order asc to listUsers", async () => {
93
+ const { WorkOS } = await import("@workos-inc/node");
94
+ const listUsersMock = vi.fn().mockResolvedValue({
95
+ data: [],
96
+ listMetadata: { after: null },
97
+ });
98
+ (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
99
+ function () {
100
+ return {
101
+ userManagement: { listUsers: listUsersMock },
102
+ };
103
+ }
104
+ );
105
+
106
+ const t = initConvexTest();
107
+ await t.run(async (ctx) => {
108
+ await ctx.db.insert("backfillState", { apiKey: "sk_test_123" });
109
+ });
110
+ await t.action(internal.backfill.processUsersPage, {});
111
+
112
+ expect(listUsersMock).toHaveBeenCalledWith({
113
+ limit: 100,
114
+ after: undefined,
115
+ order: "asc",
116
+ });
117
+ });
118
+
119
+ test("upsertUsersPage inserts new users", async () => {
120
+ const t = initConvexTest();
121
+ const users = [
122
+ makeUser({ id: "user_01", email: "a@example.com" }),
123
+ makeUser({ id: "user_02", email: "b@example.com" }),
124
+ ];
125
+
126
+ await t.mutation(internal.backfill.upsertUsersPage, { users });
127
+
128
+ const dbUsers = await t.run(async (ctx) => {
129
+ return ctx.db.query("users").collect();
130
+ });
131
+ expect(dbUsers).toHaveLength(2);
132
+ expect(dbUsers.map((u) => u.id).sort()).toEqual(["user_01", "user_02"]);
133
+ });
134
+
135
+ test("upsertUsersPage skips existing users (idempotency)", async () => {
136
+ const t = initConvexTest();
137
+ const user = makeUser({ id: "user_existing", email: "exists@example.com" });
138
+
139
+ // Pre-insert the user
140
+ await t.run(async (ctx) => {
141
+ await ctx.db.insert("users", user);
142
+ });
143
+
144
+ // Upsert with the same user
145
+ await t.mutation(internal.backfill.upsertUsersPage, { users: [user] });
146
+
147
+ const dbUsers = await t.run(async (ctx) => {
148
+ return ctx.db.query("users").collect();
149
+ });
150
+ expect(dbUsers).toHaveLength(1);
151
+ });
152
+
153
+ test("full workflow processes single page", async () => {
154
+ const users = [
155
+ makeUser({ id: "user_w1", email: "w1@example.com" }),
156
+ makeUser({ id: "user_w2", email: "w2@example.com" }),
157
+ ];
158
+
159
+ const { WorkOS } = await import("@workos-inc/node");
160
+ (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
161
+ function () {
162
+ return {
163
+ userManagement: {
164
+ listUsers: vi.fn().mockResolvedValue({
165
+ data: users.map((u) => ({ object: "user", ...u })),
166
+ listMetadata: { after: null },
167
+ }),
168
+ },
169
+ };
170
+ }
171
+ );
172
+
173
+ const t = initConvexTest();
174
+ await t.mutation(api.backfill.startBackfill, {
175
+ apiKey: "sk_test_123",
176
+ });
177
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
178
+
179
+ const dbUsers = await t.run(async (ctx) => {
180
+ return ctx.db.query("users").collect();
181
+ });
182
+ expect(dbUsers).toHaveLength(2);
183
+ expect(dbUsers.map((u) => u.id).sort()).toEqual(["user_w1", "user_w2"]);
184
+
185
+ // Verify backfillState is cleaned up
186
+ const backfillState = await t.run(async (ctx) => {
187
+ return ctx.db.query("backfillState").unique();
188
+ });
189
+ expect(backfillState).toBeNull();
190
+ });
191
+
192
+ test("processUsersPage returns cursor for pagination", async () => {
193
+ const user = makeUser({ id: "user_p1", email: "p1@example.com" });
194
+
195
+ const { WorkOS } = await import("@workos-inc/node");
196
+ (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
197
+ function () {
198
+ return {
199
+ userManagement: {
200
+ listUsers: vi.fn().mockResolvedValue({
201
+ data: [{ object: "user", ...user }],
202
+ listMetadata: { after: "cursor_abc" },
203
+ }),
204
+ },
205
+ };
206
+ }
207
+ );
208
+
209
+ const t = initConvexTest();
210
+ await t.run(async (ctx) => {
211
+ await ctx.db.insert("backfillState", { apiKey: "sk_test_123" });
212
+ });
213
+ const result = await t.action(internal.backfill.processUsersPage, {});
214
+
215
+ expect(result).toEqual({ nextCursor: "cursor_abc" });
216
+
217
+ const dbUsers = await t.run(async (ctx) => {
218
+ return ctx.db.query("users").collect();
219
+ });
220
+ expect(dbUsers).toHaveLength(1);
221
+ expect(dbUsers[0].id).toBe(user.id);
222
+ });
223
+
224
+ test("pagination data flows through processUsersPage", async () => {
225
+ const page1Users = [
226
+ makeUser({ id: "user_p1", email: "p1@example.com" }),
227
+ ];
228
+ const page2Users = [
229
+ makeUser({ id: "user_p2", email: "p2@example.com" }),
230
+ ];
231
+
232
+ const { WorkOS } = await import("@workos-inc/node");
233
+ const listUsersMock = vi
234
+ .fn()
235
+ .mockResolvedValueOnce({
236
+ data: page1Users.map((u) => ({ object: "user", ...u })),
237
+ listMetadata: { after: "cursor_abc" },
238
+ })
239
+ .mockResolvedValueOnce({
240
+ data: page2Users.map((u) => ({ object: "user", ...u })),
241
+ listMetadata: { after: null },
242
+ });
243
+
244
+ (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
245
+ function () {
246
+ return {
247
+ userManagement: { listUsers: listUsersMock },
248
+ };
249
+ }
250
+ );
251
+
252
+ const t = initConvexTest();
253
+ await t.run(async (ctx) => {
254
+ await ctx.db.insert("backfillState", { apiKey: "sk_test_123" });
255
+ });
256
+
257
+ const page1 = await t.action(internal.backfill.processUsersPage, {});
258
+ expect(page1.nextCursor).toBe("cursor_abc");
259
+
260
+ const page2 = await t.action(internal.backfill.processUsersPage, {
261
+ after: page1.nextCursor,
262
+ });
263
+ expect(page2.nextCursor).toBeUndefined();
264
+
265
+ const dbUsers = await t.run(async (ctx) => {
266
+ return ctx.db.query("users").collect();
267
+ });
268
+ expect(dbUsers).toHaveLength(2);
269
+ expect(dbUsers.map((u) => u.id).sort()).toEqual(["user_p1", "user_p2"]);
270
+ });
271
+
272
+ test("startBackfill skips when backfill already in progress", async () => {
273
+ const t = initConvexTest();
274
+
275
+ // Pre-insert a backfillState row to simulate an in-progress backfill
276
+ await t.run(async (ctx) => {
277
+ await ctx.db.insert("backfillState", { apiKey: "sk_existing" });
278
+ });
279
+
280
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
281
+
282
+ await t.mutation(api.backfill.startBackfill, {
283
+ apiKey: "sk_test_123",
284
+ });
285
+
286
+ // Should have warned about skipping
287
+ expect(warnSpy).toHaveBeenCalledWith(
288
+ "Backfill already in progress, skipping"
289
+ );
290
+
291
+ // Original key should remain unchanged
292
+ const state = await t.run(async (ctx) => {
293
+ return ctx.db.query("backfillState").unique();
294
+ });
295
+ expect(state?.apiKey).toBe("sk_existing");
296
+
297
+ warnSpy.mockRestore();
298
+ });
299
+
300
+ test("upsert is idempotent after processUsersPage inserts", async () => {
301
+ const users = [
302
+ makeUser({ id: "user_idem", email: "idem@example.com" }),
303
+ ];
304
+
305
+ const { WorkOS } = await import("@workos-inc/node");
306
+ (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
307
+ function () {
308
+ return {
309
+ userManagement: {
310
+ listUsers: vi.fn().mockResolvedValue({
311
+ data: users.map((u) => ({ object: "user", ...u })),
312
+ listMetadata: { after: null },
313
+ }),
314
+ },
315
+ };
316
+ }
317
+ );
318
+
319
+ const t = initConvexTest();
320
+ await t.run(async (ctx) => {
321
+ await ctx.db.insert("backfillState", { apiKey: "sk_test_123" });
322
+ });
323
+
324
+ // Insert users via processUsersPage (the actual insertion path)
325
+ await t.action(internal.backfill.processUsersPage, {});
326
+
327
+ // Re-upsert the same users directly
328
+ await t.mutation(internal.backfill.upsertUsersPage, { users });
329
+
330
+ const dbUsers = await t.run(async (ctx) => {
331
+ return ctx.db.query("users").collect();
332
+ });
333
+ expect(dbUsers).toHaveLength(1);
334
+ });
335
+ });
@@ -0,0 +1,217 @@
1
+ import { v } from "convex/values";
2
+ import {
3
+ internalAction,
4
+ internalMutation,
5
+ internalQuery,
6
+ mutation,
7
+ } from "./_generated/server.js";
8
+ import { components, internal } from "./_generated/api.js";
9
+ import { WorkOS } from "@workos-inc/node";
10
+ import type { FunctionHandle } from "convex/server";
11
+ import { WorkflowManager } from "@convex-dev/workflow";
12
+ import { vResultValidator } from "@convex-dev/workpool";
13
+ import schema from "./schema.js";
14
+
15
+ const workflow = new WorkflowManager(components.backfillWorkflow, {
16
+ workpoolOptions: { maxParallelism: 1 },
17
+ });
18
+
19
+ const vUser = schema.tables.users.validator;
20
+
21
+ export const getBackfillApiKey = internalQuery({
22
+ args: {},
23
+ returns: v.union(v.string(), v.null()),
24
+ handler: async (ctx): Promise<string | null> => {
25
+ const backfillState = await ctx.db.query("backfillState").unique();
26
+ return backfillState?.apiKey ?? null;
27
+ },
28
+ });
29
+
30
+ /** Fetches one page of users from WorkOS and upserts them, returning only the cursor. */
31
+ export const processUsersPage = internalAction({
32
+ args: {
33
+ after: v.optional(v.string()),
34
+ onEventHandle: v.optional(v.string()),
35
+ logLevel: v.optional(v.literal("DEBUG")),
36
+ },
37
+ returns: v.object({
38
+ nextCursor: v.optional(v.string()),
39
+ }),
40
+ handler: async (
41
+ ctx,
42
+ args
43
+ ): Promise<{ nextCursor: string | undefined }> => {
44
+ const apiKey: string | null = await ctx.runQuery(
45
+ internal.backfill.getBackfillApiKey,
46
+ {}
47
+ );
48
+ if (!apiKey) {
49
+ throw new Error("Backfill API key not found");
50
+ }
51
+ const workos = new WorkOS(apiKey);
52
+ const { data, listMetadata } = await workos.userManagement.listUsers({
53
+ limit: 100,
54
+ after: args.after,
55
+ order: "asc",
56
+ });
57
+ const users = data.map(({ object: _object, ...rest }) => rest);
58
+ if (users.length > 0) {
59
+ await ctx.runMutation(internal.backfill.upsertUsersPage, {
60
+ users,
61
+ onEventHandle: args.onEventHandle,
62
+ logLevel: args.logLevel,
63
+ });
64
+ }
65
+ return {
66
+ nextCursor: listMetadata.after ?? undefined,
67
+ };
68
+ },
69
+ });
70
+
71
+ export const upsertUsersPage = internalMutation({
72
+ args: {
73
+ users: v.array(vUser),
74
+ onEventHandle: v.optional(v.string()),
75
+ logLevel: v.optional(v.literal("DEBUG")),
76
+ },
77
+ returns: v.null(),
78
+ handler: async (ctx, args) => {
79
+ const existingUsers = await Promise.all(
80
+ args.users.map((user) =>
81
+ ctx.db
82
+ .query("users")
83
+ .withIndex("id", (q) => q.eq("id", user.id))
84
+ .unique()
85
+ )
86
+ );
87
+
88
+ const newUsers = args.users.filter((_, i) => !existingUsers[i]);
89
+
90
+ if (args.logLevel === "DEBUG") {
91
+ console.log(
92
+ `backfill: ${newUsers.length} new users out of ${args.users.length}`
93
+ );
94
+ }
95
+
96
+ for (const user of newUsers) {
97
+ await ctx.db.insert("users", user);
98
+
99
+ if (args.onEventHandle) {
100
+ await ctx.runMutation(
101
+ args.onEventHandle as FunctionHandle<"mutation">,
102
+ { event: "user.created", data: { ...user, object: "user" } }
103
+ );
104
+ }
105
+ }
106
+ return null;
107
+ },
108
+ });
109
+
110
+ const MAX_PAGES_PER_BATCH = 50;
111
+
112
+ export const backfillBatch = workflow.define({
113
+ args: {
114
+ onEventHandle: v.optional(v.string()),
115
+ logLevel: v.optional(v.literal("DEBUG")),
116
+ after: v.optional(v.string()),
117
+ },
118
+ returns: v.object({
119
+ done: v.boolean(),
120
+ cursor: v.optional(v.string()),
121
+ }),
122
+ handler: async (step, args): Promise<{ done: boolean; cursor?: string }> => {
123
+ let cursor = args.after;
124
+ let pagesProcessed = 0;
125
+
126
+ while (pagesProcessed < MAX_PAGES_PER_BATCH) {
127
+ const result = await step.runAction(
128
+ internal.backfill.processUsersPage,
129
+ {
130
+ after: cursor,
131
+ onEventHandle: args.onEventHandle,
132
+ logLevel: args.logLevel,
133
+ }
134
+ );
135
+
136
+ cursor = result.nextCursor;
137
+ pagesProcessed++;
138
+ if (!cursor) {
139
+ return { done: true };
140
+ }
141
+ }
142
+
143
+ return { done: false, cursor };
144
+ },
145
+ });
146
+
147
+ export const backfillOnComplete = internalMutation({
148
+ args: {
149
+ workflowId: v.string(),
150
+ result: vResultValidator,
151
+ context: v.object({
152
+ onEventHandle: v.optional(v.string()),
153
+ logLevel: v.optional(v.literal("DEBUG")),
154
+ }),
155
+ },
156
+ returns: v.null(),
157
+ handler: async (ctx, args) => {
158
+ if (args.result.kind !== "success") {
159
+ console.error(`Backfill workflow ${args.result.kind}`, args.result);
160
+ const backfillState = await ctx.db.query("backfillState").unique();
161
+ if (backfillState) {
162
+ await ctx.db.delete(backfillState._id);
163
+ }
164
+ return null;
165
+ }
166
+ const returnValue = args.result.returnValue as {
167
+ done: boolean;
168
+ cursor?: string;
169
+ };
170
+ if (returnValue.done || !returnValue.cursor) {
171
+ const backfillState = await ctx.db.query("backfillState").unique();
172
+ if (backfillState) {
173
+ await ctx.db.delete(backfillState._id);
174
+ }
175
+ } else {
176
+ await workflow.start(
177
+ ctx,
178
+ internal.backfill.backfillBatch,
179
+ {
180
+ ...args.context,
181
+ after: returnValue.cursor,
182
+ },
183
+ {
184
+ onComplete: internal.backfill.backfillOnComplete,
185
+ context: args.context,
186
+ }
187
+ );
188
+ }
189
+ return null;
190
+ },
191
+ });
192
+
193
+ export const startBackfill = mutation({
194
+ args: {
195
+ apiKey: v.string(),
196
+ onEventHandle: v.optional(v.string()),
197
+ logLevel: v.optional(v.literal("DEBUG")),
198
+ },
199
+ returns: v.null(),
200
+ handler: async (ctx, args) => {
201
+ const existing = await ctx.db.query("backfillState").unique();
202
+ if (existing) {
203
+ console.warn("Backfill already in progress, skipping");
204
+ return null;
205
+ }
206
+ await ctx.db.insert("backfillState", { apiKey: args.apiKey });
207
+ const context = {
208
+ onEventHandle: args.onEventHandle,
209
+ logLevel: args.logLevel,
210
+ };
211
+ await workflow.start(ctx, internal.backfill.backfillBatch, context, {
212
+ onComplete: internal.backfill.backfillOnComplete,
213
+ context,
214
+ });
215
+ return null;
216
+ },
217
+ });
@@ -1,8 +1,10 @@
1
1
  import { defineComponent } from "convex/server";
2
2
  import workpool from "@convex-dev/workpool/convex.config";
3
+ import workflow from "@convex-dev/workflow/convex.config";
3
4
 
4
5
  const component = defineComponent("workOSAuthKit");
5
6
 
6
7
  component.use(workpool, { name: "eventWorkpool" });
8
+ component.use(workflow, { name: "backfillWorkflow" });
7
9
 
8
10
  export default component;