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.
- package/README.md +17 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +5 -1
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +4 -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 +2 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/comments.d.ts +2 -2
- package/dist/component/comments.d.ts.map +1 -1
- package/dist/component/comments.js +2 -1
- package/dist/component/comments.js.map +1 -1
- package/dist/component/crons.d.ts +3 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +8 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/entries.d.ts +4 -2
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +110 -7
- package/dist/component/entries.js.map +1 -1
- package/dist/component/migrations.d.ts +11 -0
- package/dist/component/migrations.d.ts.map +1 -0
- package/dist/component/migrations.js +35 -0
- package/dist/component/migrations.js.map +1 -0
- package/dist/component/model.d.ts +4 -0
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +4 -0
- package/dist/component/model.js.map +1 -1
- package/dist/component/schema.d.ts +8 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +11 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +8 -2
- package/dist/test.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/client/api.ts +7 -0
- package/src/client/index.ts +10 -0
- package/src/component/README.md +2 -0
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +2 -0
- package/src/component/comments.ts +2 -0
- package/src/component/crons.ts +16 -0
- package/src/component/entries.ts +146 -6
- package/src/component/migrations.ts +44 -0
- package/src/component/model.ts +14 -0
- package/src/component/schema.ts +11 -1
- package/src/react/index.ts +13 -0
package/src/component/entries.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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, {
|
|
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, {
|
|
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
|
+
});
|
package/src/component/model.ts
CHANGED
|
@@ -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
|
*
|
package/src/component/schema.ts
CHANGED
|
@@ -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({
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
|