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:
Lucas Winther
2026-10-30 19:59:09 +01:00
co-authored by Claude Opus 5.5
parent 4c65939d61
commit 512e6e9464
2 changed files with 84 additions and 9 deletions
+34 -9
View File
@@ -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 };
+50
View File
@@ -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 () => {