convex-feedback 0.2.0-beta.8 → 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.
Files changed (81) hide show
  1. package/README.md +398 -7
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +49 -9
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +1 -3
  6. package/dist/client/config.d.ts.map +1 -1
  7. package/dist/client/config.js.map +1 -1
  8. package/dist/client/index.d.ts +707 -15
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +243 -12
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +2 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +222 -4
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/admin.d.ts +5 -90
  18. package/dist/component/admin.d.ts.map +1 -1
  19. package/dist/component/admin.js +59 -29
  20. package/dist/component/admin.js.map +1 -1
  21. package/dist/component/comments.d.ts +107 -15
  22. package/dist/component/comments.d.ts.map +1 -1
  23. package/dist/component/comments.js +342 -28
  24. package/dist/component/comments.js.map +1 -1
  25. package/dist/component/crons.d.ts.map +1 -1
  26. package/dist/component/crons.js +4 -0
  27. package/dist/component/crons.js.map +1 -1
  28. package/dist/component/entries.d.ts +119 -91
  29. package/dist/component/entries.d.ts.map +1 -1
  30. package/dist/component/entries.js +348 -29
  31. package/dist/component/entries.js.map +1 -1
  32. package/dist/component/helpers.d.ts +32 -1
  33. package/dist/component/helpers.d.ts.map +1 -1
  34. package/dist/component/helpers.js +121 -4
  35. package/dist/component/helpers.js.map +1 -1
  36. package/dist/component/migrations.d.ts +24 -0
  37. package/dist/component/migrations.d.ts.map +1 -1
  38. package/dist/component/migrations.js +318 -0
  39. package/dist/component/migrations.js.map +1 -1
  40. package/dist/component/model.d.ts +455 -120
  41. package/dist/component/model.d.ts.map +1 -1
  42. package/dist/component/model.js +70 -2
  43. package/dist/component/model.js.map +1 -1
  44. package/dist/component/reactions.d.ts +37 -0
  45. package/dist/component/reactions.d.ts.map +1 -0
  46. package/dist/component/reactions.js +52 -0
  47. package/dist/component/reactions.js.map +1 -0
  48. package/dist/component/roadmap.d.ts +10 -46
  49. package/dist/component/roadmap.d.ts.map +1 -1
  50. package/dist/component/roadmap.js +239 -21
  51. package/dist/component/roadmap.js.map +1 -1
  52. package/dist/component/schema.d.ts +33 -5
  53. package/dist/component/schema.d.ts.map +1 -1
  54. package/dist/component/schema.js +40 -4
  55. package/dist/component/schema.js.map +1 -1
  56. package/dist/react/index.d.ts +26 -238
  57. package/dist/react/index.d.ts.map +1 -1
  58. package/dist/react/index.js +65 -2
  59. package/dist/react/index.js.map +1 -1
  60. package/dist/test.d.ts +23 -5
  61. package/dist/test.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/client/README.md +6 -1
  64. package/src/client/api.ts +89 -7
  65. package/src/client/config.ts +1 -3
  66. package/src/client/index.ts +1237 -34
  67. package/src/component/README.md +32 -4
  68. package/src/component/_generated/api.ts +2 -0
  69. package/src/component/_generated/component.ts +290 -8
  70. package/src/component/admin.ts +88 -30
  71. package/src/component/comments.ts +405 -26
  72. package/src/component/crons.ts +10 -0
  73. package/src/component/entries.ts +388 -27
  74. package/src/component/helpers.ts +163 -4
  75. package/src/component/migrations.ts +415 -0
  76. package/src/component/model.ts +381 -117
  77. package/src/component/reactions.ts +60 -0
  78. package/src/component/roadmap.ts +357 -35
  79. package/src/component/schema.ts +40 -4
  80. package/src/react/README.md +5 -0
  81. package/src/react/index.ts +90 -3
@@ -2,8 +2,9 @@ import { paginator } from "convex-helpers/server/pagination";
2
2
  import {
3
3
  paginationOptsValidator,
4
4
  paginationResultValidator,
5
+ type PaginationResult,
5
6
  } from "convex/server";
6
- import { ConvexError, v } from "convex/values";
7
+ import { ConvexError, v, type Infer } from "convex/values";
7
8
 
8
9
  import { internal } from "./_generated/api.js";
9
10
  import type { Id } from "./_generated/dataModel.js";
@@ -28,8 +29,35 @@ const POSITION_STEP = 1_000_000;
28
29
  const REBALANCE_GAP_THRESHOLD = 10;
29
30
  const rebalanceBatchSize = 100;
30
31
  const deletionBatchSize = 100;
32
+ const roadmapStatuses = ["planned", "in_progress", "shipped"] as const;
31
33
 
32
34
  type RebalanceCtx = MutationCtx | QueryCtx;
35
+ type RoadmapDatabase = ReturnType<typeof paginator<typeof schema>>;
36
+ type RoadmapPaginationOpts = Infer<typeof paginationOptsValidator>;
37
+ type RoadmapItemPage = PaginationResult<
38
+ ReturnType<typeof serializeRoadmapItem>
39
+ >;
40
+
41
+ type RoadmapListStreamState = {
42
+ cursor: string | null;
43
+ offset: number;
44
+ done: boolean;
45
+ generation: string | null;
46
+ };
47
+
48
+ type RoadmapListCursor = {
49
+ version: 1;
50
+ streams: RoadmapListStreamState[];
51
+ };
52
+
53
+ type RoadmapStatusPage = RoadmapItemPage & {
54
+ generation: string | null;
55
+ };
56
+
57
+ type RoadmapPageStream = {
58
+ stream: RoadmapListStreamState;
59
+ result: RoadmapStatusPage;
60
+ };
33
61
 
34
62
  type RoadmapCreateArgs = {
35
63
  title: string;
@@ -123,6 +151,317 @@ async function scheduleRebalance(
123
151
  });
124
152
  }
125
153
 
154
+ function newRoadmapListStreamState(
155
+ generation: string | null = null,
156
+ ): RoadmapListStreamState {
157
+ return { cursor: null, offset: 0, done: false, generation };
158
+ }
159
+
160
+ function initialRoadmapListCursor(): RoadmapListCursor {
161
+ return {
162
+ version: 1,
163
+ streams: roadmapStatuses.map(() => newRoadmapListStreamState()),
164
+ };
165
+ }
166
+
167
+ function isRecord(value: unknown): value is Record<string, unknown> {
168
+ return typeof value === "object" && value !== null && !Array.isArray(value);
169
+ }
170
+
171
+ function isRoadmapListStreamState(
172
+ value: unknown,
173
+ ): value is RoadmapListStreamState {
174
+ if (!isRecord(value)) return false;
175
+ return (
176
+ (value.cursor === null || typeof value.cursor === "string") &&
177
+ typeof value.offset === "number" &&
178
+ Number.isInteger(value.offset) &&
179
+ value.offset >= 0 &&
180
+ typeof value.done === "boolean" &&
181
+ (value.generation === null || typeof value.generation === "string")
182
+ );
183
+ }
184
+
185
+ function decodeRoadmapListCursor(cursor: string | null): RoadmapListCursor {
186
+ if (cursor === null) return initialRoadmapListCursor();
187
+
188
+ let value: unknown;
189
+ try {
190
+ value = JSON.parse(cursor) as unknown;
191
+ } catch {
192
+ throw new ConvexError("InvalidCursor");
193
+ }
194
+
195
+ if (
196
+ !isRecord(value) ||
197
+ value.version !== 1 ||
198
+ !Array.isArray(value.streams) ||
199
+ value.streams.length !== roadmapStatuses.length ||
200
+ !value.streams.every(isRoadmapListStreamState)
201
+ ) {
202
+ throw new ConvexError("InvalidCursor");
203
+ }
204
+
205
+ return {
206
+ version: 1,
207
+ streams: value.streams.map((stream) => ({ ...stream })),
208
+ };
209
+ }
210
+
211
+ function encodeRoadmapListCursor(cursor: RoadmapListCursor): string {
212
+ return JSON.stringify(cursor);
213
+ }
214
+
215
+ async function paginateRoadmapStatus(
216
+ ctx: QueryCtx,
217
+ db: RoadmapDatabase,
218
+ status: RoadmapStatus,
219
+ paginationOpts: RoadmapPaginationOpts,
220
+ ): Promise<RoadmapStatusPage> {
221
+ const state = await getRebalanceState(ctx, status);
222
+ const generation = state?.visibleGeneration ?? null;
223
+ const roadmapQuery =
224
+ generation === null
225
+ ? db
226
+ .query("roadmap")
227
+ .withIndex("by_status_deleting_at_position", (q) =>
228
+ q.eq("status", status).eq("deletingAt", undefined),
229
+ )
230
+ : db
231
+ .query("roadmap")
232
+ .withIndex("by_status_deleting_at_rebalance_id_position", (q) =>
233
+ q
234
+ .eq("status", status)
235
+ .eq("deletingAt", undefined)
236
+ .eq("rebalanceId", generation),
237
+ );
238
+ const result = await roadmapQuery.paginate(paginationOpts);
239
+
240
+ return {
241
+ ...result,
242
+ generation,
243
+ page: result.page.map((item) =>
244
+ serializeRoadmapItem(
245
+ item,
246
+ generation === null ? undefined : item.rebalancePosition,
247
+ ),
248
+ ),
249
+ };
250
+ }
251
+
252
+ function compareRoadmapItems(
253
+ left: RoadmapItemPage["page"][number],
254
+ right: RoadmapItemPage["page"][number],
255
+ ): number {
256
+ if (left.position !== right.position) {
257
+ return left.position - right.position;
258
+ }
259
+ if (left.creationTime !== right.creationTime) {
260
+ return left.creationTime - right.creationTime;
261
+ }
262
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
263
+ }
264
+
265
+ function mergeRoadmapPages(
266
+ pages: Array<RoadmapPageStream | null>,
267
+ offsets: number[],
268
+ limit: number,
269
+ ) {
270
+ const page: RoadmapItemPage["page"] = [];
271
+
272
+ while (page.length < limit) {
273
+ let selectedIndex = -1;
274
+ for (const [index, value] of pages.entries()) {
275
+ if (value === null) continue;
276
+ const item = value.result.page[offsets[index]!];
277
+ if (item === undefined) continue;
278
+ const selected =
279
+ selectedIndex < 0
280
+ ? undefined
281
+ : pages[selectedIndex]!.result.page[offsets[selectedIndex]!];
282
+ if (selected === undefined || compareRoadmapItems(item, selected) < 0) {
283
+ selectedIndex = index;
284
+ }
285
+ }
286
+ if (selectedIndex < 0) break;
287
+ page.push(pages[selectedIndex]!.result.page[offsets[selectedIndex]!]!);
288
+ offsets[selectedIndex]! += 1;
289
+ }
290
+
291
+ return page;
292
+ }
293
+
294
+ async function paginateUnfilteredRoadmapRange(
295
+ ctx: QueryCtx,
296
+ db: RoadmapDatabase,
297
+ paginationOpts: RoadmapPaginationOpts,
298
+ startCursor: RoadmapListCursor,
299
+ endCursor: RoadmapListCursor,
300
+ ): Promise<RoadmapItemPage> {
301
+ const generations = await Promise.all(
302
+ roadmapStatuses.map(async (status) => {
303
+ const state = await getRebalanceState(ctx, status);
304
+ return state?.visibleGeneration ?? null;
305
+ }),
306
+ );
307
+ if (
308
+ startCursor.streams.some(
309
+ (stream, index) =>
310
+ stream.generation !== generations[index] ||
311
+ endCursor.streams[index]!.generation !== generations[index],
312
+ )
313
+ ) {
314
+ throw new ConvexError("InvalidCursor");
315
+ }
316
+
317
+ const pages = await Promise.all(
318
+ roadmapStatuses.map(async (status, index) => {
319
+ const start = startCursor.streams[index]!;
320
+ const end = endCursor.streams[index]!;
321
+ if (start.generation !== end.generation) {
322
+ throw new ConvexError("InvalidCursor");
323
+ }
324
+ if (start.done) return null;
325
+
326
+ if (start.cursor === end.cursor) {
327
+ if (end.offset <= start.offset) return null;
328
+ const result = await paginateRoadmapStatus(ctx, db, status, {
329
+ cursor: start.cursor,
330
+ numItems: end.offset,
331
+ });
332
+ return { stream: start, result };
333
+ }
334
+
335
+ if (end.cursor === null) {
336
+ throw new ConvexError("InvalidCursor");
337
+ }
338
+ const result = await paginateRoadmapStatus(ctx, db, status, {
339
+ cursor: start.cursor,
340
+ endCursor: end.cursor,
341
+ numItems: paginationOpts.numItems,
342
+ });
343
+ return { stream: start, result };
344
+ }),
345
+ );
346
+ const offsets = pages.map((page) => page?.stream.offset ?? 0);
347
+
348
+ return {
349
+ page: mergeRoadmapPages(pages, offsets, Number.POSITIVE_INFINITY),
350
+ isDone: endCursor.streams.every((stream) => stream.done),
351
+ continueCursor: encodeRoadmapListCursor(endCursor),
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Unfiltered reads merge one stable stream per status. Each stream is pinned
357
+ * to the generation visible in the query snapshot, so promotion batches can
358
+ * never leak a mixture of live and replacement positions.
359
+ */
360
+ async function paginateUnfilteredRoadmap(
361
+ ctx: QueryCtx,
362
+ db: RoadmapDatabase,
363
+ paginationOpts: RoadmapPaginationOpts,
364
+ ): Promise<RoadmapItemPage> {
365
+ const cursor = decodeRoadmapListCursor(paginationOpts.cursor);
366
+ if (
367
+ paginationOpts.endCursor !== undefined &&
368
+ paginationOpts.endCursor !== null
369
+ ) {
370
+ return await paginateUnfilteredRoadmapRange(
371
+ ctx,
372
+ db,
373
+ paginationOpts,
374
+ cursor,
375
+ decodeRoadmapListCursor(paginationOpts.endCursor),
376
+ );
377
+ }
378
+ const generations = await Promise.all(
379
+ roadmapStatuses.map(async (status) => {
380
+ const state = await getRebalanceState(ctx, status);
381
+ return state?.visibleGeneration ?? null;
382
+ }),
383
+ );
384
+ const streams = cursor.streams.map((stream, index) =>
385
+ stream.generation === generations[index]
386
+ ? stream
387
+ : newRoadmapListStreamState(generations[index]),
388
+ );
389
+ const pages = await Promise.all(
390
+ roadmapStatuses.map(async (status, index) => {
391
+ const stream = streams[index]!;
392
+ if (stream.done) return null;
393
+
394
+ const result = await paginateRoadmapStatus(ctx, db, status, {
395
+ ...paginationOpts,
396
+ cursor: stream.cursor,
397
+ endCursor: undefined,
398
+ numItems: Math.max(
399
+ paginationOpts.numItems,
400
+ stream.offset + paginationOpts.numItems,
401
+ ),
402
+ });
403
+
404
+ return { stream, result };
405
+ }),
406
+ );
407
+ const nextStreams = streams.map((stream) => ({ ...stream }));
408
+ const offsets = pages.map((page) => page?.stream.offset ?? 0);
409
+ const page = mergeRoadmapPages(pages, offsets, paginationOpts.numItems);
410
+
411
+ for (const [index, value] of pages.entries()) {
412
+ if (value === null) continue;
413
+ const stream = nextStreams[index]!;
414
+ const result = value.result;
415
+ const consumed = offsets[index]! - value.stream.offset;
416
+ const pageExhausted = offsets[index]! >= result.page.length;
417
+ stream.generation = result.generation;
418
+ if (pageExhausted) {
419
+ stream.done = result.isDone;
420
+ if (result.isDone) {
421
+ // Keep the source page boundary even when the paginator uses "[]" as
422
+ // its terminal cursor, so a bound previous-page query can reproduce
423
+ // exactly the same range.
424
+ stream.cursor = value.stream.cursor;
425
+ stream.offset = result.page.length;
426
+ } else {
427
+ stream.cursor = result.continueCursor;
428
+ stream.offset = 0;
429
+ }
430
+ } else {
431
+ stream.done = false;
432
+ stream.cursor = value.stream.cursor;
433
+ stream.offset = value.stream.offset + consumed;
434
+ }
435
+ }
436
+
437
+ const nextCursor: RoadmapListCursor = {
438
+ version: 1,
439
+ streams: nextStreams,
440
+ };
441
+ return {
442
+ page,
443
+ isDone: nextStreams.every((stream) => stream.done),
444
+ continueCursor: encodeRoadmapListCursor(nextCursor),
445
+ };
446
+ }
447
+
448
+ export const get = query({
449
+ args: { roadmapId: v.id("roadmap") },
450
+ returns: v.union(roadmapItemValidator, v.null()),
451
+ handler: async (ctx, args) => {
452
+ const item = await ctx.db.get("roadmap", args.roadmapId);
453
+ if (item === null || item.deletingAt !== undefined) return null;
454
+
455
+ const state = await getRebalanceState(ctx, item.status);
456
+ return serializeRoadmapItem(
457
+ item,
458
+ state?.visibleGeneration === undefined
459
+ ? undefined
460
+ : item.rebalancePosition,
461
+ );
462
+ },
463
+ });
464
+
126
465
  export const list = query({
127
466
  args: {
128
467
  paginationOpts: paginationOptsValidator,
@@ -132,40 +471,19 @@ export const list = query({
132
471
  handler: async (ctx, args) => {
133
472
  const db = paginator(ctx.db, schema);
134
473
  const status = args.status;
135
- const state =
136
- status === undefined ? null : await getRebalanceState(ctx, status);
137
- const visibleGeneration = state?.visibleGeneration;
138
- const roadmapQuery =
139
- status !== undefined && visibleGeneration !== undefined
140
- ? db
141
- .query("roadmap")
142
- .withIndex("by_status_deleting_at_rebalance_id_position", (q) =>
143
- q
144
- .eq("status", status)
145
- .eq("deletingAt", undefined)
146
- .eq("rebalanceId", visibleGeneration),
147
- )
148
- : status === undefined
149
- ? db
150
- .query("roadmap")
151
- .withIndex("by_deleting_at_and_position", (q) =>
152
- q.eq("deletingAt", undefined),
153
- )
154
- : db
155
- .query("roadmap")
156
- .withIndex("by_status_deleting_at_position", (q) =>
157
- q.eq("status", status).eq("deletingAt", undefined),
158
- );
159
- const result = await roadmapQuery.paginate(args.paginationOpts);
160
- return {
161
- ...result,
162
- page: result.page.map((item) =>
163
- serializeRoadmapItem(
164
- item,
165
- visibleGeneration === undefined ? undefined : item.rebalancePosition,
166
- ),
167
- ),
168
- };
474
+ if (status === undefined) {
475
+ return await paginateUnfilteredRoadmap(ctx, db, args.paginationOpts);
476
+ }
477
+
478
+ const result = await paginateRoadmapStatus(
479
+ ctx,
480
+ db,
481
+ status,
482
+ args.paginationOpts,
483
+ );
484
+ const { generation: _generation, ...publicResult } = result;
485
+ void _generation;
486
+ return publicResult;
169
487
  },
170
488
  });
171
489
 
@@ -530,6 +848,9 @@ async function attachFeedbackRecord(
530
848
  throw new ConvexError("Roadmap item is being deleted.");
531
849
  }
532
850
  if (entry === null) throw new ConvexError("Entry not found.");
851
+ if (entry.deletingAt !== undefined) {
852
+ throw new ConvexError("Entry is being deleted.");
853
+ }
533
854
  if (entry.roadmapId === roadmapId) return;
534
855
 
535
856
  const now = Date.now();
@@ -619,6 +940,7 @@ export const listFeedback = query({
619
940
  .query("entries")
620
941
  .withIndex("by_roadmap_id", (q) => q.eq("roadmapId", args.roadmapId))
621
942
  .order("desc")
943
+ .filterWith((entry) => Promise.resolve(entry.deletingAt === undefined))
622
944
  .paginate(args.paginationOpts);
623
945
  return {
624
946
  ...result,
@@ -26,7 +26,10 @@ const schema = defineSchema({
26
26
  metadata: v.optional(feedbackMetadataValidator),
27
27
  priority: v.optional(entryPriorityValidator),
28
28
  roadmapId: v.optional(v.id("roadmap")),
29
+ /** Set while dependent comments and reactions are being removed. */
30
+ deletingAt: v.optional(v.number()),
29
31
  })
32
+ .index("by_actor", ["actorId"])
30
33
  .index("by_kind", ["kind"])
31
34
  .index("by_status", ["status"])
32
35
  .index("by_kind_status", ["kind", "status"])
@@ -48,7 +51,13 @@ const schema = defineSchema({
48
51
  .index("by_roadmap_id", ["roadmapId"])
49
52
  .searchIndex("search", {
50
53
  searchField: "searchText",
51
- filterFields: ["kind", "status", "statusFilter", "priority"],
54
+ filterFields: [
55
+ "kind",
56
+ "status",
57
+ "statusFilter",
58
+ "priority",
59
+ "deletingAt",
60
+ ],
52
61
  }),
53
62
 
54
63
  roadmap: defineTable({
@@ -104,22 +113,49 @@ const schema = defineSchema({
104
113
  likeCount: v.number(),
105
114
  replyCount: v.number(),
106
115
  updatedAt: v.optional(v.number()),
116
+ /**
117
+ * Legacy compatibility field. New comment deletion uses deletingAt and
118
+ * permanently removes the subtree; this field is drained by the legacy
119
+ * cleanup migration and is not part of any public type.
120
+ */
107
121
  deletedAt: v.optional(v.number()),
122
+ /** Internal one-time marker used by the bounded legacy orphan sweep. */
123
+ legacyCleanupAt: v.optional(v.number()),
124
+ /** Timestamp while this comment subtree is being removed. */
125
+ deletingAt: v.optional(v.number()),
126
+ /** Root comment owning the pending subtree cleanup. */
127
+ deletionRootId: v.optional(v.id("comments")),
108
128
  })
129
+ .index("by_actor", ["actorId"])
109
130
  .index("by_entry_parent", ["entryId", "parentCommentId"])
110
- .index("by_entry_parent_likes", [
131
+ .index("by_entry_parent_deleting", [
132
+ "entryId",
133
+ "parentCommentId",
134
+ "deletingAt",
135
+ "deletedAt",
136
+ ])
137
+ .index("by_entry_parent_deleting_likes", [
111
138
  "entryId",
112
139
  "parentCommentId",
140
+ "deletingAt",
141
+ "deletedAt",
113
142
  "likeCount",
114
- ]),
143
+ ])
144
+ .index("by_deleted_at", ["deletedAt"])
145
+ .index("by_legacy_cleanup", ["legacyCleanupAt"])
146
+ .index("by_entry_deletion_root", ["entryId", "deletionRootId"]),
115
147
 
116
148
  reactions: defineTable({
117
149
  actorId: v.string(),
118
150
  entryId: v.optional(v.id("entries")),
119
151
  commentId: v.optional(v.id("comments")),
152
+ /** Internal one-time marker used by the bounded legacy orphan sweep. */
153
+ legacyCleanupAt: v.optional(v.number()),
120
154
  })
155
+ .index("by_actor", ["actorId"])
121
156
  .index("by_entry_actor", ["entryId", "actorId"])
122
- .index("by_comment_actor", ["commentId", "actorId"]),
157
+ .index("by_comment_actor", ["commentId", "actorId"])
158
+ .index("by_legacy_cleanup", ["legacyCleanupAt"]),
123
159
  });
124
160
 
125
161
  export default schema;
@@ -5,3 +5,8 @@
5
5
  The hooks expose public entries, search/similarity, lazy direct-child comment pagination, mutations, admin inbox queries, and roadmap operations. Reply queries are not mounted by the hook layer automatically; the UI decides when a comment is expanded.
6
6
 
7
7
  Page-size defaults are client concerns. The server-side component still enforces its configured hard maximum. Keep the two aligned when overriding defaults.
8
+
9
+ Lifecycle callbacks are server-side options on `exposeFeedbackApi`; hooks only
10
+ consume the generated host API and do not configure callbacks. See the
11
+ [canonical callback reference](../../README.md#lifecycle-callbacks) for the
12
+ host-side API and mutation result behavior.
@@ -12,10 +12,22 @@ import type {
12
12
  EntrySort,
13
13
  EntryStatus,
14
14
  EntryStatusFilter,
15
+ RoadmapItem,
15
16
  RoadmapStatus,
16
17
  SimilarEntriesResult,
17
18
  } from "../component/model.js";
18
19
 
20
+ function compareRoadmapItemsByPosition(
21
+ left: RoadmapItem,
22
+ right: RoadmapItem,
23
+ ): number {
24
+ if (left.position !== right.position) return left.position - right.position;
25
+ if (left.creationTime !== right.creationTime) {
26
+ return left.creationTime - right.creationTime;
27
+ }
28
+ return left.id.localeCompare(right.id);
29
+ }
30
+
19
31
  /**
20
32
  * Client-side pagination defaults used by hooks created with
21
33
  * `createFeedbackHooks`.
@@ -150,6 +162,7 @@ export interface SearchEntriesArgs {
150
162
  export interface UseAdminEntriesArgs {
151
163
  kinds?: readonly EntryKind[];
152
164
  status?: EntryStatus;
165
+ statusFilter?: EntryStatusFilter;
153
166
  priority?: EntryPriority;
154
167
  }
155
168
 
@@ -308,6 +321,9 @@ function createFeedbackHooksImplementation<RateLimitResult>(
308
321
  {
309
322
  ...(args.kinds === undefined ? {} : { kinds: [...args.kinds] }),
310
323
  ...(args.status === undefined ? {} : { status: args.status }),
324
+ ...(args.statusFilter === undefined
325
+ ? {}
326
+ : { statusFilter: args.statusFilter }),
311
327
  ...(args.priority === undefined ? {} : { priority: args.priority }),
312
328
  },
313
329
  { initialNumItems: entryPageSize },
@@ -333,6 +349,9 @@ function createFeedbackHooksImplementation<RateLimitResult>(
333
349
  searchQuery,
334
350
  ...(args.kinds === undefined ? {} : { kinds: [...args.kinds] }),
335
351
  ...(args.status === undefined ? {} : { status: args.status }),
352
+ ...(args.statusFilter === undefined
353
+ ? {}
354
+ : { statusFilter: args.statusFilter }),
336
355
  ...(args.priority === undefined
337
356
  ? {}
338
357
  : { priority: args.priority }),
@@ -341,12 +360,76 @@ function createFeedbackHooksImplementation<RateLimitResult>(
341
360
  );
342
361
  },
343
362
 
363
+ /**
364
+ * An unfiltered roadmap is composed from one stream per status so a
365
+ * rebalance cannot mix live and replacement positions in the UI.
366
+ */
344
367
  useRoadmap(status?: RoadmapStatus) {
345
- return usePaginatedQuery(
368
+ const planned = usePaginatedQuery(
346
369
  api.listRoadmap,
347
- status === undefined ? {} : { status },
370
+ status === undefined || status === "planned"
371
+ ? { status: "planned" }
372
+ : "skip",
348
373
  { initialNumItems: roadmapPageSize },
349
374
  );
375
+ const inProgress = usePaginatedQuery(
376
+ api.listRoadmap,
377
+ status === undefined || status === "in_progress"
378
+ ? { status: "in_progress" }
379
+ : "skip",
380
+ { initialNumItems: roadmapPageSize },
381
+ );
382
+ const shipped = usePaginatedQuery(
383
+ api.listRoadmap,
384
+ status === undefined || status === "shipped"
385
+ ? { status: "shipped" }
386
+ : "skip",
387
+ { initialNumItems: roadmapPageSize },
388
+ );
389
+
390
+ if (status === "planned") return planned;
391
+ if (status === "in_progress") return inProgress;
392
+ if (status === "shipped") return shipped;
393
+
394
+ const roadmaps = [planned, inProgress, shipped];
395
+ const loadingFirstPage = roadmaps.some(
396
+ (roadmap) => roadmap.status === "LoadingFirstPage",
397
+ );
398
+ const loadingMore = roadmaps.some(
399
+ (roadmap) => roadmap.status === "LoadingMore",
400
+ );
401
+ const canLoadMore = roadmaps.some(
402
+ (roadmap) => roadmap.status === "CanLoadMore",
403
+ );
404
+
405
+ return {
406
+ results: roadmaps
407
+ .flatMap((roadmap) => roadmap.results)
408
+ .sort(compareRoadmapItemsByPosition),
409
+ status: loadingFirstPage
410
+ ? ("LoadingFirstPage" as const)
411
+ : loadingMore
412
+ ? ("LoadingMore" as const)
413
+ : canLoadMore
414
+ ? ("CanLoadMore" as const)
415
+ : ("Exhausted" as const),
416
+ isLoading: loadingFirstPage || loadingMore,
417
+ loadMore: (numItems: number) => {
418
+ for (const roadmap of roadmaps) {
419
+ if (roadmap.status === "CanLoadMore") {
420
+ roadmap.loadMore(numItems);
421
+ }
422
+ }
423
+ },
424
+ };
425
+ },
426
+
427
+ /** Reactively retrieves one roadmap item independently of the list page. */
428
+ useRoadmapItem(roadmapId: string | null | undefined) {
429
+ return useQuery(
430
+ api.getRoadmapItem,
431
+ roadmapId === null || roadmapId === undefined ? "skip" : { roadmapId },
432
+ );
350
433
  },
351
434
 
352
435
  useSearchRoadmap(searchQuery: string, limit = 10) {
@@ -412,6 +495,10 @@ function createFeedbackHooksImplementation<RateLimitResult>(
412
495
  return useMutation(api.updateEntry);
413
496
  },
414
497
 
498
+ useDeleteEntry() {
499
+ return useMutation(api.deleteEntry);
500
+ },
501
+
415
502
  /** Returns the bound status mutation. */
416
503
  useSetEntryStatus() {
417
504
  return useMutation(api.setEntryStatus);
@@ -464,7 +551,7 @@ function createFeedbackHooksImplementation<RateLimitResult>(
464
551
  return useMutation(api.updateComment);
465
552
  },
466
553
 
467
- /** Returns the soft-delete-comment mutation. */
554
+ /** Returns the permanent comment-subtree deletion mutation. */
468
555
  useDeleteComment() {
469
556
  return useMutation(api.deleteComment);
470
557
  },