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.
- package/README.md +398 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +49 -9
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/config.d.ts +1 -3
- package/dist/client/config.d.ts.map +1 -1
- package/dist/client/config.js.map +1 -1
- package/dist/client/index.d.ts +707 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +243 -12
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +222 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/admin.d.ts +5 -90
- package/dist/component/admin.d.ts.map +1 -1
- package/dist/component/admin.js +59 -29
- package/dist/component/admin.js.map +1 -1
- package/dist/component/comments.d.ts +107 -15
- package/dist/component/comments.d.ts.map +1 -1
- package/dist/component/comments.js +342 -28
- package/dist/component/comments.js.map +1 -1
- package/dist/component/crons.d.ts.map +1 -1
- package/dist/component/crons.js +4 -0
- package/dist/component/crons.js.map +1 -1
- package/dist/component/entries.d.ts +119 -91
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +348 -29
- package/dist/component/entries.js.map +1 -1
- package/dist/component/helpers.d.ts +32 -1
- package/dist/component/helpers.d.ts.map +1 -1
- package/dist/component/helpers.js +121 -4
- package/dist/component/helpers.js.map +1 -1
- package/dist/component/migrations.d.ts +24 -0
- package/dist/component/migrations.d.ts.map +1 -1
- package/dist/component/migrations.js +318 -0
- package/dist/component/migrations.js.map +1 -1
- package/dist/component/model.d.ts +455 -120
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +70 -2
- package/dist/component/model.js.map +1 -1
- package/dist/component/reactions.d.ts +37 -0
- package/dist/component/reactions.d.ts.map +1 -0
- package/dist/component/reactions.js +52 -0
- package/dist/component/reactions.js.map +1 -0
- package/dist/component/roadmap.d.ts +10 -46
- package/dist/component/roadmap.d.ts.map +1 -1
- package/dist/component/roadmap.js +239 -21
- package/dist/component/roadmap.js.map +1 -1
- package/dist/component/schema.d.ts +33 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +40 -4
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +26 -238
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +65 -2
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +23 -5
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/README.md +6 -1
- package/src/client/api.ts +89 -7
- package/src/client/config.ts +1 -3
- package/src/client/index.ts +1237 -34
- package/src/component/README.md +32 -4
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +290 -8
- package/src/component/admin.ts +88 -30
- package/src/component/comments.ts +405 -26
- package/src/component/crons.ts +10 -0
- package/src/component/entries.ts +388 -27
- package/src/component/helpers.ts +163 -4
- package/src/component/migrations.ts +415 -0
- package/src/component/model.ts +381 -117
- package/src/component/reactions.ts +60 -0
- package/src/component/roadmap.ts +357 -35
- package/src/component/schema.ts +40 -4
- package/src/react/README.md +5 -0
- 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 {
|
|
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("
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 (
|
|
156
|
-
throw new ConvexError("
|
|
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)
|
|
188
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/component/crons.ts
CHANGED
|
@@ -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;
|