convex-feedback 0.1.4 → 0.1.5

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 (56) hide show
  1. package/README.md +17 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +5 -1
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/index.d.ts +1 -1
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +9 -1
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/component/_generated/api.d.ts +4 -0
  10. package/dist/component/_generated/api.d.ts.map +1 -1
  11. package/dist/component/_generated/api.js.map +1 -1
  12. package/dist/component/_generated/component.d.ts +2 -0
  13. package/dist/component/_generated/component.d.ts.map +1 -1
  14. package/dist/component/comments.d.ts +2 -2
  15. package/dist/component/comments.d.ts.map +1 -1
  16. package/dist/component/comments.js +2 -1
  17. package/dist/component/comments.js.map +1 -1
  18. package/dist/component/crons.d.ts +3 -0
  19. package/dist/component/crons.d.ts.map +1 -0
  20. package/dist/component/crons.js +8 -0
  21. package/dist/component/crons.js.map +1 -0
  22. package/dist/component/entries.d.ts +4 -2
  23. package/dist/component/entries.d.ts.map +1 -1
  24. package/dist/component/entries.js +110 -7
  25. package/dist/component/entries.js.map +1 -1
  26. package/dist/component/migrations.d.ts +11 -0
  27. package/dist/component/migrations.d.ts.map +1 -0
  28. package/dist/component/migrations.js +35 -0
  29. package/dist/component/migrations.js.map +1 -0
  30. package/dist/component/model.d.ts +4 -0
  31. package/dist/component/model.d.ts.map +1 -1
  32. package/dist/component/model.js +4 -0
  33. package/dist/component/model.js.map +1 -1
  34. package/dist/component/schema.d.ts +8 -2
  35. package/dist/component/schema.d.ts.map +1 -1
  36. package/dist/component/schema.js +11 -2
  37. package/dist/component/schema.js.map +1 -1
  38. package/dist/react/index.d.ts +5 -1
  39. package/dist/react/index.d.ts.map +1 -1
  40. package/dist/react/index.js +6 -0
  41. package/dist/react/index.js.map +1 -1
  42. package/dist/test.d.ts +8 -2
  43. package/dist/test.d.ts.map +1 -1
  44. package/package.json +6 -2
  45. package/src/client/api.ts +7 -0
  46. package/src/client/index.ts +10 -0
  47. package/src/component/README.md +2 -0
  48. package/src/component/_generated/api.ts +4 -0
  49. package/src/component/_generated/component.ts +2 -0
  50. package/src/component/comments.ts +2 -0
  51. package/src/component/crons.ts +16 -0
  52. package/src/component/entries.ts +146 -6
  53. package/src/component/migrations.ts +44 -0
  54. package/src/component/model.ts +14 -0
  55. package/src/component/schema.ts +11 -1
  56. package/src/react/index.ts +13 -0
@@ -20,6 +20,8 @@ import {
20
20
  actorValidator,
21
21
  entryKindValidator,
22
22
  entrySortValidator,
23
+ entryStatusFilterForStatus,
24
+ entryStatusFilterValidator,
23
25
  entryStatusValidator,
24
26
  feedbackMetadataValidator,
25
27
  publicEntryValidator,
@@ -63,6 +65,7 @@ export const list = query({
63
65
  paginationOpts: paginationOptsValidator,
64
66
  kinds: v.optional(v.array(entryKindValidator)),
65
67
  status: v.optional(entryStatusValidator),
68
+ statusFilter: v.optional(entryStatusFilterValidator),
66
69
  sort: entrySortValidator,
67
70
  viewerActorId: v.optional(v.string()),
68
71
  },
@@ -70,11 +73,82 @@ export const list = query({
70
73
  handler: async (ctx, args) => {
71
74
  const db = paginator(ctx.db, schema);
72
75
  const kinds = normalizeKindFilter(args.kinds);
73
- const { status } = args;
76
+ const { status, statusFilter } = args;
77
+
78
+ if (status !== undefined && statusFilter !== undefined) {
79
+ throw new ConvexError(
80
+ "`status` and `statusFilter` cannot be used together.",
81
+ );
82
+ }
74
83
 
75
84
  let result: PaginationResult<Doc<"entries">>;
76
85
 
77
- if (kinds === undefined) {
86
+ if (statusFilter !== undefined) {
87
+ if (kinds === undefined) {
88
+ result =
89
+ args.sort === "top"
90
+ ? await db
91
+ .query("entries")
92
+ .withIndex("by_status_filter_upvotes", (q) =>
93
+ q.eq("statusFilter", statusFilter),
94
+ )
95
+ .order("desc")
96
+ .paginate(args.paginationOpts)
97
+ : await db
98
+ .query("entries")
99
+ .withIndex("by_status_filter", (q) =>
100
+ q.eq("statusFilter", statusFilter),
101
+ )
102
+ .order("desc")
103
+ .paginate(args.paginationOpts);
104
+ } else if (kinds.length === 1) {
105
+ const kind = kinds[0];
106
+
107
+ if (kind === undefined) {
108
+ throw new ConvexError("Invalid kind filter.");
109
+ }
110
+
111
+ result =
112
+ args.sort === "top"
113
+ ? await db
114
+ .query("entries")
115
+ .withIndex("by_kind_status_filter_upvotes", (q) =>
116
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
117
+ )
118
+ .order("desc")
119
+ .paginate(args.paginationOpts)
120
+ : await db
121
+ .query("entries")
122
+ .withIndex("by_kind_status_filter", (q) =>
123
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
124
+ )
125
+ .order("desc")
126
+ .paginate(args.paginationOpts);
127
+ } else {
128
+ const streams = kinds.map((kind) =>
129
+ args.sort === "top"
130
+ ? stream(ctx.db, schema)
131
+ .query("entries")
132
+ .withIndex("by_kind_status_filter_upvotes", (q) =>
133
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
134
+ )
135
+ .order("desc")
136
+ : stream(ctx.db, schema)
137
+ .query("entries")
138
+ .withIndex("by_kind_status_filter", (q) =>
139
+ q.eq("kind", kind).eq("statusFilter", statusFilter),
140
+ )
141
+ .order("desc"),
142
+ );
143
+
144
+ result = await mergedStream(
145
+ streams,
146
+ args.sort === "top"
147
+ ? ["upvoteCount", "_creationTime"]
148
+ : ["_creationTime"],
149
+ ).paginate(args.paginationOpts);
150
+ }
151
+ } else if (kinds === undefined) {
78
152
  result =
79
153
  args.sort === "top"
80
154
  ? status === undefined
@@ -223,6 +297,7 @@ export const search = query({
223
297
  searchQuery: v.string(),
224
298
  kinds: v.optional(v.array(entryKindValidator)),
225
299
  status: v.optional(entryStatusValidator),
300
+ statusFilter: v.optional(entryStatusFilterValidator),
226
301
  limit: v.number(),
227
302
  viewerActorId: v.optional(v.string()),
228
303
  },
@@ -235,11 +310,67 @@ export const search = query({
235
310
  }
236
311
 
237
312
  const kinds = normalizeKindFilter(args.kinds);
238
- const { status } = args;
313
+ const { status, statusFilter } = args;
314
+
315
+ if (status !== undefined && statusFilter !== undefined) {
316
+ throw new ConvexError(
317
+ "`status` and `statusFilter` cannot be used together.",
318
+ );
319
+ }
239
320
 
240
321
  let entries: Doc<"entries">[];
241
322
 
242
- if (kinds === undefined) {
323
+ if (statusFilter !== undefined) {
324
+ if (kinds === undefined) {
325
+ entries = await ctx.db
326
+ .query("entries")
327
+ .withSearchIndex("search", (q) =>
328
+ q
329
+ .search("searchText", searchQuery)
330
+ .eq("statusFilter", statusFilter),
331
+ )
332
+ .take(args.limit);
333
+ } else if (kinds.length === 1) {
334
+ const kind = kinds[0];
335
+
336
+ if (kind === undefined) {
337
+ throw new ConvexError("Invalid kind filter.");
338
+ }
339
+
340
+ entries = await ctx.db
341
+ .query("entries")
342
+ .withSearchIndex("search", (q) =>
343
+ q
344
+ .search("searchText", searchQuery)
345
+ .eq("kind", kind)
346
+ .eq("statusFilter", statusFilter),
347
+ )
348
+ .take(args.limit);
349
+ } else {
350
+ const firstKind = kinds[0];
351
+ const secondKind = kinds[1];
352
+
353
+ if (firstKind === undefined || secondKind === undefined) {
354
+ throw new ConvexError("Invalid kind filter.");
355
+ }
356
+
357
+ entries = await ctx.db
358
+ .query("entries")
359
+ .withSearchIndex("search", (q) =>
360
+ q
361
+ .search("searchText", searchQuery)
362
+ .eq("statusFilter", statusFilter),
363
+ )
364
+ // eslint-disable-next-line @convex-dev/no-filter-in-query
365
+ .filter((q) =>
366
+ q.or(
367
+ q.eq(q.field("kind"), firstKind),
368
+ q.eq(q.field("kind"), secondKind),
369
+ ),
370
+ )
371
+ .take(args.limit);
372
+ }
373
+ } else if (kinds === undefined) {
243
374
  entries =
244
375
  status === undefined
245
376
  ? await ctx.db
@@ -453,6 +584,7 @@ export const create = mutation({
453
584
  actorId: args.actorId,
454
585
  kind: args.kind,
455
586
  status: args.defaultStatus,
587
+ statusFilter: entryStatusFilterForStatus(args.defaultStatus),
456
588
  title,
457
589
  body,
458
590
  normalizedTitle: normalizeTitle(title),
@@ -504,6 +636,7 @@ export const update = mutation({
504
636
  body,
505
637
  normalizedTitle: normalizeTitle(title),
506
638
  searchText: `${title}\n${body}`,
639
+ statusFilter: entryStatusFilterForStatus(entry.status),
507
640
  updatedAt: Date.now(),
508
641
  });
509
642
  return null;
@@ -526,6 +659,7 @@ export const setStatus = mutation({
526
659
  }
527
660
  await ctx.db.patch("entries", args.entryId, {
528
661
  status: args.status,
662
+ statusFilter: entryStatusFilterForStatus(args.status),
529
663
  updatedAt: Date.now(),
530
664
  });
531
665
  return null;
@@ -557,14 +691,20 @@ export const setUpvote = mutation({
557
691
  entryId: args.entryId,
558
692
  });
559
693
  const upvoteCount = entry.upvoteCount + 1;
560
- await ctx.db.patch("entries", args.entryId, { upvoteCount });
694
+ await ctx.db.patch("entries", args.entryId, {
695
+ upvoteCount,
696
+ statusFilter: entryStatusFilterForStatus(entry.status),
697
+ });
561
698
  return { active: true, upvoteCount };
562
699
  }
563
700
 
564
701
  if (!args.desiredState && existing !== null) {
565
702
  await ctx.db.delete("reactions", existing._id);
566
703
  const upvoteCount = Math.max(0, entry.upvoteCount - 1);
567
- await ctx.db.patch("entries", args.entryId, { upvoteCount });
704
+ await ctx.db.patch("entries", args.entryId, {
705
+ upvoteCount,
706
+ statusFilter: entryStatusFilterForStatus(entry.status),
707
+ });
568
708
  return { active: false, upvoteCount };
569
709
  }
570
710
 
@@ -0,0 +1,44 @@
1
+ import { v } from "convex/values";
2
+
3
+ import { internal } from "./_generated/api.js";
4
+ import { internalMutation } from "./_generated/server.js";
5
+ import { entryStatusFilterForStatus } from "./model.js";
6
+
7
+ const statusFilterMigrationBatchSize = 100;
8
+
9
+ /**
10
+ * Backfills one bounded batch of legacy entries and schedules the next batch.
11
+ *
12
+ * The indexed undefined lookup and deterministic status mapping make this
13
+ * safe to run concurrently, retry, or invoke again after completion.
14
+ */
15
+ export const backfillStatusFilter = internalMutation({
16
+ args: {},
17
+ returns: v.object({
18
+ updated: v.number(),
19
+ hasMore: v.boolean(),
20
+ }),
21
+ handler: async (ctx) => {
22
+ const entries = await ctx.db
23
+ .query("entries")
24
+ .withIndex("by_status_filter", (q) => q.eq("statusFilter", undefined))
25
+ .take(statusFilterMigrationBatchSize);
26
+
27
+ for (const entry of entries) {
28
+ await ctx.db.patch("entries", entry._id, {
29
+ statusFilter: entryStatusFilterForStatus(entry.status),
30
+ });
31
+ }
32
+
33
+ const hasMore = entries.length === statusFilterMigrationBatchSize;
34
+ if (hasMore) {
35
+ await ctx.scheduler.runAfter(
36
+ 0,
37
+ internal.migrations.backfillStatusFilter,
38
+ {},
39
+ );
40
+ }
41
+
42
+ return { updated: entries.length, hasMore };
43
+ },
44
+ });
@@ -16,6 +16,11 @@ export const entryStatusValidator = v.union(
16
16
  v.literal("closed"),
17
17
  );
18
18
 
19
+ export const entryStatusFilterValidator = v.union(
20
+ v.literal("open"),
21
+ v.literal("closed"),
22
+ );
23
+
19
24
  export const entrySortValidator = v.union(
20
25
  v.literal("top"),
21
26
  v.literal("newest"),
@@ -100,6 +105,15 @@ export type EntryKind = Infer<typeof entryKindValidator>;
100
105
  */
101
106
  export type EntryStatus = Infer<typeof entryStatusValidator>;
102
107
 
108
+ /** Coarse status bucket used by the indexed board and search filters. */
109
+ export type EntryStatusFilter = Infer<typeof entryStatusFilterValidator>;
110
+
111
+ export function entryStatusFilterForStatus(
112
+ status: EntryStatus,
113
+ ): EntryStatusFilter {
114
+ return status === "closed" ? "closed" : "open";
115
+ }
116
+
103
117
  /**
104
118
  * Server-side ordering strategy for feedback entries.
105
119
  *
@@ -3,6 +3,7 @@ import { v } from "convex/values";
3
3
 
4
4
  import {
5
5
  entryKindValidator,
6
+ entryStatusFilterValidator,
6
7
  entryStatusValidator,
7
8
  feedbackMetadataValidator,
8
9
  } from "./model.js";
@@ -12,6 +13,7 @@ const schema = defineSchema({
12
13
  actorId: v.string(),
13
14
  kind: entryKindValidator,
14
15
  status: entryStatusValidator,
16
+ statusFilter: v.optional(entryStatusFilterValidator),
15
17
  title: v.string(),
16
18
  body: v.string(),
17
19
  normalizedTitle: v.string(),
@@ -24,15 +26,23 @@ const schema = defineSchema({
24
26
  .index("by_kind", ["kind"])
25
27
  .index("by_status", ["status"])
26
28
  .index("by_kind_status", ["kind", "status"])
29
+ .index("by_status_filter", ["statusFilter"])
30
+ .index("by_kind_status_filter", ["kind", "statusFilter"])
27
31
  .index("by_upvotes", ["upvoteCount"])
28
32
  .index("by_kind_upvotes", ["kind", "upvoteCount"])
29
33
  .index("by_status_upvotes", ["status", "upvoteCount"])
30
34
  .index("by_kind_status_upvotes", ["kind", "status", "upvoteCount"])
35
+ .index("by_status_filter_upvotes", ["statusFilter", "upvoteCount"])
36
+ .index("by_kind_status_filter_upvotes", [
37
+ "kind",
38
+ "statusFilter",
39
+ "upvoteCount",
40
+ ])
31
41
  .index("by_normalized_title", ["normalizedTitle"])
32
42
  .index("by_kind_normalized_title", ["kind", "normalizedTitle"])
33
43
  .searchIndex("search", {
34
44
  searchField: "searchText",
35
- filterFields: ["kind", "status"],
45
+ filterFields: ["kind", "status", "statusFilter"],
36
46
  }),
37
47
 
38
48
  comments: defineTable({
@@ -9,6 +9,7 @@ import type {
9
9
  EntryKind,
10
10
  EntrySort,
11
11
  EntryStatus,
12
+ EntryStatusFilter,
12
13
  SimilarEntriesResult,
13
14
  } from "../component/model.js";
14
15
 
@@ -61,6 +62,9 @@ export interface UseEntriesArgs {
61
62
  /** Optional workflow-status filter. */
62
63
  status?: EntryStatus;
63
64
 
65
+ /** Optional indexed open or closed board bucket. */
66
+ statusFilter?: EntryStatusFilter;
67
+
64
68
  /**
65
69
  * Server-side entry ordering.
66
70
  *
@@ -120,6 +124,9 @@ export interface SearchEntriesArgs {
120
124
  /** Optional workflow-status filter. */
121
125
  status?: EntryStatus;
122
126
 
127
+ /** Optional indexed open or closed board bucket. */
128
+ statusFilter?: EntryStatusFilter;
129
+
123
130
  /**
124
131
  * Maximum number of results requested.
125
132
  *
@@ -212,6 +219,9 @@ function createFeedbackHooksImplementation<RateLimitResult>(
212
219
  const queryArgs = {
213
220
  ...(args.kinds === undefined ? {} : { kinds: [...args.kinds] }),
214
221
  ...(args.status === undefined ? {} : { status: args.status }),
222
+ ...(args.statusFilter === undefined
223
+ ? {}
224
+ : { statusFilter: args.statusFilter }),
215
225
  ...(args.sort === undefined ? {} : { sort: args.sort }),
216
226
  };
217
227
 
@@ -244,6 +254,9 @@ function createFeedbackHooksImplementation<RateLimitResult>(
244
254
  searchQuery,
245
255
  ...(args.kinds === undefined ? {} : { kinds: [...args.kinds] }),
246
256
  ...(args.status === undefined ? {} : { status: args.status }),
257
+ ...(args.statusFilter === undefined
258
+ ? {}
259
+ : { statusFilter: args.statusFilter }),
247
260
  ...(args.limit === undefined ? {} : { limit: args.limit }),
248
261
  };
249
262