@xixixao/convex-migrations 0.3.1-alpha.1

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 (57) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +523 -0
  3. package/dist/client/index.d.ts +390 -0
  4. package/dist/client/index.d.ts.map +1 -0
  5. package/dist/client/index.js +528 -0
  6. package/dist/client/index.js.map +1 -0
  7. package/dist/client/log.d.ts +8 -0
  8. package/dist/client/log.d.ts.map +1 -0
  9. package/dist/client/log.js +74 -0
  10. package/dist/client/log.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +34 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +95 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +3 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/lib.d.ts +74 -0
  32. package/dist/component/lib.d.ts.map +1 -0
  33. package/dist/component/lib.js +290 -0
  34. package/dist/component/lib.js.map +1 -0
  35. package/dist/component/schema.d.ts +28 -0
  36. package/dist/component/schema.d.ts.map +1 -0
  37. package/dist/component/schema.js +20 -0
  38. package/dist/component/schema.js.map +1 -0
  39. package/dist/shared.d.ts +40 -0
  40. package/dist/shared.d.ts.map +1 -0
  41. package/dist/shared.js +22 -0
  42. package/dist/shared.js.map +1 -0
  43. package/package.json +95 -0
  44. package/src/client/index.test.ts +16 -0
  45. package/src/client/index.ts +765 -0
  46. package/src/client/log.ts +76 -0
  47. package/src/component/_generated/api.ts +50 -0
  48. package/src/component/_generated/component.ts +116 -0
  49. package/src/component/_generated/dataModel.ts +60 -0
  50. package/src/component/_generated/server.ts +161 -0
  51. package/src/component/convex.config.ts +3 -0
  52. package/src/component/lib.test.ts +110 -0
  53. package/src/component/lib.ts +356 -0
  54. package/src/component/schema.ts +20 -0
  55. package/src/component/setup.test.ts +5 -0
  56. package/src/shared.ts +37 -0
  57. package/src/test.ts +18 -0
@@ -0,0 +1,356 @@
1
+ import type { FunctionHandle, WithoutSystemFields } from "convex/server";
2
+ import { ConvexError, type ObjectType, v } from "convex/values";
3
+ import {
4
+ type MigrationArgs,
5
+ type MigrationResult,
6
+ type MigrationStatus,
7
+ migrationStatus,
8
+ } from "../shared.js";
9
+ import { api } from "./_generated/api.js";
10
+ import type { Doc } from "./_generated/dataModel.js";
11
+ import {
12
+ mutation,
13
+ type MutationCtx,
14
+ query,
15
+ type QueryCtx,
16
+ } from "./_generated/server.js";
17
+
18
+ export type MigrationFunctionHandle = FunctionHandle<
19
+ "mutation",
20
+ MigrationArgs,
21
+ MigrationResult
22
+ >;
23
+
24
+ const runMigrationArgs = {
25
+ name: v.string(),
26
+ fnHandle: v.string(),
27
+ cursor: v.optional(v.union(v.string(), v.null())),
28
+
29
+ batchSize: v.optional(v.number()),
30
+ oneBatchOnly: v.optional(v.boolean()),
31
+ next: v.optional(
32
+ v.array(
33
+ v.object({
34
+ name: v.string(),
35
+ fnHandle: v.string(),
36
+ }),
37
+ ),
38
+ ),
39
+ dryRun: v.boolean(),
40
+ args: v.optional(v.any()),
41
+ };
42
+
43
+ export const migrate = mutation({
44
+ args: runMigrationArgs,
45
+ returns: migrationStatus,
46
+ handler: async (ctx, args) => {
47
+ // Step 1: Get or create the state.
48
+ const { fnHandle, batchSize, next: next_, dryRun, name } = args;
49
+ if (batchSize !== undefined && batchSize <= 0) {
50
+ throw new Error("Batch size must be greater than 0");
51
+ }
52
+ if (!fnHandle.startsWith("function://")) {
53
+ throw new Error(
54
+ "Invalid fnHandle.\n" +
55
+ "Do not call this from the CLI or dashboard directly.\n" +
56
+ "Instead use the `migrations.runner` function to run migrations." +
57
+ "See https://www.convex.dev/components/migrations",
58
+ );
59
+ }
60
+ const state =
61
+ (await ctx.db
62
+ .query("migrations")
63
+ .withIndex("name", (q) => q.eq("name", name))
64
+ .unique()) ??
65
+ (await ctx.db.get(
66
+ await ctx.db.insert("migrations", {
67
+ name,
68
+ cursor: args.cursor ?? null,
69
+ isDone: false,
70
+ processed: 0,
71
+ latestStart: Date.now(),
72
+ args: args.args,
73
+ }),
74
+ ))!;
75
+
76
+ // Update the state if the cursor arg differs.
77
+ if (state.cursor !== args.cursor) {
78
+ // This happens if:
79
+ // 1. The migration is being started/resumed (args.cursor unset).
80
+ // 2. The migration is being resumed at a different cursor.
81
+ // 3. There are two instances of the same migration racing.
82
+ const worker =
83
+ state.workerId && (await ctx.db.system.get(state.workerId));
84
+ if (
85
+ worker &&
86
+ (worker.state.kind === "pending" || worker.state.kind === "inProgress")
87
+ ) {
88
+ // Case 3. The migration is already in progress.
89
+ console.debug({ state, worker });
90
+ return getMigrationState(ctx, state);
91
+ }
92
+ // Case 2. Update the cursor.
93
+ if (args.cursor !== undefined) {
94
+ state.cursor = args.cursor;
95
+ state.isDone = false;
96
+ state.latestStart = Date.now();
97
+ state.latestEnd = undefined;
98
+ state.processed = 0;
99
+ }
100
+ // For Case 1, Step 2 will take the right action.
101
+ }
102
+
103
+ function updateState(result: MigrationResult) {
104
+ state.cursor = result.continueCursor;
105
+ state.isDone = result.isDone;
106
+ state.processed += result.processed;
107
+ if (result.isDone && state.latestEnd === undefined) {
108
+ state.latestEnd = Date.now();
109
+ }
110
+ }
111
+
112
+ try {
113
+ // Step 2: Run the migration.
114
+ if (!state.isDone) {
115
+ const result = await ctx.runMutation(
116
+ fnHandle as MigrationFunctionHandle,
117
+ {
118
+ cursor: state.cursor,
119
+ batchSize,
120
+ dryRun,
121
+ args: args.args,
122
+ },
123
+ );
124
+ updateState(result);
125
+ state.error = undefined;
126
+ }
127
+
128
+ // Step 3: Schedule the next batch or next migration.
129
+ if (args.oneBatchOnly) {
130
+ state.workerId = undefined;
131
+ } else if (!state.isDone) {
132
+ // Recursively schedule the next batch.
133
+ state.workerId = await ctx.scheduler.runAfter(0, api.lib.migrate, {
134
+ ...args,
135
+ cursor: state.cursor,
136
+ });
137
+ } else {
138
+ state.workerId = undefined;
139
+ // Schedule the next migration in the series.
140
+ const next = next_ ?? [];
141
+ // Find the next migration that hasn't been done.
142
+ let i = 0;
143
+ for (; i < next.length; i++) {
144
+ const doc = await ctx.db
145
+ .query("migrations")
146
+ .withIndex("name", (q) => q.eq("name", next[i]!.name))
147
+ .unique();
148
+ if (!doc || !doc.isDone) {
149
+ const [nextFn, ...rest] = next.slice(i);
150
+ if (nextFn) {
151
+ await ctx.scheduler.runAfter(0, api.lib.migrate, {
152
+ name: nextFn.name,
153
+ fnHandle: nextFn.fnHandle,
154
+ next: rest,
155
+ batchSize,
156
+ dryRun,
157
+ });
158
+ }
159
+ break;
160
+ }
161
+ }
162
+ if (args.cursor === undefined) {
163
+ if (next.length && i === next.length) {
164
+ console.info(`Migration${i > 0 ? "s" : ""} up next already done.`);
165
+ }
166
+ } else {
167
+ console.info(
168
+ `Migration ${name} is done.` +
169
+ (i < next.length ? ` Next: ${next[i]!.name}` : ""),
170
+ );
171
+ }
172
+ }
173
+ } catch (e) {
174
+ state.workerId = undefined;
175
+ if (dryRun && e instanceof ConvexError && e.data.kind === "DRY RUN") {
176
+ // Add the state to the error to bubble up.
177
+ updateState(e.data.result);
178
+ } else {
179
+ state.error = e instanceof Error ? e.message : String(e);
180
+ console.error(`Migration ${name} failed: ${state.error}`);
181
+ }
182
+ if (dryRun) {
183
+ const status = await getMigrationState(ctx, state);
184
+ status.batchSize = batchSize;
185
+ status.next = next_?.map((n) => n.name);
186
+ throw new ConvexError({
187
+ kind: "DRY RUN",
188
+ status,
189
+ });
190
+ }
191
+ }
192
+
193
+ // Step 4: Update the state
194
+ await ctx.db.patch(state._id, state);
195
+ if (args.dryRun) {
196
+ // By throwing an error, the transaction will be rolled back and nothing
197
+ // will be scheduled.
198
+ console.debug({ args, state });
199
+ throw new Error(
200
+ "Error: Dry run attempted to update state - rolling back transaction.",
201
+ );
202
+ }
203
+ return getMigrationState(ctx, state);
204
+ },
205
+ });
206
+
207
+ export const getStatus = query({
208
+ args: {
209
+ names: v.optional(v.array(v.string())),
210
+ limit: v.optional(v.number()),
211
+ },
212
+ returns: v.array(migrationStatus),
213
+ handler: async (ctx, args) => {
214
+ const docs = args.names
215
+ ? await Promise.all(
216
+ args.names.map(
217
+ async (m) =>
218
+ (await ctx.db
219
+ .query("migrations")
220
+ .withIndex("name", (q) => q.eq("name", m))
221
+ .unique()) ?? {
222
+ name: m,
223
+ processed: 0,
224
+ cursor: null,
225
+ latestStart: 0,
226
+ workerId: undefined,
227
+ isDone: false as const,
228
+ },
229
+ ),
230
+ )
231
+ : await ctx.db
232
+ .query("migrations")
233
+ .order("desc")
234
+ .take(args.limit ?? 10);
235
+
236
+ return Promise.all(
237
+ docs
238
+ .reverse()
239
+ .map(async (migration) => getMigrationState(ctx, migration)),
240
+ );
241
+ },
242
+ });
243
+
244
+ async function getMigrationState(
245
+ ctx: QueryCtx,
246
+ migration: WithoutSystemFields<Doc<"migrations">>,
247
+ ): Promise<MigrationStatus> {
248
+ const worker =
249
+ migration.workerId && (await ctx.db.system.get(migration.workerId));
250
+ const args = worker?.args[0] as
251
+ | ObjectType<typeof runMigrationArgs>
252
+ | undefined;
253
+ const state = migration.isDone
254
+ ? "success"
255
+ : migration.error || worker?.state.kind === "failed"
256
+ ? "failed"
257
+ : worker?.state.kind === "canceled"
258
+ ? "canceled"
259
+ : worker?.state.kind === "inProgress" ||
260
+ worker?.state.kind === "pending"
261
+ ? "inProgress"
262
+ : "unknown";
263
+ return {
264
+ name: migration.name,
265
+ cursor: migration.cursor,
266
+ processed: migration.processed,
267
+ isDone: migration.isDone,
268
+ latestStart: migration.latestStart,
269
+ latestEnd: migration.latestEnd,
270
+ error: migration.error,
271
+ state,
272
+ batchSize: args?.batchSize,
273
+ next: args?.next?.map((n: { name: string }) => n.name),
274
+ };
275
+ }
276
+
277
+ export const cancel = mutation({
278
+ args: { name: v.string() },
279
+ returns: migrationStatus,
280
+ handler: async (ctx, args) => {
281
+ const migration = await ctx.db
282
+ .query("migrations")
283
+ .withIndex("name", (q) => q.eq("name", args.name))
284
+ .unique();
285
+
286
+ if (!migration) {
287
+ throw new Error(`Migration ${args.name} not found`);
288
+ }
289
+ const state = await cancelMigration(ctx, migration);
290
+ if (state.state !== "canceled") {
291
+ console.log(
292
+ `Did not cancel migration ${migration.name}. Status was ${state.state}`,
293
+ );
294
+ }
295
+ return state;
296
+ },
297
+ });
298
+
299
+ async function cancelMigration(ctx: MutationCtx, migration: Doc<"migrations">) {
300
+ const state = await getMigrationState(ctx, migration);
301
+ if (state.isDone) {
302
+ return state;
303
+ }
304
+ if (state.state === "inProgress") {
305
+ if (!migration.workerId) {
306
+ await ctx.scheduler.cancel(migration.workerId!);
307
+ }
308
+ console.log(`Canceled migration ${migration.name}`);
309
+ return { ...state, state: "canceled" as const };
310
+ }
311
+ return state;
312
+ }
313
+
314
+ export const cancelAll = mutation({
315
+ // Paginating with creation time for now
316
+ args: { sinceTs: v.optional(v.number()) },
317
+ returns: v.array(migrationStatus),
318
+ handler: async (ctx, args) => {
319
+ const results = await ctx.db
320
+ .query("migrations")
321
+ .withIndex("isDone", (q) =>
322
+ args.sinceTs
323
+ ? q.eq("isDone", false).gte("_creationTime", args.sinceTs)
324
+ : q.eq("isDone", false),
325
+ )
326
+ .take(100);
327
+ if (results.length === 100) {
328
+ await ctx.scheduler.runAfter(0, api.lib.cancelAll, {
329
+ sinceTs: results[results.length - 1]!._creationTime,
330
+ });
331
+ }
332
+ return Promise.all(results.map((m) => cancelMigration(ctx, m)));
333
+ },
334
+ });
335
+
336
+ export const clearAll = mutation({
337
+ args: { before: v.optional(v.number()) },
338
+ returns: v.null(),
339
+ handler: async (ctx, args) => {
340
+ const results = await ctx.db
341
+ .query("migrations")
342
+ .withIndex("by_creation_time", (q) =>
343
+ q.lte("_creationTime", args.before ?? Date.now()),
344
+ )
345
+ .order("desc")
346
+ .take(100);
347
+ for (const m of results) {
348
+ await ctx.db.delete(m._id);
349
+ }
350
+ if (results.length === 100) {
351
+ await ctx.scheduler.runAfter(0, api.lib.clearAll, {
352
+ before: results[99]._creationTime,
353
+ });
354
+ }
355
+ },
356
+ });
@@ -0,0 +1,20 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ migrations: defineTable({
6
+ name: v.string(), // Defaults to the function name.
7
+ cursor: v.union(v.string(), v.null()),
8
+ isDone: v.boolean(),
9
+ workerId: v.optional(v.id("_scheduled_functions")),
10
+ error: v.optional(v.string()),
11
+ // The number of documents processed so far.
12
+ processed: v.number(),
13
+ latestStart: v.number(),
14
+ latestEnd: v.optional(v.number()),
15
+ // Runtime args passed to the migration.
16
+ args: v.optional(v.any()),
17
+ })
18
+ .index("name", ["name"])
19
+ .index("isDone", ["isDone"]),
20
+ });
@@ -0,0 +1,5 @@
1
+ /// <reference types="vite/client" />
2
+ import { test } from "vitest";
3
+ export const modules = import.meta.glob("./**/*.*s");
4
+
5
+ test("setup", () => {});
package/src/shared.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { type Infer, type ObjectType, v } from "convex/values";
2
+
3
+ export const migrationArgs = {
4
+ fn: v.optional(v.string()),
5
+ cursor: v.optional(v.union(v.string(), v.null())),
6
+ batchSize: v.optional(v.number()),
7
+ dryRun: v.optional(v.boolean()),
8
+ next: v.optional(v.array(v.string())),
9
+ args: v.optional(v.any()),
10
+ };
11
+ export type MigrationArgs = ObjectType<typeof migrationArgs>;
12
+
13
+ export type MigrationResult = {
14
+ continueCursor: string;
15
+ isDone: boolean;
16
+ processed: number;
17
+ };
18
+
19
+ export const migrationStatus = v.object({
20
+ name: v.string(),
21
+ cursor: v.optional(v.union(v.string(), v.null())),
22
+ processed: v.number(),
23
+ isDone: v.boolean(),
24
+ error: v.optional(v.string()),
25
+ state: v.union(
26
+ v.literal("inProgress"),
27
+ v.literal("success"),
28
+ v.literal("failed"),
29
+ v.literal("canceled"),
30
+ v.literal("unknown"),
31
+ ),
32
+ latestStart: v.number(),
33
+ latestEnd: v.optional(v.number()),
34
+ batchSize: v.optional(v.number()),
35
+ next: v.optional(v.array(v.string())),
36
+ });
37
+ export type MigrationStatus = Infer<typeof migrationStatus>;
package/src/test.ts ADDED
@@ -0,0 +1,18 @@
1
+ /// <reference types="vite/client" />
2
+ import type { TestConvex } from "convex-test";
3
+ import type { GenericSchema, SchemaDefinition } from "convex/server";
4
+ import schema from "./component/schema.js";
5
+ const modules = import.meta.glob("./component/**/*.ts");
6
+
7
+ /**
8
+ * Register the component with the test convex instance.
9
+ * @param t - The test convex instance, e.g. from calling `convexTest`.
10
+ * @param name - The name of the component, as registered in convex.config.ts.
11
+ */
12
+ export function register(
13
+ t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
14
+ name: string = "migrations",
15
+ ) {
16
+ t.registerComponent(name, schema, modules);
17
+ }
18
+ export default { register, schema, modules };