convex-feedback 0.2.0-beta.1 → 0.2.0-beta.3

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.
@@ -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),
@@ -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")