fix(core): roll up descendants and surface orphaned entries; SQL-derived title cursor
- childSummary counts the user's live entries up to three levels below the work. - The default library view (parentId null) lists entries with no ancestor (up to three levels) carrying a live entry of this user, so episodes of an untracked show still show. - The title keyset cursor carries the SQL lower(w.title) value, so SQLite no longer skips non-ASCII titles. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4c65939d61
commit
512e6e9464
@@ -594,6 +594,28 @@ export async function quickAdd(
|
||||
// Reading: views and the library list
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* FROM/WHERE for this user's live entries on works below `e.work_id`, down to great-grandchildren
|
||||
* (show → season → episode, series → volume → chapter). Portable: three fixed self-joins.
|
||||
*/
|
||||
const DESCENDANTS = sql`from entries c
|
||||
inner join works cw on cw.id = c.work_id
|
||||
left join works p1 on p1.id = cw.parent_id
|
||||
left join works p2 on p2.id = p1.parent_id
|
||||
where c.user_id = e.user_id and c.deleted_at is null
|
||||
and (cw.parent_id = e.work_id or p1.parent_id = e.work_id or p2.parent_id = e.work_id)`;
|
||||
|
||||
/**
|
||||
* True when one of the work's ancestors (parent, grandparent, great-grandparent) carries a live entry
|
||||
* of this user, i.e. the entry is rolled up under that ancestor's entry.
|
||||
*/
|
||||
const HAS_ENTRY_ANCESTOR = sql<boolean>`exists (select 1 from works a1
|
||||
left join works a2 on a2.id = a1.parent_id
|
||||
left join works a3 on a3.id = a2.parent_id
|
||||
inner join entries pe on pe.user_id = e.user_id and pe.deleted_at is null
|
||||
and (pe.work_id = a1.id or pe.work_id = a2.id or pe.work_id = a3.id)
|
||||
where a1.id = w.parent_id)`;
|
||||
|
||||
function viewQuery(k: Executor, userId: string) {
|
||||
return k
|
||||
.selectFrom("entries as e")
|
||||
@@ -609,11 +631,9 @@ function viewQuery(k: Executor, userId: string) {
|
||||
sql<number>`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as(
|
||||
"is_draft",
|
||||
),
|
||||
sql<number | string>`(select count(*) from entries c inner join works cw on cw.id = c.work_id
|
||||
where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id)`.as("child_total"),
|
||||
sql<number | string>`(select count(*) from entries c inner join works cw on cw.id = c.work_id
|
||||
where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id
|
||||
and c.status = 'completed')`.as("child_completed"),
|
||||
sql<number | string>`(select count(*) ${DESCENDANTS})`.as("child_total"),
|
||||
sql<number | string>`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"),
|
||||
sql<string>`lower(w.title)`.as("title_key"),
|
||||
])
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.deleted_at", "is", null);
|
||||
@@ -629,6 +649,7 @@ type ViewRow = EntryRow & {
|
||||
is_draft: number | string;
|
||||
child_total: number | string;
|
||||
child_completed: number | string;
|
||||
title_key: string;
|
||||
};
|
||||
|
||||
function toView(r: ViewRow): EntryView {
|
||||
@@ -664,7 +685,10 @@ export interface EntryFilter {
|
||||
tag?: string;
|
||||
draft?: boolean;
|
||||
q?: string;
|
||||
/** Default `null`: top-level works only. A work id lists that work's children. */
|
||||
/**
|
||||
* Default `null`: the top of the user's library, i.e. entries with no ancestor work (up to three
|
||||
* levels) carrying a live entry of this user. A work id lists that work's direct children.
|
||||
*/
|
||||
parentId?: string | null;
|
||||
sort?: "updated" | "title" | "rating";
|
||||
limit?: number;
|
||||
@@ -674,7 +698,8 @@ export interface EntryFilter {
|
||||
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
|
||||
/**
|
||||
* The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`.
|
||||
* The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`; for the
|
||||
* title sort `sortValue` is the SQL `lower(w.title)` of the last row, so SQL and cursor always agree.
|
||||
* Sorts: `updated` (newest first), `title` (A–Z, case-insensitive), `rating` (highest first, unrated last).
|
||||
*/
|
||||
export async function listEntries(
|
||||
@@ -690,7 +715,7 @@ export async function listEntries(
|
||||
|
||||
let q = viewQuery(db.k, userId);
|
||||
const parentId = filter.parentId === undefined ? null : filter.parentId;
|
||||
q = parentId === null ? q.where("w.parent_id", "is", null) : q.where("w.parent_id", "=", parentId);
|
||||
q = parentId === null ? q.where(sql<boolean>`not ${HAS_ENTRY_ANCESTOR}`) : q.where("w.parent_id", "=", parentId);
|
||||
if (filter.mediaType) q = q.where(sql<string>`coalesce(e.media_type_override, w.media_type)`, "=", filter.mediaType);
|
||||
if (filter.status) q = q.where("e.status", "=", filter.status);
|
||||
if (filter.tag) {
|
||||
@@ -743,7 +768,7 @@ export async function listEntries(
|
||||
let nextCursor: string | null = null;
|
||||
if (rows.length > take) {
|
||||
const last = page[page.length - 1]!;
|
||||
const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.w_title.toLowerCase() : last.rating;
|
||||
const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.title_key : last.rating;
|
||||
nextCursor = Buffer.from(JSON.stringify({ sort, sortValue, id: last.id })).toString("base64");
|
||||
}
|
||||
return { items: page.map(toView), nextCursor };
|
||||
|
||||
@@ -127,6 +127,56 @@ forEachDialect("entries", (getDb) => {
|
||||
const children = await listEntries(db, userId, { parentId: show });
|
||||
expect(children.items).toHaveLength(3);
|
||||
expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true);
|
||||
|
||||
// grandchildren: show → season (no entry) → episodes still roll up to the show
|
||||
const show2 = await makeWork(db, { title: "The Expanse", mediaType: "show" });
|
||||
const show2Entry = await upsertEntry(db, userId, show2, {}, "user");
|
||||
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show2 });
|
||||
for (const status of ["completed", "planned"] as const) {
|
||||
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: season });
|
||||
await upsertEntry(db, userId, ep, { status }, "user");
|
||||
}
|
||||
const top2 = await listEntries(db, userId, {});
|
||||
expect(top2.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id, show2Entry.id].sort());
|
||||
expect(top2.items.find((i) => i.id === show2Entry.id)!.childSummary).toEqual({ total: 2, completed: 1 });
|
||||
});
|
||||
|
||||
test("orphaned child entries are top level until an ancestor has an entry", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
|
||||
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show });
|
||||
const ep = await makeWork(db, { title: "Good News About Hell", mediaType: "episode", parentId: season });
|
||||
const epEntry = await upsertEntry(db, userId, ep, { status: "completed" }, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||
|
||||
// another user's show entry does not hide it
|
||||
const bob = await makeUser(db, "bob");
|
||||
await upsertEntry(db, bob, show, {}, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||
|
||||
const showEntry = await upsertEntry(db, userId, show, {}, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([showEntry.id]);
|
||||
// a soft-deleted ancestor entry does not hide it
|
||||
await softDeleteEntry(db, userId, showEntry.id, "user");
|
||||
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
|
||||
});
|
||||
|
||||
test("title pagination does not skip non-ASCII titles", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const titles = ["Éclair", "Ölund", "Zeta", "alpha", "日本", "éclair 2", "BETA"];
|
||||
for (const title of titles) await upsertEntry(db, userId, await makeWork(db, { title }), {}, "user");
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const page = await listEntries(db, userId, { sort: "title", limit: 1, cursor });
|
||||
seen.push(...page.items.map((x) => x.work.title));
|
||||
if (!page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
expect(seen).toHaveLength(titles.length);
|
||||
expect([...seen].sort()).toEqual([...titles].sort());
|
||||
});
|
||||
|
||||
test("listEntries filters, sorts and paginates", async () => {
|
||||
|
||||
Reference in New Issue
Block a user