convex-feedback 0.2.0-beta.9 → 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,8 +4,11 @@ import type { DataModel } from "./_generated/dataModel.js";
4
4
  import type { QueryCtx } from "./types.js";
5
5
  import type {
6
6
  AdminFeedbackEntry,
7
+ FeedbackActivityComment,
8
+ FeedbackActivityEntryWithContext,
7
9
  FeedbackComment,
8
10
  FeedbackEntry,
11
+ FeedbackReaction,
9
12
  RoadmapItem,
10
13
  } from "./model.js";
11
14
 
@@ -108,6 +111,35 @@ export function assertActorId(actorId: string): void {
108
111
  }
109
112
  }
110
113
 
114
+ /**
115
+ * Find the nearest pending deletion in a comment's ancestor chain. The
116
+ * parent chain is bounded by the comment depth configured by the host, and is
117
+ * walked one document at a time so deletion reads stay indexed and bounded.
118
+ */
119
+ export async function findDeletingComment(
120
+ ctx: QueryCtx,
121
+ comment: DataModel["comments"]["document"],
122
+ ): Promise<DataModel["comments"]["document"] | null> {
123
+ let current: DataModel["comments"]["document"] | null = comment;
124
+ let remaining = Math.max(0, comment.depth) + 1;
125
+ while (current !== null && remaining > 0) {
126
+ if (current.deletingAt !== undefined || current.deletedAt !== undefined) {
127
+ return current;
128
+ }
129
+ if (current.parentCommentId === undefined) return null;
130
+ remaining -= 1;
131
+ current = await ctx.db.get("comments", current.parentCommentId);
132
+ }
133
+ return null;
134
+ }
135
+
136
+ export async function commentIsLive(
137
+ ctx: QueryCtx,
138
+ comment: DataModel["comments"]["document"],
139
+ ): Promise<boolean> {
140
+ return (await findDeletingComment(ctx, comment)) === null;
141
+ }
142
+
111
143
  export async function serializeEntry(
112
144
  ctx: QueryCtx,
113
145
  entry: DataModel["entries"]["document"],
@@ -206,15 +238,142 @@ export async function serializeComment(
206
238
  : { parentCommentId: comment.parentCommentId }),
207
239
  actorId: comment.actorId,
208
240
  depth: comment.depth,
209
- body: comment.deletedAt === undefined ? comment.body : null,
241
+ body: comment.body,
210
242
  likeCount: comment.likeCount,
211
243
  replyCount: comment.replyCount,
212
244
  ...(comment.updatedAt === undefined
213
245
  ? {}
214
246
  : { updatedAt: comment.updatedAt }),
215
- ...(comment.deletedAt === undefined
216
- ? {}
217
- : { deletedAt: comment.deletedAt }),
218
247
  viewerHasLiked: reaction !== null,
219
248
  };
220
249
  }
250
+
251
+ /**
252
+ * Serialize an actor-scoped entry. Private diagnostic and triage context is
253
+ * available only to trusted component consumers that explicitly request it.
254
+ */
255
+ export async function serializeActivityEntry(
256
+ ctx: QueryCtx,
257
+ entry: DataModel["entries"]["document"],
258
+ includeContext: boolean,
259
+ ): Promise<FeedbackActivityEntryWithContext> {
260
+ const roadmap =
261
+ includeContext && entry.roadmapId !== undefined
262
+ ? await ctx.db.get("roadmap", entry.roadmapId)
263
+ : null;
264
+
265
+ return {
266
+ id: entry._id,
267
+ creationTime: entry._creationTime,
268
+ actorId: entry.actorId,
269
+ kind: entry.kind,
270
+ status: entry.status,
271
+ title: entry.title,
272
+ body: entry.body,
273
+ upvoteCount: entry.upvoteCount,
274
+ commentCount: entry.commentCount,
275
+ ...(entry.updatedAt === undefined ? {} : { updatedAt: entry.updatedAt }),
276
+ ...(includeContext && entry.metadata === undefined
277
+ ? {}
278
+ : includeContext
279
+ ? { metadata: entry.metadata }
280
+ : {}),
281
+ ...(includeContext && entry.priority === undefined
282
+ ? {}
283
+ : includeContext
284
+ ? { priority: entry.priority }
285
+ : {}),
286
+ ...(roadmap === null || !includeContext
287
+ ? {}
288
+ : { roadmap: serializeRoadmapItem(roadmap) }),
289
+ };
290
+ }
291
+
292
+ /** Remove component-only diagnostic and triage context before host exposure. */
293
+ export function stripActivityEntryContext(
294
+ entry: FeedbackActivityEntryWithContext,
295
+ ): Omit<FeedbackActivityEntryWithContext, "metadata" | "priority" | "roadmap"> {
296
+ return {
297
+ id: entry.id,
298
+ creationTime: entry.creationTime,
299
+ actorId: entry.actorId,
300
+ kind: entry.kind,
301
+ status: entry.status,
302
+ title: entry.title,
303
+ body: entry.body,
304
+ upvoteCount: entry.upvoteCount,
305
+ commentCount: entry.commentCount,
306
+ ...(entry.updatedAt === undefined ? {} : { updatedAt: entry.updatedAt }),
307
+ };
308
+ }
309
+
310
+ /** Serialize an actor-scoped comment using its already-loaded entry. */
311
+ export function serializeActivityComment(
312
+ comment: DataModel["comments"]["document"],
313
+ entry: DataModel["entries"]["document"],
314
+ ): FeedbackActivityComment {
315
+ return {
316
+ id: comment._id,
317
+ creationTime: comment._creationTime,
318
+ entryId: comment.entryId,
319
+ entryTitle: entry.title,
320
+ ...(comment.parentCommentId === undefined
321
+ ? {}
322
+ : { parentCommentId: comment.parentCommentId }),
323
+ actorId: comment.actorId,
324
+ depth: comment.depth,
325
+ body: comment.body,
326
+ likeCount: comment.likeCount,
327
+ replyCount: comment.replyCount,
328
+ ...(comment.updatedAt === undefined
329
+ ? {}
330
+ : { updatedAt: comment.updatedAt }),
331
+ };
332
+ }
333
+
334
+ /**
335
+ * A reaction target loaded by the activity stream before serialization.
336
+ * Loading the target in the stream mapper and passing it here prevents a
337
+ * second read of the same entry or comment.
338
+ */
339
+ export type ActivityReactionTarget =
340
+ | { type: "entry"; entry: DataModel["entries"]["document"] }
341
+ | {
342
+ type: "comment";
343
+ comment: DataModel["comments"]["document"];
344
+ entry: DataModel["entries"]["document"];
345
+ };
346
+
347
+ /** Serialize a reaction using its already-loaded target context. */
348
+ export function serializeActivityReaction(
349
+ reaction: DataModel["reactions"]["document"],
350
+ target: ActivityReactionTarget,
351
+ ): FeedbackReaction {
352
+ if (target.type === "entry") {
353
+ const { entry } = target;
354
+ return {
355
+ type: "entry_upvote",
356
+ id: reaction._id,
357
+ creationTime: reaction._creationTime,
358
+ entry: {
359
+ id: entry._id,
360
+ title: entry.title,
361
+ kind: entry.kind,
362
+ status: entry.status,
363
+ },
364
+ };
365
+ }
366
+
367
+ const { comment, entry } = target;
368
+ return {
369
+ type: "comment_like",
370
+ id: reaction._id,
371
+ creationTime: reaction._creationTime,
372
+ comment: {
373
+ id: comment._id,
374
+ body: comment.body,
375
+ entryId: comment.entryId,
376
+ entryTitle: entry.title,
377
+ },
378
+ };
379
+ }
@@ -2,9 +2,105 @@ import { v } from "convex/values";
2
2
 
3
3
  import { internal } from "./_generated/api.js";
4
4
  import { internalMutation } from "./_generated/server.js";
5
+ import type { Doc } from "./_generated/dataModel.js";
6
+ import { startCommentDeletion } from "./comments.js";
5
7
  import { entryStatusFilterForStatus } from "./model.js";
8
+ import type { MutationCtx } from "./types.js";
6
9
 
7
10
  const statusFilterMigrationBatchSize = 100;
11
+ const legacyCommentMigrationBatchSize = 25;
12
+ const legacyReactionMigrationBatchSize = 100;
13
+
14
+ type LegacyCommentMigrationPhase = "tombstones" | "comments" | "reactions";
15
+
16
+ async function scheduleLegacyCommentMigration(
17
+ ctx: MutationCtx,
18
+ phase: LegacyCommentMigrationPhase,
19
+ ): Promise<void> {
20
+ await ctx.scheduler.runAfter(0, internal.migrations.cleanupLegacyComments, {
21
+ phase,
22
+ });
23
+ }
24
+
25
+ type CommentInspection =
26
+ | { kind: "valid" }
27
+ | { kind: "orphan" }
28
+ | { kind: "pending"; root: Doc<"comments"> };
29
+
30
+ /**
31
+ * Validate a comment's parent chain without loading the same document twice.
32
+ * A missing parent or an entry mismatch makes the whole subtree legacy
33
+ * orphan data; a pending ancestor points the migration at the existing
34
+ * bounded subtree cleaner.
35
+ */
36
+ async function inspectCommentChain(
37
+ ctx: MutationCtx,
38
+ comment: Doc<"comments">,
39
+ cache: Map<string, Doc<"comments"> | null>,
40
+ ): Promise<CommentInspection> {
41
+ let current = comment;
42
+ let remaining = Math.max(0, comment.depth) + 1;
43
+ while (remaining > 0) {
44
+ if (current.deletingAt !== undefined) {
45
+ return { kind: "pending", root: current };
46
+ }
47
+ if (current.deletedAt !== undefined) {
48
+ return { kind: "pending", root: current };
49
+ }
50
+ if (current.parentCommentId === undefined) {
51
+ return { kind: "valid" };
52
+ }
53
+
54
+ remaining -= 1;
55
+ const parentId = current.parentCommentId;
56
+ let parent = cache.get(parentId);
57
+ if (parent === undefined) {
58
+ parent = await ctx.db.get("comments", parentId);
59
+ cache.set(parentId, parent);
60
+ }
61
+ if (parent === null || parent.entryId !== comment.entryId) {
62
+ return { kind: "orphan" };
63
+ }
64
+ current = parent;
65
+ }
66
+
67
+ // A malformed cycle or an invalid depth is safer to remove than to leave
68
+ // permanently unreachable feedback data behind.
69
+ return { kind: "orphan" };
70
+ }
71
+
72
+ async function scheduleEntryCleanupOnce(
73
+ ctx: MutationCtx,
74
+ entryId: Doc<"entries">["_id"],
75
+ scheduled: Set<string>,
76
+ ): Promise<void> {
77
+ const key = String(entryId);
78
+ if (scheduled.has(key)) return;
79
+ scheduled.add(key);
80
+ await ctx.scheduler.runAfter(0, internal.entries.removeBatch, { entryId });
81
+ }
82
+
83
+ async function deleteLegacyReaction(
84
+ ctx: MutationCtx,
85
+ reaction: Doc<"reactions">,
86
+ comment: Doc<"comments"> | null,
87
+ entry: Doc<"entries"> | null,
88
+ ): Promise<void> {
89
+ await ctx.db.delete("reactions", reaction._id);
90
+
91
+ // Keep a surviving comment's denormalized count accurate when a malformed
92
+ // legacy reaction points at a missing entry but still names a live comment.
93
+ if (
94
+ comment !== null &&
95
+ entry !== null &&
96
+ comment.deletingAt === undefined &&
97
+ comment.deletedAt === undefined
98
+ ) {
99
+ await ctx.db.patch("comments", comment._id, {
100
+ likeCount: Math.max(0, comment.likeCount - 1),
101
+ });
102
+ }
103
+ }
8
104
 
9
105
  /**
10
106
  * Backfills one bounded batch of legacy entries and schedules the next batch.
@@ -42,3 +138,322 @@ export const backfillStatusFilter = internalMutation({
42
138
  return { updated: entries.length, hasMore };
43
139
  },
44
140
  });
141
+
142
+ /**
143
+ * Drain comments written by versions that used soft deletion. The migration
144
+ * is deliberately split into three bounded passes:
145
+ *
146
+ * 1. indexed legacy tombstones are marked for the current subtree cleaner;
147
+ * 2. an indexed comment sweep marks broken parent/entry references;
148
+ * 3. an indexed reaction sweep removes any remaining orphan reactions.
149
+ *
150
+ * Each pass schedules its continuation after committing the current batch.
151
+ * Re-running any phase is safe because migration markers and document deletes
152
+ * are idempotent, while the normal comment cleaner remains the single place
153
+ * that updates counters and removes descendants.
154
+ */
155
+ export const cleanupLegacyComments = internalMutation({
156
+ args: {
157
+ phase: v.optional(
158
+ v.union(
159
+ v.literal("tombstones"),
160
+ v.literal("comments"),
161
+ v.literal("reactions"),
162
+ ),
163
+ ),
164
+ },
165
+ returns: v.object({
166
+ phase: v.union(
167
+ v.literal("tombstones"),
168
+ v.literal("comments"),
169
+ v.literal("reactions"),
170
+ ),
171
+ processed: v.number(),
172
+ hasMore: v.boolean(),
173
+ }),
174
+ handler: async (ctx, args) => {
175
+ const phase = args.phase ?? "tombstones";
176
+
177
+ if (phase === "tombstones") {
178
+ const tombstones = await ctx.db
179
+ .query("comments")
180
+ .withIndex("by_deleted_at", (q) => q.gte("deletedAt", 0))
181
+ .take(legacyCommentMigrationBatchSize);
182
+
183
+ const scheduledRoots = new Set<string>();
184
+ for (const comment of tombstones) {
185
+ const rootId = comment.deletionRootId ?? comment._id;
186
+ const root =
187
+ rootId === comment._id
188
+ ? comment
189
+ : await ctx.db.get("comments", rootId);
190
+ const deletionRoot = root ?? comment;
191
+ const key = String(deletionRoot._id);
192
+ if (scheduledRoots.has(key)) continue;
193
+ scheduledRoots.add(key);
194
+ await startCommentDeletion(
195
+ ctx,
196
+ deletionRoot,
197
+ deletionRoot.deletedAt ?? deletionRoot.deletingAt ?? Date.now(),
198
+ );
199
+ }
200
+
201
+ if (tombstones.length > 0) {
202
+ // Tombstone documents remain indexed until their scheduled cleaner
203
+ // finishes. Retry the same indexed pass so a short migration run also
204
+ // waits for those cleaners before moving to orphan discovery.
205
+ await scheduleLegacyCommentMigration(ctx, "tombstones");
206
+ return {
207
+ phase: "tombstones" as const,
208
+ processed: tombstones.length,
209
+ hasMore: true,
210
+ };
211
+ }
212
+
213
+ const pendingComments = await ctx.db
214
+ .query("comments")
215
+ .withIndex("by_legacy_cleanup", (q) =>
216
+ q.eq("legacyCleanupAt", undefined),
217
+ )
218
+ .take(1);
219
+ if (pendingComments.length > 0) {
220
+ await scheduleLegacyCommentMigration(ctx, "comments");
221
+ return { phase: "comments" as const, processed: 0, hasMore: true };
222
+ }
223
+
224
+ const pendingReactions = await ctx.db
225
+ .query("reactions")
226
+ .withIndex("by_legacy_cleanup", (q) =>
227
+ q.eq("legacyCleanupAt", undefined),
228
+ )
229
+ .take(1);
230
+ if (pendingReactions.length > 0) {
231
+ await scheduleLegacyCommentMigration(ctx, "reactions");
232
+ return { phase: "reactions" as const, processed: 0, hasMore: true };
233
+ }
234
+
235
+ return { phase: "reactions" as const, processed: 0, hasMore: false };
236
+ }
237
+
238
+ if (phase === "comments") {
239
+ const page = await ctx.db
240
+ .query("comments")
241
+ .withIndex("by_legacy_cleanup", (q) =>
242
+ q.eq("legacyCleanupAt", undefined),
243
+ )
244
+ .take(legacyCommentMigrationBatchSize);
245
+
246
+ const commentCache = new Map<string, Doc<"comments"> | null>();
247
+ for (const comment of page) {
248
+ commentCache.set(String(comment._id), comment);
249
+ }
250
+ const entryCache = new Map<string, Doc<"entries"> | null>();
251
+ const scheduledRoots = new Set<string>();
252
+ const scheduledEntries = new Set<string>();
253
+
254
+ const markRoot = async (
255
+ comment: Doc<"comments">,
256
+ deletionToken?: number,
257
+ ): Promise<void> => {
258
+ const rootId = comment.deletionRootId ?? comment._id;
259
+ const root =
260
+ rootId === comment._id
261
+ ? comment
262
+ : await ctx.db.get("comments", rootId);
263
+ const deletionRoot = root ?? comment;
264
+ const key = String(deletionRoot._id);
265
+ if (scheduledRoots.has(key)) return;
266
+ scheduledRoots.add(key);
267
+ await startCommentDeletion(
268
+ ctx,
269
+ deletionRoot,
270
+ deletionToken ??
271
+ deletionRoot.deletedAt ??
272
+ deletionRoot.deletingAt ??
273
+ Date.now(),
274
+ );
275
+ };
276
+
277
+ const getEntry = async (
278
+ entryId: Doc<"entries">["_id"],
279
+ ): Promise<Doc<"entries"> | null> => {
280
+ const key = String(entryId);
281
+ const cached = entryCache.get(key);
282
+ if (cached !== undefined) return cached;
283
+ const entry = await ctx.db.get("entries", entryId);
284
+ entryCache.set(key, entry);
285
+ return entry;
286
+ };
287
+
288
+ for (const comment of page) {
289
+ if (
290
+ comment.deletedAt !== undefined ||
291
+ comment.deletingAt !== undefined
292
+ ) {
293
+ await markRoot(comment, comment.deletedAt ?? comment.deletingAt);
294
+ continue;
295
+ }
296
+
297
+ const entry = await getEntry(comment.entryId);
298
+ if (entry === null) {
299
+ await markRoot(comment);
300
+ continue;
301
+ }
302
+ if (entry.deletingAt !== undefined) {
303
+ await scheduleEntryCleanupOnce(ctx, entry._id, scheduledEntries);
304
+ continue;
305
+ }
306
+
307
+ const inspection = await inspectCommentChain(
308
+ ctx,
309
+ comment,
310
+ commentCache,
311
+ );
312
+ if (inspection.kind === "orphan") {
313
+ await markRoot(comment);
314
+ } else if (inspection.kind === "pending") {
315
+ await markRoot(inspection.root);
316
+ } else {
317
+ await ctx.db.patch("comments", comment._id, {
318
+ legacyCleanupAt: Date.now(),
319
+ });
320
+ }
321
+ }
322
+
323
+ if (page.length === 0) {
324
+ await scheduleLegacyCommentMigration(ctx, "reactions");
325
+ } else {
326
+ await scheduleLegacyCommentMigration(ctx, "comments");
327
+ }
328
+
329
+ const nextPhase: "comments" | "reactions" =
330
+ page.length === 0 ? "reactions" : "comments";
331
+ return {
332
+ phase: nextPhase,
333
+ processed: page.length,
334
+ hasMore: true,
335
+ };
336
+ }
337
+
338
+ const page = await ctx.db
339
+ .query("reactions")
340
+ .withIndex("by_legacy_cleanup", (q) => q.eq("legacyCleanupAt", undefined))
341
+ .take(legacyReactionMigrationBatchSize);
342
+ const commentCache = new Map<string, Doc<"comments"> | null>();
343
+ const entryCache = new Map<string, Doc<"entries"> | null>();
344
+ const scheduledRoots = new Set<string>();
345
+ const scheduledEntries = new Set<string>();
346
+
347
+ const getComment = async (
348
+ commentId: Doc<"comments">["_id"],
349
+ ): Promise<Doc<"comments"> | null> => {
350
+ const key = String(commentId);
351
+ const cached = commentCache.get(key);
352
+ if (cached !== undefined) return cached;
353
+ const comment = await ctx.db.get("comments", commentId);
354
+ commentCache.set(key, comment);
355
+ return comment;
356
+ };
357
+ const getEntry = async (
358
+ entryId: Doc<"entries">["_id"],
359
+ ): Promise<Doc<"entries"> | null> => {
360
+ const key = String(entryId);
361
+ const cached = entryCache.get(key);
362
+ if (cached !== undefined) return cached;
363
+ const entry = await ctx.db.get("entries", entryId);
364
+ entryCache.set(key, entry);
365
+ return entry;
366
+ };
367
+ const markRoot = async (comment: Doc<"comments">): Promise<void> => {
368
+ const rootId = comment.deletionRootId ?? comment._id;
369
+ const root =
370
+ rootId === comment._id ? comment : await ctx.db.get("comments", rootId);
371
+ const deletionRoot = root ?? comment;
372
+ const key = String(deletionRoot._id);
373
+ if (scheduledRoots.has(key)) return;
374
+ scheduledRoots.add(key);
375
+ await startCommentDeletion(
376
+ ctx,
377
+ deletionRoot,
378
+ deletionRoot.deletedAt ?? deletionRoot.deletingAt ?? Date.now(),
379
+ );
380
+ };
381
+
382
+ for (const reaction of page) {
383
+ let comment: Doc<"comments"> | null = null;
384
+ let owningEntry: Doc<"entries"> | null = null;
385
+ let removeReaction = false;
386
+
387
+ if (reaction.commentId !== undefined) {
388
+ comment = await getComment(reaction.commentId);
389
+ if (comment === null) {
390
+ removeReaction = true;
391
+ } else {
392
+ owningEntry = await getEntry(comment.entryId);
393
+ if (owningEntry === null) {
394
+ removeReaction = true;
395
+ await markRoot(comment);
396
+ } else if (owningEntry.deletingAt !== undefined) {
397
+ removeReaction = true;
398
+ await scheduleEntryCleanupOnce(
399
+ ctx,
400
+ owningEntry._id,
401
+ scheduledEntries,
402
+ );
403
+ } else {
404
+ const inspection = await inspectCommentChain(
405
+ ctx,
406
+ comment,
407
+ commentCache,
408
+ );
409
+ if (inspection.kind === "orphan") {
410
+ removeReaction = true;
411
+ await markRoot(comment);
412
+ } else if (inspection.kind === "pending") {
413
+ removeReaction = true;
414
+ await markRoot(inspection.root);
415
+ }
416
+ }
417
+ }
418
+
419
+ if (reaction.entryId !== undefined) {
420
+ const reactionEntry = await getEntry(reaction.entryId);
421
+ if (
422
+ reactionEntry === null ||
423
+ (owningEntry !== null && reactionEntry._id !== owningEntry._id)
424
+ ) {
425
+ removeReaction = true;
426
+ }
427
+ }
428
+ } else if (reaction.entryId !== undefined) {
429
+ const entry = await getEntry(reaction.entryId);
430
+ if (entry === null) {
431
+ removeReaction = true;
432
+ } else if (entry.deletingAt !== undefined) {
433
+ removeReaction = true;
434
+ await scheduleEntryCleanupOnce(ctx, entry._id, scheduledEntries);
435
+ }
436
+ } else {
437
+ removeReaction = true;
438
+ }
439
+
440
+ if (removeReaction) {
441
+ await deleteLegacyReaction(ctx, reaction, comment, owningEntry);
442
+ } else {
443
+ await ctx.db.patch("reactions", reaction._id, {
444
+ legacyCleanupAt: Date.now(),
445
+ });
446
+ }
447
+ }
448
+
449
+ if (page.length > 0) {
450
+ await scheduleLegacyCommentMigration(ctx, "reactions");
451
+ }
452
+
453
+ return {
454
+ phase: "reactions" as const,
455
+ processed: page.length,
456
+ hasMore: page.length > 0,
457
+ };
458
+ },
459
+ });