convex-feedback 0.2.0-beta.9 → 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.
- package/README.md +398 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/client/api.d.ts +49 -9
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/config.d.ts +1 -3
- package/dist/client/config.d.ts.map +1 -1
- package/dist/client/config.js.map +1 -1
- package/dist/client/index.d.ts +707 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +243 -12
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -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 +222 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/admin.d.ts +5 -90
- package/dist/component/admin.d.ts.map +1 -1
- package/dist/component/admin.js +59 -29
- package/dist/component/admin.js.map +1 -1
- package/dist/component/comments.d.ts +107 -15
- package/dist/component/comments.d.ts.map +1 -1
- package/dist/component/comments.js +342 -28
- package/dist/component/comments.js.map +1 -1
- package/dist/component/crons.d.ts.map +1 -1
- package/dist/component/crons.js +4 -0
- package/dist/component/crons.js.map +1 -1
- package/dist/component/entries.d.ts +119 -91
- package/dist/component/entries.d.ts.map +1 -1
- package/dist/component/entries.js +348 -29
- package/dist/component/entries.js.map +1 -1
- package/dist/component/helpers.d.ts +32 -1
- package/dist/component/helpers.d.ts.map +1 -1
- package/dist/component/helpers.js +121 -4
- package/dist/component/helpers.js.map +1 -1
- package/dist/component/migrations.d.ts +24 -0
- package/dist/component/migrations.d.ts.map +1 -1
- package/dist/component/migrations.js +318 -0
- package/dist/component/migrations.js.map +1 -1
- package/dist/component/model.d.ts +455 -120
- package/dist/component/model.d.ts.map +1 -1
- package/dist/component/model.js +70 -2
- package/dist/component/model.js.map +1 -1
- package/dist/component/reactions.d.ts +37 -0
- package/dist/component/reactions.d.ts.map +1 -0
- package/dist/component/reactions.js +52 -0
- package/dist/component/reactions.js.map +1 -0
- package/dist/component/roadmap.d.ts +10 -46
- package/dist/component/roadmap.d.ts.map +1 -1
- package/dist/component/roadmap.js +239 -21
- package/dist/component/roadmap.js.map +1 -1
- package/dist/component/schema.d.ts +33 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +40 -4
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +26 -238
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +65 -2
- package/dist/react/index.js.map +1 -1
- package/dist/test.d.ts +23 -5
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/README.md +6 -1
- package/src/client/api.ts +89 -7
- package/src/client/config.ts +1 -3
- package/src/client/index.ts +1237 -34
- package/src/component/README.md +32 -4
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +290 -8
- package/src/component/admin.ts +88 -30
- package/src/component/comments.ts +405 -26
- package/src/component/crons.ts +10 -0
- package/src/component/entries.ts +388 -27
- package/src/component/helpers.ts +163 -4
- package/src/component/migrations.ts +415 -0
- package/src/component/model.ts +381 -117
- package/src/component/reactions.ts +60 -0
- package/src/component/roadmap.ts +357 -35
- package/src/component/schema.ts +40 -4
- package/src/react/README.md +5 -0
- package/src/react/index.ts +90 -3
package/src/component/roadmap.ts
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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,
|
package/src/component/schema.ts
CHANGED
|
@@ -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: [
|
|
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("
|
|
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;
|
package/src/react/README.md
CHANGED
|
@@ -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.
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
-
|
|
368
|
+
const planned = usePaginatedQuery(
|
|
346
369
|
api.listRoadmap,
|
|
347
|
-
status === undefined
|
|
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
|
|
554
|
+
/** Returns the permanent comment-subtree deletion mutation. */
|
|
468
555
|
useDeleteComment() {
|
|
469
556
|
return useMutation(api.deleteComment);
|
|
470
557
|
},
|