convex-feedback 0.2.0-beta.0 → 0.2.0-beta.10

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 (45) hide show
  1. package/README.md +13 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +2 -0
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +2 -0
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +10 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/admin.d.ts +3 -87
  11. package/dist/component/admin.d.ts.map +1 -1
  12. package/dist/component/admin.js +47 -6
  13. package/dist/component/admin.js.map +1 -1
  14. package/dist/component/comments.d.ts +1 -14
  15. package/dist/component/comments.d.ts.map +1 -1
  16. package/dist/component/entries.d.ts +6 -85
  17. package/dist/component/entries.d.ts.map +1 -1
  18. package/dist/component/entries.js +7 -0
  19. package/dist/component/entries.js.map +1 -1
  20. package/dist/component/helpers.d.ts.map +1 -1
  21. package/dist/component/helpers.js +1 -0
  22. package/dist/component/helpers.js.map +1 -1
  23. package/dist/component/model.d.ts +170 -114
  24. package/dist/component/model.d.ts.map +1 -1
  25. package/dist/component/model.js +2 -0
  26. package/dist/component/model.js.map +1 -1
  27. package/dist/component/roadmap.d.ts +3 -39
  28. package/dist/component/roadmap.d.ts.map +1 -1
  29. package/dist/component/roadmap.js +11 -6
  30. package/dist/component/roadmap.js.map +1 -1
  31. package/dist/react/index.d.ts +12 -228
  32. package/dist/react/index.d.ts.map +1 -1
  33. package/package.json +3 -2
  34. package/src/client/api.ts +3 -0
  35. package/src/client/index.ts +2 -0
  36. package/src/component/_generated/component.ts +10 -0
  37. package/src/component/admin.ts +70 -6
  38. package/src/component/entries.ts +7 -0
  39. package/src/component/helpers.ts +2 -0
  40. package/src/component/model.ts +202 -110
  41. package/src/component/roadmap.ts +11 -6
  42. package/dist/component/tags.d.ts +0 -58
  43. package/dist/component/tags.d.ts.map +0 -1
  44. package/dist/component/tags.js +0 -216
  45. package/dist/component/tags.js.map +0 -1
@@ -62,6 +62,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
62
62
  updatedAt?: number;
63
63
  upvoteCount: number;
64
64
  viewerHasUpvoted: boolean;
65
+ viewerIsAuthor?: boolean;
65
66
  } | null,
66
67
  Name
67
68
  >;
@@ -125,6 +126,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
125
126
  updatedAt?: number;
126
127
  upvoteCount: number;
127
128
  viewerHasUpvoted: boolean;
129
+ viewerIsAuthor?: boolean;
128
130
  }>;
129
131
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
130
132
  splitCursor?: string | null;
@@ -192,6 +194,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
192
194
  updatedAt?: number;
193
195
  upvoteCount: number;
194
196
  viewerHasUpvoted: boolean;
197
+ viewerIsAuthor?: boolean;
195
198
  }>;
196
199
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
197
200
  splitCursor?: string | null;
@@ -338,6 +341,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
338
341
  updatedAt?: number;
339
342
  upvoteCount: number;
340
343
  viewerHasUpvoted: boolean;
344
+ viewerIsAuthor?: boolean;
341
345
  } | null,
342
346
  Name
343
347
  >;
@@ -390,6 +394,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
390
394
  updatedAt?: number;
391
395
  upvoteCount: number;
392
396
  viewerHasUpvoted: boolean;
397
+ viewerIsAuthor?: boolean;
393
398
  }>;
394
399
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
395
400
  splitCursor?: string | null;
@@ -435,6 +440,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
435
440
  updatedAt?: number;
436
441
  upvoteCount: number;
437
442
  viewerHasUpvoted: boolean;
443
+ viewerIsAuthor?: boolean;
438
444
  }>,
439
445
  Name
440
446
  >;
@@ -506,6 +512,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
506
512
  updatedAt?: number;
507
513
  upvoteCount: number;
508
514
  viewerHasUpvoted: boolean;
515
+ viewerIsAuthor?: boolean;
509
516
  }>;
510
517
  similar: Array<{
511
518
  actorId: string;
@@ -529,6 +536,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
529
536
  updatedAt?: number;
530
537
  upvoteCount: number;
531
538
  viewerHasUpvoted: boolean;
539
+ viewerIsAuthor?: boolean;
532
540
  }>;
533
541
  },
534
542
  Name
@@ -541,6 +549,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
541
549
  body: string;
542
550
  editableByAuthor: boolean;
543
551
  entryId: string;
552
+ kind?: "feedback" | "feature_request" | "bug_report";
544
553
  maxBodyLength: number;
545
554
  maxTitleLength: number;
546
555
  title: string;
@@ -669,6 +678,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
669
678
  updatedAt?: number;
670
679
  upvoteCount: number;
671
680
  viewerHasUpvoted: boolean;
681
+ viewerIsAuthor?: boolean;
672
682
  }>;
673
683
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
674
684
  splitCursor?: string | null;
@@ -1,9 +1,11 @@
1
+ import { stream } from "convex-helpers/server/stream";
1
2
  import {
2
3
  paginationOptsValidator,
3
4
  paginationResultValidator,
5
+ type PaginationOptions,
6
+ type PaginationResult,
4
7
  } from "convex/server";
5
8
  import { ConvexError, v } from "convex/values";
6
- import { stream } from "convex-helpers/server/stream";
7
9
 
8
10
  import type { Doc } from "./_generated/dataModel.js";
9
11
  import { query } from "./_generated/server.js";
@@ -19,6 +21,68 @@ import {
19
21
  } from "./model.js";
20
22
  import schema from "./schema.js";
21
23
 
24
+ const ADMIN_SEARCH_CURSOR_PREFIX = "convex-feedback:admin-search:";
25
+
26
+ type TakeQuery<T> = {
27
+ take(n: number): Promise<T[]>;
28
+ };
29
+
30
+ function encodeAdminSearchCursor(offset: number): string {
31
+ return `${ADMIN_SEARCH_CURSOR_PREFIX}${offset}`;
32
+ }
33
+
34
+ function decodeAdminSearchCursor(cursor: string | null | undefined): number {
35
+ if (cursor === null || cursor === undefined) return 0;
36
+ if (!cursor.startsWith(ADMIN_SEARCH_CURSOR_PREFIX)) {
37
+ throw new ConvexError("InvalidCursor: invalid admin search cursor.");
38
+ }
39
+
40
+ const offset = Number(cursor.slice(ADMIN_SEARCH_CURSOR_PREFIX.length));
41
+ if (!Number.isSafeInteger(offset) || offset < 0) {
42
+ throw new ConvexError("InvalidCursor: invalid admin search cursor.");
43
+ }
44
+ return offset;
45
+ }
46
+
47
+ function addAdminSearchCursorOffsets(left: number, right: number): number {
48
+ const result = left + right;
49
+ if (!Number.isSafeInteger(result) || result < 0) {
50
+ throw new ConvexError("InvalidCursor: admin search cursor is too large.");
51
+ }
52
+ return result;
53
+ }
54
+
55
+ async function paginateAdminSearchIndex(
56
+ query: TakeQuery<Doc<"entries">>,
57
+ paginationOpts: PaginationOptions,
58
+ ): Promise<PaginationResult<Doc<"entries">>> {
59
+ const start = decodeAdminSearchCursor(paginationOpts.cursor);
60
+ const end = decodeAdminSearchCursor(paginationOpts.endCursor);
61
+ const requestedEnd =
62
+ paginationOpts.endCursor === undefined || paginationOpts.endCursor === null
63
+ ? addAdminSearchCursorOffsets(start, paginationOpts.numItems)
64
+ : end;
65
+
66
+ if (requestedEnd < start) {
67
+ throw new ConvexError("InvalidCursor: admin search cursors are reversed.");
68
+ }
69
+
70
+ // Search-index queries cannot expose a component-safe continuation cursor.
71
+ // Read through the requested range and use the result offset as our opaque
72
+ // cursor instead.
73
+ const results = await query.take(
74
+ addAdminSearchCursorOffsets(requestedEnd, 1),
75
+ );
76
+ const page = results.slice(start, requestedEnd);
77
+ const nextOffset = addAdminSearchCursorOffsets(start, page.length);
78
+
79
+ return {
80
+ page,
81
+ isDone: results.length <= requestedEnd,
82
+ continueCursor: encodeAdminSearchCursor(nextOffset),
83
+ };
84
+ }
85
+
22
86
  function matchesFilters(
23
87
  entry: Doc<"entries">,
24
88
  kinds: EntryKind[] | undefined,
@@ -127,9 +191,8 @@ export const searchEntries = query({
127
191
  const canUseSearchIndex = kinds === undefined || kinds.length === 1;
128
192
 
129
193
  const result = canUseSearchIndex
130
- ? await ctx.db
131
- .query("entries")
132
- .withSearchIndex("search", (q) => {
194
+ ? await paginateAdminSearchIndex(
195
+ ctx.db.query("entries").withSearchIndex("search", (q) => {
133
196
  const searched = q.search("searchText", searchQuery);
134
197
  const withKind =
135
198
  kinds?.length === 1 ? searched.eq("kind", kinds[0]!) : searched;
@@ -140,8 +203,9 @@ export const searchEntries = query({
140
203
  return args.priority === undefined
141
204
  ? withStatus
142
205
  : withStatus.eq("priority", args.priority);
143
- })
144
- .paginate(args.paginationOpts)
206
+ }),
207
+ args.paginationOpts,
208
+ )
145
209
  : await (
146
210
  args.priority !== undefined
147
211
  ? stream(ctx.db, schema)
@@ -609,6 +609,7 @@ export const update = mutation({
609
609
  args: {
610
610
  actor: actorValidator,
611
611
  entryId: v.id("entries"),
612
+ kind: v.optional(entryKindValidator),
612
613
  title: v.string(),
613
614
  body: v.string(),
614
615
  editableByAuthor: v.boolean(),
@@ -625,6 +626,11 @@ export const update = mutation({
625
626
  actorIsAdmin(args.actor) ||
626
627
  (args.editableByAuthor && entry.actorId === args.actor.id);
627
628
  if (!canEdit) throw new ConvexError("Not authorized to edit this entry.");
629
+ if (args.kind !== undefined && args.kind !== entry.kind) {
630
+ if (!actorIsAdmin(args.actor)) {
631
+ throw new ConvexError("Admin access is required to change entry kind.");
632
+ }
633
+ }
628
634
 
629
635
  const title = normalizeRequiredText(
630
636
  args.title,
@@ -634,6 +640,7 @@ export const update = mutation({
634
640
  const body = normalizeRequiredText(args.body, "Body", args.maxBodyLength);
635
641
 
636
642
  await ctx.db.patch("entries", args.entryId, {
643
+ kind: args.kind ?? entry.kind,
637
644
  title,
638
645
  body,
639
646
  normalizedTitle: normalizeTitle(title),
@@ -136,6 +136,8 @@ export async function serializeEntry(
136
136
  commentCount: entry.commentCount,
137
137
  ...(entry.updatedAt === undefined ? {} : { updatedAt: entry.updatedAt }),
138
138
  viewerHasUpvoted: reaction !== null,
139
+ viewerIsAuthor:
140
+ viewerActorId !== undefined && entry.actorId === viewerActorId,
139
141
  ...(includeMetadata && entry.metadata !== undefined
140
142
  ? { metadata: entry.metadata }
141
143
  : {}),
@@ -104,6 +104,8 @@ export const publicEntryValidator = v.object({
104
104
  commentCount: v.number(),
105
105
  updatedAt: v.optional(v.number()),
106
106
  viewerHasUpvoted: v.boolean(),
107
+ /** Whether the current viewer is the actor who created this entry. */
108
+ viewerIsAuthor: v.optional(v.boolean()),
107
109
  metadata: v.optional(feedbackMetadataValidator),
108
110
  });
109
111
 
@@ -133,6 +135,14 @@ export const adminEntryValidator = publicEntryValidator.extend({
133
135
  metadata: v.optional(feedbackMetadataValidator),
134
136
  });
135
137
 
138
+ type InferredFeedbackActor = Infer<typeof actorValidator>;
139
+ type InferredRoadmapItem = Infer<typeof roadmapItemValidator>;
140
+ type InferredFeedbackMetadata = Infer<typeof feedbackMetadataValidator>;
141
+ type InferredFeedbackEntry = Infer<typeof publicEntryValidator>;
142
+ type InferredAdminFeedbackEntry = Infer<typeof adminEntryValidator>;
143
+ type InferredFeedbackComment = Infer<typeof publicCommentValidator>;
144
+ type InferredSimilarEntriesResult = Infer<typeof similarEntriesValidator>;
145
+
136
146
  /**
137
147
  * Category of feedback represented by an entry.
138
148
  *
@@ -175,7 +185,34 @@ export type EntryPriority = Infer<typeof entryPriorityValidator>;
175
185
  export type RoadmapStatus = Infer<typeof roadmapStatusValidator>;
176
186
 
177
187
  /** Admin-managed roadmap item and its current attached-feedback count. */
178
- export type RoadmapItem = Infer<typeof roadmapItemValidator>;
188
+ export interface RoadmapItem {
189
+ /** Public component document identifier. */
190
+ id: InferredRoadmapItem["id"];
191
+
192
+ /** Convex document creation timestamp in milliseconds since the Unix epoch. */
193
+ creationTime: InferredRoadmapItem["creationTime"];
194
+
195
+ /** Roadmap item title. */
196
+ title: InferredRoadmapItem["title"];
197
+
198
+ /** Optional roadmap item description. */
199
+ description?: InferredRoadmapItem["description"];
200
+
201
+ /** Current roadmap workflow stage. */
202
+ status: InferredRoadmapItem["status"];
203
+
204
+ /** Relative ordering position among roadmap items. */
205
+ position: InferredRoadmapItem["position"];
206
+
207
+ /** Millisecond timestamp at which the roadmap item was created. */
208
+ createdAt: InferredRoadmapItem["createdAt"];
209
+
210
+ /** Millisecond timestamp of the latest roadmap item update. */
211
+ updatedAt: InferredRoadmapItem["updatedAt"];
212
+
213
+ /** Number of feedback entries currently attached to the roadmap item. */
214
+ feedbackCount: InferredRoadmapItem["feedbackCount"];
215
+ }
179
216
 
180
217
  /**
181
218
  * Server-side ordering strategy for comments and replies.
@@ -192,21 +229,25 @@ export type CommentSort = Infer<typeof commentSortValidator>;
192
229
  *
193
230
  * The component does not access the host application's authentication system
194
231
  * directly. The host resolves its current user/session into this shape.
195
- *
196
- * @property id
197
- * Stable identifier for the actor. This can be a Clerk ID, Convex Auth ID,
198
- * application user ID, anonymous installation ID, or another stable
199
- * host-controlled identifier.
200
- *
201
- * @property isAdmin
202
- * Whether the actor can perform admin-only actions such as changing entry
203
- * status or modifying content they do not own. When omitted, the deprecated
204
- * `isModerator` field is used.
205
- *
206
- * @property isModerator
207
- * @deprecated Use `isAdmin`.
208
232
  */
209
- export type FeedbackActor = Infer<typeof actorValidator>;
233
+ export interface FeedbackActor {
234
+ /**
235
+ * Stable identifier for the actor. This can be a Clerk ID, Convex Auth ID,
236
+ * application user ID, anonymous installation ID, or another stable
237
+ * host-controlled identifier.
238
+ */
239
+ id: InferredFeedbackActor["id"];
240
+
241
+ /**
242
+ * Whether the actor can perform admin-only actions such as changing entry
243
+ * status or modifying content they do not own. When omitted, the deprecated
244
+ * `isModerator` field is used.
245
+ */
246
+ isAdmin?: InferredFeedbackActor["isAdmin"];
247
+
248
+ /** @deprecated Use `isAdmin`. */
249
+ isModerator?: InferredFeedbackActor["isModerator"];
250
+ }
210
251
 
211
252
  /** Scalar value accepted in entry diagnostic metadata. */
212
253
  export type FeedbackMetadataValue = Infer<
@@ -214,56 +255,111 @@ export type FeedbackMetadataValue = Infer<
214
255
  >;
215
256
 
216
257
  /** Flat metadata values grouped by their source. */
217
- export type FeedbackMetadata = Infer<typeof feedbackMetadataValidator>;
258
+ export interface FeedbackMetadata {
259
+ /** Standard diagnostic metadata collected by the component. */
260
+ standard?: InferredFeedbackMetadata["standard"];
218
261
 
219
- /**
220
- * Public representation of a feedback, feature-request, or bug-report entry.
221
- *
222
- * @property id
223
- * Public component document identifier.
224
- *
225
- * @property creationTime
226
- * Convex document creation timestamp in milliseconds since the Unix epoch.
227
- *
228
- * @property actorId
229
- * Stable identifier of the actor who created the entry.
230
- *
231
- * @property kind
232
- * Entry category.
233
- *
234
- * @property status
235
- * Current workflow status.
236
- *
237
- * @property title
238
- * User-provided entry title.
239
- *
240
- * @property body
241
- * User-provided entry description.
242
- *
243
- * @property upvoteCount
244
- * Denormalized number of actors currently upvoting this entry.
245
- *
246
- * @property commentCount
247
- * Denormalized total number of comments belonging to the entry, including
248
- * nested replies.
249
- *
250
- * @property updatedAt
251
- * Millisecond timestamp of the most recent content update. Absent when the
252
- * entry has never been edited.
253
- *
254
- * @property viewerHasUpvoted
255
- * Whether the actor associated with the current query has upvoted the entry.
256
- * `false` when no viewer actor is available.
257
- *
258
- * @property metadata
259
- * Creation-time diagnostic metadata. Present only when `getEntry` is queried
260
- * by an admin. Ordinary entry lists, searches, and non-admin reads omit
261
- * this property.
262
- */
263
- export type FeedbackEntry = Infer<typeof publicEntryValidator>;
262
+ /** Additional host-provided diagnostic metadata. */
263
+ additional?: InferredFeedbackMetadata["additional"];
264
+ }
265
+
266
+ /** Public representation of a feedback, feature-request, or bug-report entry. */
267
+ export interface FeedbackEntry {
268
+ /** Public component document identifier. */
269
+ id: InferredFeedbackEntry["id"];
270
+
271
+ /** Convex document creation timestamp in milliseconds since the Unix epoch. */
272
+ creationTime: InferredFeedbackEntry["creationTime"];
273
+
274
+ /** Stable identifier of the actor who created the entry. */
275
+ actorId: InferredFeedbackEntry["actorId"];
276
+
277
+ /** Entry category. */
278
+ kind: InferredFeedbackEntry["kind"];
279
+
280
+ /** Current workflow status. */
281
+ status: InferredFeedbackEntry["status"];
282
+
283
+ /** User-provided entry title. */
284
+ title: InferredFeedbackEntry["title"];
285
+
286
+ /** User-provided entry description. */
287
+ body: InferredFeedbackEntry["body"];
288
+
289
+ /** Denormalized number of actors currently upvoting this entry. */
290
+ upvoteCount: InferredFeedbackEntry["upvoteCount"];
291
+
292
+ /** Denormalized total number of comments belonging to the entry, including nested replies. */
293
+ commentCount: InferredFeedbackEntry["commentCount"];
294
+
295
+ /** Millisecond timestamp of the most recent content update. Absent when the entry has never been edited. */
296
+ updatedAt?: InferredFeedbackEntry["updatedAt"];
297
+
298
+ /** Whether the actor associated with the current query has upvoted the entry. `false` when no viewer actor is available. */
299
+ viewerHasUpvoted: InferredFeedbackEntry["viewerHasUpvoted"];
300
+
301
+ /**
302
+ * Whether the actor associated with the current query created the entry.
303
+ * `false` when no viewer actor is available. This is optional for compatibility
304
+ * with hosts that have not yet redeployed the updated wrapper.
305
+ */
306
+ viewerIsAuthor?: InferredFeedbackEntry["viewerIsAuthor"];
307
+
308
+ /**
309
+ * Creation-time diagnostic metadata. Present only when `getEntry` is queried
310
+ * by an admin. Ordinary entry lists, searches, and non-admin reads omit this
311
+ * property.
312
+ */
313
+ metadata?: InferredFeedbackEntry["metadata"];
314
+ }
264
315
 
265
316
  /** Feedback entry enriched with private triage metadata for admin clients. */
266
- export type AdminFeedbackEntry = Infer<typeof adminEntryValidator>;
317
+ export interface AdminFeedbackEntry {
318
+ /** Public component document identifier. */
319
+ id: InferredAdminFeedbackEntry["id"];
320
+
321
+ /** Convex document creation timestamp in milliseconds since the Unix epoch. */
322
+ creationTime: InferredAdminFeedbackEntry["creationTime"];
323
+
324
+ /** Stable identifier of the actor who created the entry. */
325
+ actorId: InferredAdminFeedbackEntry["actorId"];
326
+
327
+ /** Entry category. */
328
+ kind: InferredAdminFeedbackEntry["kind"];
329
+
330
+ /** Current workflow status. */
331
+ status: InferredAdminFeedbackEntry["status"];
332
+
333
+ /** User-provided entry title. */
334
+ title: InferredAdminFeedbackEntry["title"];
335
+
336
+ /** User-provided entry description. */
337
+ body: InferredAdminFeedbackEntry["body"];
338
+
339
+ /** Denormalized number of actors currently upvoting this entry. */
340
+ upvoteCount: InferredAdminFeedbackEntry["upvoteCount"];
341
+
342
+ /** Denormalized total number of comments belonging to the entry, including nested replies. */
343
+ commentCount: InferredAdminFeedbackEntry["commentCount"];
344
+
345
+ /** Millisecond timestamp of the most recent content update. Absent when the entry has never been edited. */
346
+ updatedAt?: InferredAdminFeedbackEntry["updatedAt"];
347
+
348
+ /** Whether the actor associated with the current query has upvoted the entry. `false` when no viewer actor is available. */
349
+ viewerHasUpvoted: InferredAdminFeedbackEntry["viewerHasUpvoted"];
350
+
351
+ /** Whether the actor associated with the current query created the entry. */
352
+ viewerIsAuthor?: InferredAdminFeedbackEntry["viewerIsAuthor"];
353
+
354
+ /** Creation-time diagnostic metadata returned to admin clients. */
355
+ metadata?: InferredAdminFeedbackEntry["metadata"];
356
+
357
+ /** Internal triage priority. */
358
+ priority?: InferredAdminFeedbackEntry["priority"];
359
+
360
+ /** Roadmap item attached to the entry, when one exists. */
361
+ roadmap?: InferredAdminFeedbackEntry["roadmap"];
362
+ }
267
363
 
268
364
  /**
269
365
  * Public representation of a comment or reply.
@@ -271,47 +367,44 @@ export type AdminFeedbackEntry = Infer<typeof adminEntryValidator>;
271
367
  * Comments are returned one level at a time. Child comments are not included
272
368
  * automatically; query them separately using the comment's `id` as
273
369
  * `parentCommentId`.
274
- *
275
- * @property id
276
- * Public component document identifier.
277
- *
278
- * @property creationTime
279
- * Convex document creation timestamp in milliseconds since the Unix epoch.
280
- *
281
- * @property entryId
282
- * Entry this comment belongs to.
283
- *
284
- * @property parentCommentId
285
- * Direct parent comment. Absent for top-level comments.
286
- *
287
- * @property actorId
288
- * Stable identifier of the actor who created the comment.
289
- *
290
- * @property depth
291
- * Zero-based nesting depth. Top-level comments have depth `0`.
292
- *
293
- * @property body
294
- * User-provided comment text. `null` when the comment was soft-deleted so
295
- * nested replies can retain their place in the conversation.
296
- *
297
- * @property likeCount
298
- * Denormalized number of actors currently liking this comment.
299
- *
300
- * @property replyCount
301
- * Number of direct child replies. Descendants below those direct children are
302
- * not included in this count.
303
- *
304
- * @property updatedAt
305
- * Millisecond timestamp of the latest edit, when the comment has been edited.
306
- *
307
- * @property deletedAt
308
- * Millisecond timestamp at which the comment was soft-deleted.
309
- *
310
- * @property viewerHasLiked
311
- * Whether the actor associated with the current query likes this comment.
312
- * `false` when no viewer actor is available.
313
370
  */
314
- export type FeedbackComment = Infer<typeof publicCommentValidator>;
371
+ export interface FeedbackComment {
372
+ /** Public component document identifier. */
373
+ id: InferredFeedbackComment["id"];
374
+
375
+ /** Convex document creation timestamp in milliseconds since the Unix epoch. */
376
+ creationTime: InferredFeedbackComment["creationTime"];
377
+
378
+ /** Entry this comment belongs to. */
379
+ entryId: InferredFeedbackComment["entryId"];
380
+
381
+ /** Direct parent comment. Absent for top-level comments. */
382
+ parentCommentId?: InferredFeedbackComment["parentCommentId"];
383
+
384
+ /** Stable identifier of the actor who created the comment. */
385
+ actorId: InferredFeedbackComment["actorId"];
386
+
387
+ /** Zero-based nesting depth. Top-level comments have depth `0`. */
388
+ depth: InferredFeedbackComment["depth"];
389
+
390
+ /** User-provided comment text. `null` when the comment was soft-deleted so nested replies can retain their place in the conversation. */
391
+ body: InferredFeedbackComment["body"];
392
+
393
+ /** Denormalized number of actors currently liking this comment. */
394
+ likeCount: InferredFeedbackComment["likeCount"];
395
+
396
+ /** Number of direct child replies. Descendants below those direct children are not included in this count. */
397
+ replyCount: InferredFeedbackComment["replyCount"];
398
+
399
+ /** Millisecond timestamp of the latest edit, when the comment has been edited. */
400
+ updatedAt?: InferredFeedbackComment["updatedAt"];
401
+
402
+ /** Millisecond timestamp at which the comment was soft-deleted. */
403
+ deletedAt?: InferredFeedbackComment["deletedAt"];
404
+
405
+ /** Whether the actor associated with the current query likes this comment. `false` when no viewer actor is available. */
406
+ viewerHasLiked: InferredFeedbackComment["viewerHasLiked"];
407
+ }
315
408
 
316
409
  /**
317
410
  * Duplicate-detection result for a proposed entry.
@@ -328,12 +421,11 @@ export type FeedbackComment = Infer<typeof publicCommentValidator>;
328
421
  * - 0 exact matches → `0 exact + at most 3 similar`
329
422
  *
330
423
  * An entry returned in `exact` is never repeated in `similar`.
331
- *
332
- * @property exact
333
- * Entries whose normalized title exactly matches the proposed title.
334
- *
335
- * @property similar
336
- * Full-text matches ordered by search relevance after exact matches have been
337
- * removed.
338
424
  */
339
- export type SimilarEntriesResult = Infer<typeof similarEntriesValidator>;
425
+ export interface SimilarEntriesResult {
426
+ /** Entries whose normalized title exactly matches the proposed title. */
427
+ exact: InferredSimilarEntriesResult["exact"];
428
+
429
+ /** Full-text matches ordered by search relevance after exact matches have been removed. */
430
+ similar: InferredSimilarEntriesResult["similar"];
431
+ }
@@ -1,3 +1,4 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
1
2
  import {
2
3
  paginationOptsValidator,
3
4
  paginationResultValidator,
@@ -21,6 +22,7 @@ import {
21
22
  roadmapStatusValidator,
22
23
  } from "./model.js";
23
24
  import type { RoadmapStatus } from "./model.js";
25
+ import schema from "./schema.js";
24
26
 
25
27
  const POSITION_STEP = 1_000_000;
26
28
  const REBALANCE_GAP_THRESHOLD = 10;
@@ -128,13 +130,14 @@ export const list = query({
128
130
  },
129
131
  returns: paginationResultValidator(roadmapItemValidator),
130
132
  handler: async (ctx, args) => {
133
+ const db = paginator(ctx.db, schema);
131
134
  const status = args.status;
132
135
  const state =
133
136
  status === undefined ? null : await getRebalanceState(ctx, status);
134
137
  const visibleGeneration = state?.visibleGeneration;
135
138
  const roadmapQuery =
136
139
  status !== undefined && visibleGeneration !== undefined
137
- ? ctx.db
140
+ ? db
138
141
  .query("roadmap")
139
142
  .withIndex("by_status_deleting_at_rebalance_id_position", (q) =>
140
143
  q
@@ -143,12 +146,12 @@ export const list = query({
143
146
  .eq("rebalanceId", visibleGeneration),
144
147
  )
145
148
  : status === undefined
146
- ? ctx.db
149
+ ? db
147
150
  .query("roadmap")
148
151
  .withIndex("by_deleting_at_and_position", (q) =>
149
152
  q.eq("deletingAt", undefined),
150
153
  )
151
- : ctx.db
154
+ : db
152
155
  .query("roadmap")
153
156
  .withIndex("by_status_deleting_at_position", (q) =>
154
157
  q.eq("status", status).eq("deletingAt", undefined),
@@ -348,11 +351,12 @@ export const rebalanceBatch = internalMutation({
348
351
  },
349
352
  returns: v.null(),
350
353
  handler: async (ctx, args) => {
354
+ const db = paginator(ctx.db, schema);
351
355
  const state = await getRebalanceState(ctx, args.status);
352
356
  if (state?.activeGeneration !== args.rebalanceId) return null;
353
357
 
354
358
  if (args.phase === "mark") {
355
- const result = await ctx.db
359
+ const result = await db
356
360
  .query("roadmap")
357
361
  .withIndex("by_status_deleting_at_position", (q) =>
358
362
  q.eq("status", args.status).eq("deletingAt", undefined),
@@ -391,7 +395,7 @@ export const rebalanceBatch = internalMutation({
391
395
  return null;
392
396
  }
393
397
 
394
- const result = await ctx.db
398
+ const result = await db
395
399
  .query("roadmap")
396
400
  .withIndex("by_status_rebalance_rank", (q) =>
397
401
  q.eq("status", args.status).eq("rebalanceId", args.rebalanceId),
@@ -610,7 +614,8 @@ export const listFeedback = query({
610
614
  },
611
615
  returns: paginationResultValidator(publicEntryValidator),
612
616
  handler: async (ctx, args) => {
613
- const result = await ctx.db
617
+ const db = paginator(ctx.db, schema);
618
+ const result = await db
614
619
  .query("entries")
615
620
  .withIndex("by_roadmap_id", (q) => q.eq("roadmapId", args.roadmapId))
616
621
  .order("desc")