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
@@ -8,15 +8,18 @@ import { ConvexError, v } from "convex/values";
8
8
 
9
9
  import { mergedStream, stream } from "convex-helpers/server/stream";
10
10
  import type { Doc } from "./_generated/dataModel.js";
11
- import { mutation, query } from "./_generated/server.js";
11
+ import { internal } from "./_generated/api.js";
12
+ import { internalMutation, mutation, query } from "./_generated/server.js";
12
13
  import {
13
14
  assertActorId,
14
15
  normalizeRequiredText,
15
16
  normalizeTitle,
17
+ serializeActivityEntry,
16
18
  serializeEntry,
17
19
  validateFeedbackMetadata,
18
20
  } from "./helpers.js";
19
21
  import {
22
+ activityEntryWithContextValidator,
20
23
  actorValidator,
21
24
  actorIsAdmin,
22
25
  entryKindValidator,
@@ -31,6 +34,7 @@ import {
31
34
  type EntryKind,
32
35
  } from "./model.js";
33
36
  import schema from "./schema.js";
37
+ import type { MutationCtx } from "./types.js";
34
38
 
35
39
  const allEntryKinds: readonly EntryKind[] = [
36
40
  "feedback",
@@ -38,6 +42,26 @@ const allEntryKinds: readonly EntryKind[] = [
38
42
  "bug_report",
39
43
  ];
40
44
 
45
+ /**
46
+ * Cleanup deliberately uses small bounded batches. A comment may have more
47
+ * reactions than this limit, so its reactions are drained over multiple
48
+ * scheduled transactions before the comment itself is removed.
49
+ */
50
+ const deletionBatchSize = 100;
51
+ const commentDeletionBatchSize = 25;
52
+ const reactionDeletionBatchSize = 100;
53
+
54
+ function entryIsLive(entry: Doc<"entries">): Promise<boolean> {
55
+ return Promise.resolve(entry.deletingAt === undefined);
56
+ }
57
+
58
+ async function scheduleEntryCleanup(
59
+ ctx: MutationCtx,
60
+ entryId: Doc<"entries">["_id"],
61
+ ): Promise<void> {
62
+ await ctx.scheduler.runAfter(0, internal.entries.removeBatch, { entryId });
63
+ }
64
+
41
65
  function normalizeKindFilter(
42
66
  kinds: readonly EntryKind[] | undefined,
43
67
  ): EntryKind[] | undefined {
@@ -95,6 +119,7 @@ export const list = query({
95
119
  q.eq("statusFilter", statusFilter),
96
120
  )
97
121
  .order("desc")
122
+ .filterWith(entryIsLive)
98
123
  .paginate(args.paginationOpts)
99
124
  : await db
100
125
  .query("entries")
@@ -102,6 +127,7 @@ export const list = query({
102
127
  q.eq("statusFilter", statusFilter),
103
128
  )
104
129
  .order("desc")
130
+ .filterWith(entryIsLive)
105
131
  .paginate(args.paginationOpts);
106
132
  } else if (kinds.length === 1) {
107
133
  const kind = kinds[0];
@@ -118,6 +144,7 @@ export const list = query({
118
144
  q.eq("kind", kind).eq("statusFilter", statusFilter),
119
145
  )
120
146
  .order("desc")
147
+ .filterWith(entryIsLive)
121
148
  .paginate(args.paginationOpts)
122
149
  : await db
123
150
  .query("entries")
@@ -125,6 +152,7 @@ export const list = query({
125
152
  q.eq("kind", kind).eq("statusFilter", statusFilter),
126
153
  )
127
154
  .order("desc")
155
+ .filterWith(entryIsLive)
128
156
  .paginate(args.paginationOpts);
129
157
  } else {
130
158
  const streams = kinds.map((kind) =>
@@ -135,12 +163,14 @@ export const list = query({
135
163
  q.eq("kind", kind).eq("statusFilter", statusFilter),
136
164
  )
137
165
  .order("desc")
166
+ .filterWith(entryIsLive)
138
167
  : stream(ctx.db, schema)
139
168
  .query("entries")
140
169
  .withIndex("by_kind_status_filter", (q) =>
141
170
  q.eq("kind", kind).eq("statusFilter", statusFilter),
142
171
  )
143
- .order("desc"),
172
+ .order("desc")
173
+ .filterWith(entryIsLive),
144
174
  );
145
175
 
146
176
  result = await mergedStream(
@@ -158,21 +188,25 @@ export const list = query({
158
188
  .query("entries")
159
189
  .withIndex("by_upvotes")
160
190
  .order("desc")
191
+ .filterWith(entryIsLive)
161
192
  .paginate(args.paginationOpts)
162
193
  : await db
163
194
  .query("entries")
164
195
  .withIndex("by_status_upvotes", (q) => q.eq("status", status))
165
196
  .order("desc")
197
+ .filterWith(entryIsLive)
166
198
  .paginate(args.paginationOpts)
167
199
  : status === undefined
168
200
  ? await db
169
201
  .query("entries")
170
202
  .order("desc")
203
+ .filterWith(entryIsLive)
171
204
  .paginate(args.paginationOpts)
172
205
  : await db
173
206
  .query("entries")
174
207
  .withIndex("by_status", (q) => q.eq("status", status))
175
208
  .order("desc")
209
+ .filterWith(entryIsLive)
176
210
  .paginate(args.paginationOpts);
177
211
  } else if (kinds.length === 1) {
178
212
  const kind = kinds[0];
@@ -188,6 +222,7 @@ export const list = query({
188
222
  .query("entries")
189
223
  .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
190
224
  .order("desc")
225
+ .filterWith(entryIsLive)
191
226
  .paginate(args.paginationOpts)
192
227
  : await db
193
228
  .query("entries")
@@ -195,12 +230,14 @@ export const list = query({
195
230
  q.eq("kind", kind).eq("status", status),
196
231
  )
197
232
  .order("desc")
233
+ .filterWith(entryIsLive)
198
234
  .paginate(args.paginationOpts)
199
235
  : status === undefined
200
236
  ? await db
201
237
  .query("entries")
202
238
  .withIndex("by_kind", (q) => q.eq("kind", kind))
203
239
  .order("desc")
240
+ .filterWith(entryIsLive)
204
241
  .paginate(args.paginationOpts)
205
242
  : await db
206
243
  .query("entries")
@@ -208,6 +245,7 @@ export const list = query({
208
245
  q.eq("kind", kind).eq("status", status),
209
246
  )
210
247
  .order("desc")
248
+ .filterWith(entryIsLive)
211
249
  .paginate(args.paginationOpts);
212
250
  } else if (args.sort === "top") {
213
251
  if (status === undefined) {
@@ -215,7 +253,8 @@ export const list = query({
215
253
  stream(ctx.db, schema)
216
254
  .query("entries")
217
255
  .withIndex("by_kind_upvotes", (q) => q.eq("kind", kind))
218
- .order("desc"),
256
+ .order("desc")
257
+ .filterWith(entryIsLive),
219
258
  );
220
259
 
221
260
  result = await mergedStream(streams, [
@@ -229,7 +268,8 @@ export const list = query({
229
268
  .withIndex("by_kind_status_upvotes", (q) =>
230
269
  q.eq("kind", kind).eq("status", status),
231
270
  )
232
- .order("desc"),
271
+ .order("desc")
272
+ .filterWith(entryIsLive),
233
273
  );
234
274
 
235
275
  result = await mergedStream(streams, [
@@ -242,7 +282,8 @@ export const list = query({
242
282
  stream(ctx.db, schema)
243
283
  .query("entries")
244
284
  .withIndex("by_kind", (q) => q.eq("kind", kind))
245
- .order("desc"),
285
+ .order("desc")
286
+ .filterWith(entryIsLive),
246
287
  );
247
288
 
248
289
  result = await mergedStream(streams, ["_creationTime"]).paginate(
@@ -255,7 +296,8 @@ export const list = query({
255
296
  .withIndex("by_kind_status", (q) =>
256
297
  q.eq("kind", kind).eq("status", status),
257
298
  )
258
- .order("desc"),
299
+ .order("desc")
300
+ .filterWith(entryIsLive),
259
301
  );
260
302
 
261
303
  result = await mergedStream(streams, ["_creationTime"]).paginate(
@@ -274,6 +316,43 @@ export const list = query({
274
316
  },
275
317
  });
276
318
 
319
+ /**
320
+ * List entries created by a known actor. This component-level query remains
321
+ * actor-parameterized so trusted host/server consumers can use it without a
322
+ * request authentication context.
323
+ *
324
+ * `includeAdminContext` is intentionally unavailable on the host-facing
325
+ * `listUserEntries` wrapper. Trusted server consumers such as exports may opt
326
+ * into retained metadata and triage context when needed.
327
+ */
328
+ export const listByActor = query({
329
+ args: {
330
+ paginationOpts: paginationOptsValidator,
331
+ actorId: v.string(),
332
+ includeAdminContext: v.optional(v.boolean()),
333
+ },
334
+ returns: paginationResultValidator(activityEntryWithContextValidator),
335
+ handler: async (ctx, args) => {
336
+ assertActorId(args.actorId);
337
+
338
+ const result = await stream(ctx.db, schema)
339
+ .query("entries")
340
+ .withIndex("by_actor", (q) => q.eq("actorId", args.actorId))
341
+ .order("desc")
342
+ .filterWith(entryIsLive)
343
+ .paginate(args.paginationOpts);
344
+
345
+ return {
346
+ ...result,
347
+ page: await Promise.all(
348
+ result.page.map((entry) =>
349
+ serializeActivityEntry(ctx, entry, args.includeAdminContext === true),
350
+ ),
351
+ ),
352
+ };
353
+ },
354
+ });
355
+
277
356
  export const get = query({
278
357
  args: {
279
358
  entryId: v.id("entries"),
@@ -283,7 +362,7 @@ export const get = query({
283
362
  returns: v.union(publicEntryValidator, v.null()),
284
363
  handler: async (ctx, args) => {
285
364
  const entry = await ctx.db.get("entries", args.entryId);
286
- return entry === null
365
+ return entry === null || entry.deletingAt !== undefined
287
366
  ? null
288
367
  : serializeEntry(
289
368
  ctx,
@@ -329,7 +408,8 @@ export const search = query({
329
408
  .withSearchIndex("search", (q) =>
330
409
  q
331
410
  .search("searchText", searchQuery)
332
- .eq("statusFilter", statusFilter),
411
+ .eq("statusFilter", statusFilter)
412
+ .eq("deletingAt", undefined),
333
413
  )
334
414
  .take(args.limit);
335
415
  } else if (kinds.length === 1) {
@@ -345,7 +425,8 @@ export const search = query({
345
425
  q
346
426
  .search("searchText", searchQuery)
347
427
  .eq("kind", kind)
348
- .eq("statusFilter", statusFilter),
428
+ .eq("statusFilter", statusFilter)
429
+ .eq("deletingAt", undefined),
349
430
  )
350
431
  .take(args.limit);
351
432
  } else {
@@ -361,7 +442,8 @@ export const search = query({
361
442
  .withSearchIndex("search", (q) =>
362
443
  q
363
444
  .search("searchText", searchQuery)
364
- .eq("statusFilter", statusFilter),
445
+ .eq("statusFilter", statusFilter)
446
+ .eq("deletingAt", undefined),
365
447
  )
366
448
  // eslint-disable-next-line @convex-dev/no-filter-in-query
367
449
  .filter((q) =>
@@ -378,13 +460,16 @@ export const search = query({
378
460
  ? await ctx.db
379
461
  .query("entries")
380
462
  .withSearchIndex("search", (q) =>
381
- q.search("searchText", searchQuery),
463
+ q.search("searchText", searchQuery).eq("deletingAt", undefined),
382
464
  )
383
465
  .take(args.limit)
384
466
  : await ctx.db
385
467
  .query("entries")
386
468
  .withSearchIndex("search", (q) =>
387
- q.search("searchText", searchQuery).eq("status", status),
469
+ q
470
+ .search("searchText", searchQuery)
471
+ .eq("status", status)
472
+ .eq("deletingAt", undefined),
388
473
  )
389
474
  .take(args.limit);
390
475
  } else if (kinds.length === 1) {
@@ -399,7 +484,10 @@ export const search = query({
399
484
  ? await ctx.db
400
485
  .query("entries")
401
486
  .withSearchIndex("search", (q) =>
402
- q.search("searchText", searchQuery).eq("kind", kind),
487
+ q
488
+ .search("searchText", searchQuery)
489
+ .eq("kind", kind)
490
+ .eq("deletingAt", undefined),
403
491
  )
404
492
  .take(args.limit)
405
493
  : await ctx.db
@@ -408,7 +496,8 @@ export const search = query({
408
496
  q
409
497
  .search("searchText", searchQuery)
410
498
  .eq("kind", kind)
411
- .eq("status", status),
499
+ .eq("status", status)
500
+ .eq("deletingAt", undefined),
412
501
  )
413
502
  .take(args.limit);
414
503
  } else {
@@ -424,12 +513,15 @@ export const search = query({
424
513
  ? ctx.db
425
514
  .query("entries")
426
515
  .withSearchIndex("search", (q) =>
427
- q.search("searchText", searchQuery),
516
+ q.search("searchText", searchQuery).eq("deletingAt", undefined),
428
517
  )
429
518
  : ctx.db
430
519
  .query("entries")
431
520
  .withSearchIndex("search", (q) =>
432
- q.search("searchText", searchQuery).eq("status", status),
521
+ q
522
+ .search("searchText", searchQuery)
523
+ .eq("status", status)
524
+ .eq("deletingAt", undefined),
433
525
  );
434
526
 
435
527
  entries = await searchResults
@@ -444,7 +536,9 @@ export const search = query({
444
536
  }
445
537
 
446
538
  return await Promise.all(
447
- entries.map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
539
+ entries
540
+ .filter((entry) => entry.deletingAt === undefined)
541
+ .map((entry) => serializeEntry(ctx, entry, args.viewerActorId)),
448
542
  );
449
543
  },
450
544
  });
@@ -489,12 +583,18 @@ export const similar = query({
489
583
  .withIndex("by_normalized_title", (q) =>
490
584
  q.eq("normalizedTitle", normalizedTitle),
491
585
  )
586
+ // The deletion flag is not part of this exact-match index.
587
+ // Filter before take so pending entries cannot consume a slot.
588
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
589
+ .filter((q) => q.eq(q.field("deletingAt"), undefined))
492
590
  .take(args.limit)
493
591
  : await ctx.db
494
592
  .query("entries")
495
593
  .withIndex("by_kind_normalized_title", (q) =>
496
594
  q.eq("kind", kind).eq("normalizedTitle", normalizedTitle),
497
595
  )
596
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
597
+ .filter((q) => q.eq(q.field("deletingAt"), undefined))
498
598
  .take(args.limit);
499
599
 
500
600
  const exact = await Promise.all(
@@ -530,13 +630,16 @@ export const similar = query({
530
630
  ? await ctx.db
531
631
  .query("entries")
532
632
  .withSearchIndex("search", (q) =>
533
- q.search("searchText", searchText),
633
+ q.search("searchText", searchText).eq("deletingAt", undefined),
534
634
  )
535
635
  .take(candidateLimit)
536
636
  : await ctx.db
537
637
  .query("entries")
538
638
  .withSearchIndex("search", (q) =>
539
- q.search("searchText", searchText).eq("kind", kind),
639
+ q
640
+ .search("searchText", searchText)
641
+ .eq("kind", kind)
642
+ .eq("deletingAt", undefined),
540
643
  )
541
644
  .take(candidateLimit);
542
645
 
@@ -566,8 +669,25 @@ export const create = mutation({
566
669
  maxTitleLength: v.number(),
567
670
  maxBodyLength: v.number(),
568
671
  metadata: v.optional(feedbackMetadataValidator),
672
+ includeCallbackContext: v.optional(v.boolean()),
569
673
  },
570
- returns: v.id("entries"),
674
+ returns: v.union(
675
+ v.object({ id: v.id("entries") }),
676
+ v.object({
677
+ id: v.id("entries"),
678
+ entry: v.object({
679
+ id: v.id("entries"),
680
+ actorId: v.string(),
681
+ kind: entryKindValidator,
682
+ status: entryStatusValidator,
683
+ title: v.string(),
684
+ body: v.string(),
685
+ metadata: v.optional(feedbackMetadataValidator),
686
+ upvoteCount: v.number(),
687
+ commentCount: v.number(),
688
+ }),
689
+ }),
690
+ ),
571
691
  handler: async (ctx, args) => {
572
692
  assertActorId(args.actorId);
573
693
  if (!args.enabledKinds.includes(args.kind)) {
@@ -601,7 +721,22 @@ export const create = mutation({
601
721
  entryId: entry,
602
722
  });
603
723
 
604
- return entry;
724
+ if (!args.includeCallbackContext) return { id: entry };
725
+
726
+ return {
727
+ id: entry,
728
+ entry: {
729
+ id: entry,
730
+ actorId: args.actorId,
731
+ kind: args.kind,
732
+ status: args.defaultStatus,
733
+ title,
734
+ body,
735
+ ...(args.metadata === undefined ? {} : { metadata: args.metadata }),
736
+ upvoteCount: 1,
737
+ commentCount: 0,
738
+ },
739
+ };
605
740
  },
606
741
  });
607
742
 
@@ -621,6 +756,9 @@ export const update = mutation({
621
756
  assertActorId(args.actor.id);
622
757
  const entry = await ctx.db.get("entries", args.entryId);
623
758
  if (entry === null) throw new ConvexError("Entry not found.");
759
+ if (entry.deletingAt !== undefined) {
760
+ throw new ConvexError("Entry is being deleted.");
761
+ }
624
762
 
625
763
  const canEdit =
626
764
  actorIsAdmin(args.actor) ||
@@ -652,6 +790,150 @@ export const update = mutation({
652
790
  },
653
791
  });
654
792
 
793
+ export const remove = mutation({
794
+ args: {
795
+ actor: actorValidator,
796
+ entryId: v.id("entries"),
797
+ },
798
+ returns: v.null(),
799
+ handler: async (ctx, args) => {
800
+ assertActorId(args.actor.id);
801
+ if (!actorIsAdmin(args.actor)) {
802
+ throw new ConvexError("Admin access is required to delete an entry.");
803
+ }
804
+
805
+ const entry = await ctx.db.get("entries", args.entryId);
806
+ if (entry === null) return null;
807
+
808
+ // Mark and detach in one transaction so every committed read can
809
+ // hide/reject the entry before scheduled cleanup removes dependents.
810
+ // Repeated requests are safe: they only enqueue another idempotent batch.
811
+ if (entry.deletingAt !== undefined) {
812
+ await scheduleEntryCleanup(ctx, args.entryId);
813
+ return null;
814
+ }
815
+
816
+ const now = Date.now();
817
+ if (entry.roadmapId !== undefined) {
818
+ const roadmap = await ctx.db.get("roadmap", entry.roadmapId);
819
+ if (roadmap !== null) {
820
+ await ctx.db.patch("roadmap", roadmap._id, {
821
+ feedbackCount: Math.max(0, roadmap.feedbackCount - 1),
822
+ updatedAt: now,
823
+ });
824
+ }
825
+ }
826
+
827
+ await ctx.db.patch("entries", args.entryId, {
828
+ roadmapId: undefined,
829
+ deletingAt: now,
830
+ updatedAt: now,
831
+ });
832
+ await scheduleEntryCleanup(ctx, args.entryId);
833
+ return null;
834
+ },
835
+ });
836
+
837
+ /**
838
+ * Remove one bounded slice of an entry's dependent documents. Direct entry
839
+ * reactions are drained first, then comments are processed in index order.
840
+ * Each comment's reactions are drained before the comment is deleted, which
841
+ * keeps this safe for comments with arbitrarily many likes.
842
+ */
843
+ export const removeBatch = internalMutation({
844
+ args: { entryId: v.id("entries") },
845
+ returns: v.object({ processed: v.number(), hasMore: v.boolean() }),
846
+ handler: async (ctx, args) => {
847
+ const entry = await ctx.db.get("entries", args.entryId);
848
+ if (entry === null || entry.deletingAt === undefined) {
849
+ return { processed: 0, hasMore: false };
850
+ }
851
+
852
+ const directReactions = await ctx.db
853
+ .query("reactions")
854
+ .withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId))
855
+ .take(deletionBatchSize);
856
+ for (const reaction of directReactions) {
857
+ await ctx.db.delete("reactions", reaction._id);
858
+ }
859
+ if (directReactions.length === deletionBatchSize) {
860
+ await scheduleEntryCleanup(ctx, args.entryId);
861
+ return { processed: directReactions.length, hasMore: true };
862
+ }
863
+
864
+ const comments = await ctx.db
865
+ .query("comments")
866
+ .withIndex("by_entry_parent", (q) => q.eq("entryId", args.entryId))
867
+ .take(commentDeletionBatchSize);
868
+ let processed = directReactions.length;
869
+
870
+ for (const comment of comments) {
871
+ const commentReactions = await ctx.db
872
+ .query("reactions")
873
+ .withIndex("by_comment_actor", (q) => q.eq("commentId", comment._id))
874
+ .take(reactionDeletionBatchSize + 1);
875
+
876
+ const reactionsToDelete = commentReactions.slice(
877
+ 0,
878
+ reactionDeletionBatchSize,
879
+ );
880
+ for (const reaction of reactionsToDelete) {
881
+ await ctx.db.delete("reactions", reaction._id);
882
+ processed += 1;
883
+ }
884
+
885
+ // Leave this comment in place until its complete reaction set has been
886
+ // drained. It will be the first item in the next indexed batch.
887
+ if (commentReactions.length > reactionDeletionBatchSize) {
888
+ await scheduleEntryCleanup(ctx, args.entryId);
889
+ return { processed, hasMore: true };
890
+ }
891
+
892
+ await ctx.db.delete("comments", comment._id);
893
+ processed += 1;
894
+ }
895
+
896
+ if (comments.length === commentDeletionBatchSize) {
897
+ await scheduleEntryCleanup(ctx, args.entryId);
898
+ return { processed, hasMore: true };
899
+ }
900
+
901
+ await ctx.scheduler.runAfter(0, internal.entries.finalizeRemoval, {
902
+ entryId: args.entryId,
903
+ });
904
+ return { processed, hasMore: false };
905
+ },
906
+ });
907
+
908
+ /** Hard-delete only after a fresh bounded dependency check. */
909
+ export const finalizeRemoval = internalMutation({
910
+ args: { entryId: v.id("entries") },
911
+ returns: v.null(),
912
+ handler: async (ctx, args) => {
913
+ const entry = await ctx.db.get("entries", args.entryId);
914
+ if (entry === null || entry.deletingAt === undefined) return null;
915
+
916
+ const [directReaction, comment] = await Promise.all([
917
+ ctx.db
918
+ .query("reactions")
919
+ .withIndex("by_entry_actor", (q) => q.eq("entryId", args.entryId))
920
+ .first(),
921
+ ctx.db
922
+ .query("comments")
923
+ .withIndex("by_entry_parent", (q) => q.eq("entryId", args.entryId))
924
+ .first(),
925
+ ]);
926
+
927
+ if (directReaction !== null || comment !== null) {
928
+ await scheduleEntryCleanup(ctx, args.entryId);
929
+ return null;
930
+ }
931
+
932
+ await ctx.db.delete("entries", args.entryId);
933
+ return null;
934
+ },
935
+ });
936
+
655
937
  export const setStatus = mutation({
656
938
  args: {
657
939
  actor: actorValidator,
@@ -663,9 +945,13 @@ export const setStatus = mutation({
663
945
  if (!actorIsAdmin(args.actor)) {
664
946
  throw new ConvexError("Admin access is required to change status.");
665
947
  }
666
- if ((await ctx.db.get("entries", args.entryId)) === null) {
948
+ const entry = await ctx.db.get("entries", args.entryId);
949
+ if (entry === null) {
667
950
  throw new ConvexError("Entry not found.");
668
951
  }
952
+ if (entry.deletingAt !== undefined) {
953
+ throw new ConvexError("Entry is being deleted.");
954
+ }
669
955
  await ctx.db.patch("entries", args.entryId, {
670
956
  status: args.status,
671
957
  statusFilter: entryStatusFilterForStatus(args.status),
@@ -686,9 +972,13 @@ export const setPriority = mutation({
686
972
  if (!actorIsAdmin(args.actor)) {
687
973
  throw new ConvexError("Admin access is required to change priority.");
688
974
  }
689
- if ((await ctx.db.get("entries", args.entryId)) === null) {
975
+ const entry = await ctx.db.get("entries", args.entryId);
976
+ if (entry === null) {
690
977
  throw new ConvexError("Entry not found.");
691
978
  }
979
+ if (entry.deletingAt !== undefined) {
980
+ throw new ConvexError("Entry is being deleted.");
981
+ }
692
982
  await ctx.db.patch("entries", args.entryId, {
693
983
  priority: args.priority === null ? undefined : args.priority,
694
984
  updatedAt: Date.now(),
@@ -702,12 +992,42 @@ export const setUpvote = mutation({
702
992
  actorId: v.string(),
703
993
  entryId: v.id("entries"),
704
994
  desiredState: v.boolean(),
995
+ includeCallbackContext: v.optional(v.boolean()),
705
996
  },
706
- returns: v.object({ active: v.boolean(), upvoteCount: v.number() }),
997
+ returns: v.union(
998
+ v.object({
999
+ active: v.boolean(),
1000
+ upvoteCount: v.number(),
1001
+ }),
1002
+ v.object({
1003
+ changed: v.literal(false),
1004
+ active: v.boolean(),
1005
+ transition: v.null(),
1006
+ previousCount: v.number(),
1007
+ count: v.number(),
1008
+ }),
1009
+ v.object({
1010
+ changed: v.literal(true),
1011
+ active: v.boolean(),
1012
+ transition: v.union(v.literal("added"), v.literal("removed")),
1013
+ previousCount: v.number(),
1014
+ count: v.number(),
1015
+ entry: v.object({
1016
+ id: v.id("entries"),
1017
+ actorId: v.string(),
1018
+ kind: entryKindValidator,
1019
+ status: entryStatusValidator,
1020
+ title: v.string(),
1021
+ }),
1022
+ }),
1023
+ ),
707
1024
  handler: async (ctx, args) => {
708
1025
  assertActorId(args.actorId);
709
1026
  const entry = await ctx.db.get("entries", args.entryId);
710
1027
  if (entry === null) throw new ConvexError("Entry not found.");
1028
+ if (entry.deletingAt !== undefined) {
1029
+ throw new ConvexError("Entry is being deleted.");
1030
+ }
711
1031
 
712
1032
  const existing = await ctx.db
713
1033
  .query("reactions")
@@ -726,7 +1046,23 @@ export const setUpvote = mutation({
726
1046
  upvoteCount,
727
1047
  statusFilter: entryStatusFilterForStatus(entry.status),
728
1048
  });
729
- return { active: true, upvoteCount };
1049
+ if (!args.includeCallbackContext) {
1050
+ return { active: true, upvoteCount };
1051
+ }
1052
+ return {
1053
+ changed: true as const,
1054
+ active: true,
1055
+ transition: "added" as const,
1056
+ previousCount: entry.upvoteCount,
1057
+ count: upvoteCount,
1058
+ entry: {
1059
+ id: entry._id,
1060
+ actorId: entry.actorId,
1061
+ kind: entry.kind,
1062
+ status: entry.status,
1063
+ title: entry.title,
1064
+ },
1065
+ };
730
1066
  }
731
1067
 
732
1068
  if (!args.desiredState && existing !== null) {
@@ -736,9 +1072,34 @@ export const setUpvote = mutation({
736
1072
  upvoteCount,
737
1073
  statusFilter: entryStatusFilterForStatus(entry.status),
738
1074
  });
739
- return { active: false, upvoteCount };
1075
+ if (!args.includeCallbackContext) {
1076
+ return { active: false, upvoteCount };
1077
+ }
1078
+ return {
1079
+ changed: true as const,
1080
+ active: false,
1081
+ transition: "removed" as const,
1082
+ previousCount: entry.upvoteCount,
1083
+ count: upvoteCount,
1084
+ entry: {
1085
+ id: entry._id,
1086
+ actorId: entry.actorId,
1087
+ kind: entry.kind,
1088
+ status: entry.status,
1089
+ title: entry.title,
1090
+ },
1091
+ };
740
1092
  }
741
1093
 
742
- return { active: args.desiredState, upvoteCount: entry.upvoteCount };
1094
+ if (!args.includeCallbackContext) {
1095
+ return { active: args.desiredState, upvoteCount: entry.upvoteCount };
1096
+ }
1097
+ return {
1098
+ changed: false as const,
1099
+ active: args.desiredState,
1100
+ transition: null,
1101
+ previousCount: entry.upvoteCount,
1102
+ count: entry.upvoteCount,
1103
+ };
743
1104
  },
744
1105
  });