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
package/src/component/helpers.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|