convex-feedback 0.2.0-beta.8 → 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 (81) hide show
  1. package/README.md +398 -7
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +49 -9
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +1 -3
  6. package/dist/client/config.d.ts.map +1 -1
  7. package/dist/client/config.js.map +1 -1
  8. package/dist/client/index.d.ts +707 -15
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +243 -12
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +2 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +222 -4
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/admin.d.ts +5 -90
  18. package/dist/component/admin.d.ts.map +1 -1
  19. package/dist/component/admin.js +59 -29
  20. package/dist/component/admin.js.map +1 -1
  21. package/dist/component/comments.d.ts +107 -15
  22. package/dist/component/comments.d.ts.map +1 -1
  23. package/dist/component/comments.js +342 -28
  24. package/dist/component/comments.js.map +1 -1
  25. package/dist/component/crons.d.ts.map +1 -1
  26. package/dist/component/crons.js +4 -0
  27. package/dist/component/crons.js.map +1 -1
  28. package/dist/component/entries.d.ts +119 -91
  29. package/dist/component/entries.d.ts.map +1 -1
  30. package/dist/component/entries.js +348 -29
  31. package/dist/component/entries.js.map +1 -1
  32. package/dist/component/helpers.d.ts +32 -1
  33. package/dist/component/helpers.d.ts.map +1 -1
  34. package/dist/component/helpers.js +121 -4
  35. package/dist/component/helpers.js.map +1 -1
  36. package/dist/component/migrations.d.ts +24 -0
  37. package/dist/component/migrations.d.ts.map +1 -1
  38. package/dist/component/migrations.js +318 -0
  39. package/dist/component/migrations.js.map +1 -1
  40. package/dist/component/model.d.ts +455 -120
  41. package/dist/component/model.d.ts.map +1 -1
  42. package/dist/component/model.js +70 -2
  43. package/dist/component/model.js.map +1 -1
  44. package/dist/component/reactions.d.ts +37 -0
  45. package/dist/component/reactions.d.ts.map +1 -0
  46. package/dist/component/reactions.js +52 -0
  47. package/dist/component/reactions.js.map +1 -0
  48. package/dist/component/roadmap.d.ts +10 -46
  49. package/dist/component/roadmap.d.ts.map +1 -1
  50. package/dist/component/roadmap.js +239 -21
  51. package/dist/component/roadmap.js.map +1 -1
  52. package/dist/component/schema.d.ts +33 -5
  53. package/dist/component/schema.d.ts.map +1 -1
  54. package/dist/component/schema.js +40 -4
  55. package/dist/component/schema.js.map +1 -1
  56. package/dist/react/index.d.ts +26 -238
  57. package/dist/react/index.d.ts.map +1 -1
  58. package/dist/react/index.js +65 -2
  59. package/dist/react/index.js.map +1 -1
  60. package/dist/test.d.ts +23 -5
  61. package/dist/test.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/client/README.md +6 -1
  64. package/src/client/api.ts +89 -7
  65. package/src/client/config.ts +1 -3
  66. package/src/client/index.ts +1237 -34
  67. package/src/component/README.md +32 -4
  68. package/src/component/_generated/api.ts +2 -0
  69. package/src/component/_generated/component.ts +290 -8
  70. package/src/component/admin.ts +88 -30
  71. package/src/component/comments.ts +405 -26
  72. package/src/component/crons.ts +10 -0
  73. package/src/component/entries.ts +388 -27
  74. package/src/component/helpers.ts +163 -4
  75. package/src/component/migrations.ts +415 -0
  76. package/src/component/model.ts +381 -117
  77. package/src/component/reactions.ts +60 -0
  78. package/src/component/roadmap.ts +357 -35
  79. package/src/component/schema.ts +40 -4
  80. package/src/react/README.md +5 -0
  81. package/src/react/index.ts +90 -3
@@ -4,22 +4,85 @@ import {
4
4
  paginationResultValidator,
5
5
  } from "convex/server";
6
6
  import { ConvexError, v } from "convex/values";
7
+ import { stream } from "convex-helpers/server/stream";
7
8
 
8
- import { mutation, query } from "./_generated/server.js";
9
+ import type { Doc } from "./_generated/dataModel.js";
10
+ import { internal } from "./_generated/api.js";
11
+ import { internalMutation, mutation, query } from "./_generated/server.js";
9
12
  import {
10
13
  assertActorId,
11
14
  assertPositiveInteger,
15
+ commentIsLive,
16
+ findDeletingComment,
12
17
  normalizeRequiredText,
18
+ serializeActivityComment,
13
19
  serializeComment,
14
20
  } from "./helpers.js";
15
21
  import {
22
+ activityCommentValidator,
16
23
  actorValidator,
17
24
  actorIsAdmin,
18
25
  commentSortValidator,
26
+ entryKindValidator,
27
+ entryStatusValidator,
19
28
  entryStatusFilterForStatus,
20
29
  publicCommentValidator,
21
30
  } from "./model.js";
22
31
  import schema from "./schema.js";
32
+ import type { MutationCtx } from "./types.js";
33
+
34
+ const commentDeletionBatchSize = 25;
35
+ const reactionDeletionBatchSize = 100;
36
+
37
+ async function scheduleCommentCleanup(
38
+ ctx: MutationCtx,
39
+ commentId: Doc<"comments">["_id"],
40
+ ): Promise<void> {
41
+ await ctx.scheduler.runAfter(0, internal.comments.removeBatch, { commentId });
42
+ }
43
+
44
+ /**
45
+ * Mark a comment subtree for permanent deletion and enqueue its bounded
46
+ * cleanup. The helper is shared by the public delete mutation and the legacy
47
+ * migration so both paths use the same deletion token and retry semantics.
48
+ */
49
+ export async function startCommentDeletion(
50
+ ctx: MutationCtx,
51
+ comment: Doc<"comments">,
52
+ deletionToken = Date.now(),
53
+ ): Promise<void> {
54
+ let rootId =
55
+ comment.deletingAt === undefined
56
+ ? comment._id
57
+ : (comment.deletionRootId ?? comment._id);
58
+
59
+ // A legacy or interrupted cleanup can leave a pending child pointing at a
60
+ // root that was already removed. Re-root that child so retries cannot keep
61
+ // scheduling a missing document forever.
62
+ if (
63
+ comment.deletingAt !== undefined &&
64
+ comment.deletionRootId !== undefined
65
+ ) {
66
+ const root = await ctx.db.get("comments", comment.deletionRootId);
67
+ if (root === null || root.deletingAt === undefined) {
68
+ rootId = comment._id;
69
+ await ctx.db.patch("comments", comment._id, {
70
+ deletionRootId: rootId,
71
+ });
72
+ }
73
+ }
74
+ if (comment.deletingAt === undefined) {
75
+ await ctx.db.patch("comments", comment._id, {
76
+ deletingAt: deletionToken,
77
+ deletionRootId: rootId,
78
+ });
79
+ }
80
+ await scheduleCommentCleanup(ctx, rootId);
81
+ }
82
+
83
+ function emptyCommentPaginationResult() {
84
+ return { page: [], isDone: true, continueCursor: "" };
85
+ }
23
86
 
24
87
  export const list = query({
25
88
  args: {
@@ -31,11 +94,23 @@ export const list = query({
31
94
  },
32
95
  returns: paginationResultValidator(publicCommentValidator),
33
96
  handler: async (ctx, args) => {
97
+ const entry = await ctx.db.get("entries", args.entryId);
98
+ if (entry === null || entry.deletingAt !== undefined) {
99
+ return {
100
+ ...emptyCommentPaginationResult(),
101
+ };
102
+ }
103
+
34
104
  if (args.parentCommentId !== undefined) {
35
105
  const parent = await ctx.db.get("comments", args.parentCommentId);
36
106
  if (parent === null || parent.entryId !== args.entryId) {
37
107
  throw new ConvexError("Parent comment not found on this entry.");
38
108
  }
109
+ if (!(await commentIsLive(ctx, parent))) {
110
+ return {
111
+ ...emptyCommentPaginationResult(),
112
+ };
113
+ }
39
114
  }
40
115
 
41
116
  const db = paginator(ctx.db, schema);
@@ -43,19 +118,23 @@ export const list = query({
43
118
  args.sort === "top"
44
119
  ? await db
45
120
  .query("comments")
46
- .withIndex("by_entry_parent_likes", (q) =>
121
+ .withIndex("by_entry_parent_deleting_likes", (q) =>
47
122
  q
48
123
  .eq("entryId", args.entryId)
49
- .eq("parentCommentId", args.parentCommentId),
124
+ .eq("parentCommentId", args.parentCommentId)
125
+ .eq("deletingAt", undefined)
126
+ .eq("deletedAt", undefined),
50
127
  )
51
128
  .order("desc")
52
129
  .paginate(args.paginationOpts)
53
130
  : await db
54
131
  .query("comments")
55
- .withIndex("by_entry_parent", (q) =>
132
+ .withIndex("by_entry_parent_deleting", (q) =>
56
133
  q
57
134
  .eq("entryId", args.entryId)
58
- .eq("parentCommentId", args.parentCommentId),
135
+ .eq("parentCommentId", args.parentCommentId)
136
+ .eq("deletingAt", undefined)
137
+ .eq("deletedAt", undefined),
59
138
  )
60
139
  .order(args.sort === "newest" ? "desc" : "asc")
61
140
  .paginate(args.paginationOpts);
@@ -71,6 +150,50 @@ export const list = query({
71
150
  },
72
151
  });
73
152
 
153
+ /**
154
+ * List comments created by a known actor. The actor is supplied by trusted
155
+ * component/server callers; the host-facing wrapper resolves it from the
156
+ * current request instead of accepting it from clients.
157
+ *
158
+ * This activity query resolves the current parent entry title and excludes
159
+ * comments whose entry or ancestor is pending deletion.
160
+ */
161
+ export const listByActor = query({
162
+ args: {
163
+ paginationOpts: paginationOptsValidator,
164
+ actorId: v.string(),
165
+ },
166
+ returns: paginationResultValidator(activityCommentValidator),
167
+ handler: async (ctx, args) => {
168
+ assertActorId(args.actorId);
169
+
170
+ const result = await stream(ctx.db, schema)
171
+ .query("comments")
172
+ .withIndex("by_actor", (q) => q.eq("actorId", args.actorId))
173
+ .order("desc")
174
+ .map(async (comment) => {
175
+ if (
176
+ comment.deletingAt !== undefined ||
177
+ comment.deletedAt !== undefined
178
+ ) {
179
+ return null;
180
+ }
181
+ const entry = await ctx.db.get("entries", comment.entryId);
182
+ if (
183
+ entry === null ||
184
+ entry.deletingAt !== undefined ||
185
+ !(await commentIsLive(ctx, comment))
186
+ ) {
187
+ return null;
188
+ }
189
+ return serializeActivityComment(comment, entry);
190
+ })
191
+ .paginate(args.paginationOpts);
192
+
193
+ return result;
194
+ },
195
+ });
196
+
74
197
  export const create = mutation({
75
198
  args: {
76
199
  actorId: v.string(),
@@ -79,8 +202,32 @@ export const create = mutation({
79
202
  body: v.string(),
80
203
  maxDepth: v.number(),
81
204
  maxCommentLength: v.number(),
205
+ includeCallbackContext: v.optional(v.boolean()),
82
206
  },
83
- returns: v.id("comments"),
207
+ returns: v.union(
208
+ v.object({ id: v.id("comments") }),
209
+ v.object({
210
+ id: v.id("comments"),
211
+ comment: v.object({
212
+ id: v.id("comments"),
213
+ actorId: v.string(),
214
+ entryId: v.id("entries"),
215
+ parentCommentId: v.optional(v.id("comments")),
216
+ body: v.string(),
217
+ depth: v.number(),
218
+ }),
219
+ entry: v.object({
220
+ id: v.id("entries"),
221
+ actorId: v.string(),
222
+ kind: entryKindValidator,
223
+ status: entryStatusValidator,
224
+ title: v.string(),
225
+ }),
226
+ parentComment: v.optional(
227
+ v.object({ id: v.id("comments"), actorId: v.string() }),
228
+ ),
229
+ }),
230
+ ),
84
231
  handler: async (ctx, args) => {
85
232
  assertActorId(args.actorId);
86
233
  assertPositiveInteger(args.maxDepth, "Maximum comment depth");
@@ -88,6 +235,9 @@ export const create = mutation({
88
235
 
89
236
  const entry = await ctx.db.get("entries", args.entryId);
90
237
  if (entry === null) throw new ConvexError("Entry not found.");
238
+ if (entry.deletingAt !== undefined) {
239
+ throw new ConvexError("Entry is being deleted.");
240
+ }
91
241
 
92
242
  const body = normalizeRequiredText(
93
243
  args.body,
@@ -96,11 +246,15 @@ export const create = mutation({
96
246
  );
97
247
 
98
248
  let depth = 0;
249
+ let parent: Doc<"comments"> | null = null;
99
250
  if (args.parentCommentId !== undefined) {
100
- const parent = await ctx.db.get("comments", args.parentCommentId);
251
+ parent = await ctx.db.get("comments", args.parentCommentId);
101
252
  if (parent === null || parent.entryId !== args.entryId) {
102
253
  throw new ConvexError("Parent comment not found on this entry.");
103
254
  }
255
+ if (!(await commentIsLive(ctx, parent))) {
256
+ throw new ConvexError("Comment is being deleted.");
257
+ }
104
258
  depth = parent.depth + 1;
105
259
  if (depth > args.maxDepth) {
106
260
  throw new ConvexError(
@@ -126,16 +280,39 @@ export const create = mutation({
126
280
  statusFilter: entryStatusFilterForStatus(entry.status),
127
281
  });
128
282
 
129
- if (args.parentCommentId !== undefined) {
130
- const parent = await ctx.db.get("comments", args.parentCommentId);
131
- if (parent !== null) {
132
- await ctx.db.patch("comments", args.parentCommentId, {
133
- replyCount: parent.replyCount + 1,
134
- });
135
- }
283
+ if (parent !== null) {
284
+ await ctx.db.patch("comments", parent._id, {
285
+ replyCount: parent.replyCount + 1,
286
+ });
136
287
  }
137
288
 
138
- return commentId;
289
+ if (!args.includeCallbackContext) return { id: commentId };
290
+
291
+ return {
292
+ id: commentId,
293
+ comment: {
294
+ id: commentId,
295
+ actorId: args.actorId,
296
+ entryId: args.entryId,
297
+ ...(args.parentCommentId === undefined
298
+ ? {}
299
+ : { parentCommentId: args.parentCommentId }),
300
+ body,
301
+ depth,
302
+ },
303
+ entry: {
304
+ id: entry._id,
305
+ actorId: entry.actorId,
306
+ kind: entry.kind,
307
+ status: entry.status,
308
+ title: entry.title,
309
+ },
310
+ ...(parent === null
311
+ ? {}
312
+ : {
313
+ parentComment: { id: parent._id, actorId: parent.actorId },
314
+ }),
315
+ };
139
316
  },
140
317
  });
141
318
 
@@ -152,8 +329,13 @@ export const update = mutation({
152
329
  assertActorId(args.actor.id);
153
330
  const comment = await ctx.db.get("comments", args.commentId);
154
331
  if (comment === null) throw new ConvexError("Comment not found.");
155
- if (comment.deletedAt !== undefined) {
156
- throw new ConvexError("Deleted comments cannot be edited.");
332
+ if (!(await commentIsLive(ctx, comment))) {
333
+ throw new ConvexError("Comment is being deleted.");
334
+ }
335
+ const entry = await ctx.db.get("entries", comment.entryId);
336
+ if (entry === null) throw new ConvexError("Entry not found.");
337
+ if (entry.deletingAt !== undefined) {
338
+ throw new ConvexError("Entry is being deleted.");
157
339
  }
158
340
 
159
341
  const canEdit =
@@ -184,8 +366,13 @@ export const remove = mutation({
184
366
  handler: async (ctx, args) => {
185
367
  assertActorId(args.actor.id);
186
368
  const comment = await ctx.db.get("comments", args.commentId);
187
- if (comment === null) throw new ConvexError("Comment not found.");
188
- if (comment.deletedAt !== undefined) return null;
369
+ if (comment === null) return null;
370
+
371
+ const pending = await findDeletingComment(ctx, comment);
372
+ if (pending !== null) {
373
+ await scheduleCommentCleanup(ctx, pending._id);
374
+ return null;
375
+ }
189
376
 
190
377
  const canDelete =
191
378
  actorIsAdmin(args.actor) ||
@@ -194,24 +381,171 @@ export const remove = mutation({
194
381
  throw new ConvexError("Not authorized to delete this comment.");
195
382
  }
196
383
 
197
- await ctx.db.patch("comments", args.commentId, { deletedAt: Date.now() });
384
+ await startCommentDeletion(ctx, comment);
198
385
  return null;
199
386
  },
200
387
  });
201
388
 
389
+ /**
390
+ * Remove one bounded slice of a pending comment subtree. Children are marked
391
+ * with the root's deletion token before leaves are removed, which keeps the
392
+ * whole subtree hidden immediately and lets retries resume from indexed
393
+ * state. Reactions are drained before each comment is deleted.
394
+ */
395
+ export const removeBatch = internalMutation({
396
+ args: { commentId: v.id("comments") },
397
+ returns: v.object({ processed: v.number(), hasMore: v.boolean() }),
398
+ handler: async (ctx, args) => {
399
+ const root = await ctx.db.get("comments", args.commentId);
400
+ if (root === null || root.deletingAt === undefined) {
401
+ return { processed: 0, hasMore: false };
402
+ }
403
+
404
+ const deletionToken = root.deletingAt;
405
+ const deletionRootId = root.deletionRootId ?? root._id;
406
+ if (root.deletionRootId === undefined) {
407
+ await ctx.db.patch("comments", root._id, { deletionRootId });
408
+ }
409
+ const entry = await ctx.db.get("entries", root.entryId);
410
+ const pendingComments = await ctx.db
411
+ .query("comments")
412
+ .withIndex("by_entry_deletion_root", (q) =>
413
+ q.eq("entryId", root.entryId).eq("deletionRootId", deletionRootId),
414
+ )
415
+ .take(commentDeletionBatchSize + 1);
416
+
417
+ let processed = 0;
418
+ let deletedComments = 0;
419
+
420
+ for (const comment of pendingComments.slice(0, commentDeletionBatchSize)) {
421
+ const unmarkedChildren = await ctx.db
422
+ .query("comments")
423
+ .withIndex("by_entry_parent_deleting", (q) =>
424
+ q
425
+ .eq("entryId", root.entryId)
426
+ .eq("parentCommentId", comment._id)
427
+ .eq("deletingAt", undefined),
428
+ )
429
+ .take(commentDeletionBatchSize + 1);
430
+
431
+ if (unmarkedChildren.length > 0) {
432
+ for (const child of unmarkedChildren.slice(
433
+ 0,
434
+ commentDeletionBatchSize,
435
+ )) {
436
+ await ctx.db.patch("comments", child._id, {
437
+ deletingAt: deletionToken,
438
+ deletionRootId,
439
+ });
440
+ processed += 1;
441
+ }
442
+ continue;
443
+ }
444
+
445
+ const remainingChild = await ctx.db
446
+ .query("comments")
447
+ .withIndex("by_entry_parent", (q) =>
448
+ q.eq("entryId", root.entryId).eq("parentCommentId", comment._id),
449
+ )
450
+ .first();
451
+ if (remainingChild !== null) continue;
452
+
453
+ const commentReactions = await ctx.db
454
+ .query("reactions")
455
+ .withIndex("by_comment_actor", (q) => q.eq("commentId", comment._id))
456
+ .take(reactionDeletionBatchSize + 1);
457
+ for (const reaction of commentReactions.slice(
458
+ 0,
459
+ reactionDeletionBatchSize,
460
+ )) {
461
+ await ctx.db.delete("reactions", reaction._id);
462
+ processed += 1;
463
+ }
464
+ if (commentReactions.length > reactionDeletionBatchSize) {
465
+ await scheduleCommentCleanup(ctx, root._id);
466
+ return { processed, hasMore: true };
467
+ }
468
+
469
+ await ctx.db.delete("comments", comment._id);
470
+ processed += 1;
471
+ deletedComments += 1;
472
+
473
+ if (comment.parentCommentId !== undefined) {
474
+ const parent = await ctx.db.get("comments", comment.parentCommentId);
475
+ if (parent !== null) {
476
+ await ctx.db.patch("comments", parent._id, {
477
+ replyCount: Math.max(0, parent.replyCount - 1),
478
+ });
479
+ }
480
+ }
481
+ }
482
+
483
+ if (entry !== null && deletedComments > 0) {
484
+ await ctx.db.patch("entries", entry._id, {
485
+ commentCount: Math.max(0, entry.commentCount - deletedComments),
486
+ });
487
+ }
488
+
489
+ const remainingPending = await ctx.db
490
+ .query("comments")
491
+ .withIndex("by_entry_deletion_root", (q) =>
492
+ q.eq("entryId", root.entryId).eq("deletionRootId", deletionRootId),
493
+ )
494
+ .first();
495
+ if (remainingPending !== null) {
496
+ await scheduleCommentCleanup(ctx, root._id);
497
+ return { processed, hasMore: true };
498
+ }
499
+
500
+ return { processed, hasMore: false };
501
+ },
502
+ });
503
+
202
504
  export const setLike = mutation({
203
505
  args: {
204
506
  actorId: v.string(),
205
507
  commentId: v.id("comments"),
206
508
  desiredState: v.boolean(),
509
+ includeCallbackContext: v.optional(v.boolean()),
207
510
  },
208
- returns: v.object({ active: v.boolean(), likeCount: v.number() }),
511
+ returns: v.union(
512
+ v.object({
513
+ active: v.boolean(),
514
+ likeCount: v.number(),
515
+ }),
516
+ v.object({
517
+ changed: v.literal(false),
518
+ active: v.boolean(),
519
+ transition: v.null(),
520
+ previousCount: v.number(),
521
+ count: v.number(),
522
+ }),
523
+ v.object({
524
+ changed: v.literal(true),
525
+ active: v.boolean(),
526
+ transition: v.union(v.literal("added"), v.literal("removed")),
527
+ previousCount: v.number(),
528
+ count: v.number(),
529
+ comment: v.object({
530
+ id: v.id("comments"),
531
+ actorId: v.string(),
532
+ entryId: v.id("entries"),
533
+ parentCommentId: v.optional(v.id("comments")),
534
+ body: v.string(),
535
+ }),
536
+ }),
537
+ ),
209
538
  handler: async (ctx, args) => {
210
539
  assertActorId(args.actorId);
211
540
  const comment = await ctx.db.get("comments", args.commentId);
212
541
  if (comment === null) throw new ConvexError("Comment not found.");
213
- if (comment.deletedAt !== undefined) {
214
- throw new ConvexError("Deleted comments cannot receive likes.");
542
+ const entry = await ctx.db.get("entries", comment.entryId);
543
+ if (entry === null) throw new ConvexError("Entry not found.");
544
+ if (entry.deletingAt !== undefined) {
545
+ throw new ConvexError("Entry is being deleted.");
546
+ }
547
+ if (!(await commentIsLive(ctx, comment))) {
548
+ throw new ConvexError("Comment is being deleted.");
215
549
  }
216
550
 
217
551
  const existing = await ctx.db
@@ -228,16 +562,61 @@ export const setLike = mutation({
228
562
  });
229
563
  const likeCount = comment.likeCount + 1;
230
564
  await ctx.db.patch("comments", args.commentId, { likeCount });
231
- return { active: true, likeCount };
565
+ if (!args.includeCallbackContext) {
566
+ return { active: true, likeCount };
567
+ }
568
+ return {
569
+ changed: true as const,
570
+ active: true,
571
+ transition: "added" as const,
572
+ previousCount: comment.likeCount,
573
+ count: likeCount,
574
+ comment: {
575
+ id: comment._id,
576
+ actorId: comment.actorId,
577
+ entryId: comment.entryId,
578
+ ...(comment.parentCommentId === undefined
579
+ ? {}
580
+ : { parentCommentId: comment.parentCommentId }),
581
+ body: comment.body,
582
+ },
583
+ };
232
584
  }
233
585
 
234
586
  if (!args.desiredState && existing !== null) {
235
587
  await ctx.db.delete("reactions", existing._id);
236
588
  const likeCount = Math.max(0, comment.likeCount - 1);
237
589
  await ctx.db.patch("comments", args.commentId, { likeCount });
238
- return { active: false, likeCount };
590
+ if (!args.includeCallbackContext) {
591
+ return { active: false, likeCount };
592
+ }
593
+ return {
594
+ changed: true as const,
595
+ active: false,
596
+ transition: "removed" as const,
597
+ previousCount: comment.likeCount,
598
+ count: likeCount,
599
+ comment: {
600
+ id: comment._id,
601
+ actorId: comment.actorId,
602
+ entryId: comment.entryId,
603
+ ...(comment.parentCommentId === undefined
604
+ ? {}
605
+ : { parentCommentId: comment.parentCommentId }),
606
+ body: comment.body,
607
+ },
608
+ };
239
609
  }
240
610
 
241
- return { active: args.desiredState, likeCount: comment.likeCount };
611
+ if (!args.includeCallbackContext) {
612
+ return { active: args.desiredState, likeCount: comment.likeCount };
613
+ }
614
+ return {
615
+ changed: false as const,
616
+ active: args.desiredState,
617
+ transition: null,
618
+ previousCount: comment.likeCount,
619
+ count: comment.likeCount,
620
+ };
242
621
  },
243
622
  });
@@ -13,4 +13,14 @@ crons.interval(
13
13
  {},
14
14
  );
15
15
 
16
+ // Legacy comment tombstones and orphaned feedback are drained in bounded
17
+ // scheduled batches. The interval is a low-frequency retry for installations
18
+ // that upgrade while an earlier batch is paused or interrupted.
19
+ crons.interval(
20
+ "clean up legacy comment tombstones",
21
+ { hours: 24 * 30 },
22
+ internal.migrations.cleanupLegacyComments,
23
+ {},
24
+ );
25
+
16
26
  export default crons;