aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/lib
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-09 01:03:29 +0000
committerGitHub <noreply@github.com>2026-02-09 01:03:29 +0000
commitb3d3602dc5af6768e5b03613a7ca22ad3b47ec8d (patch)
tree800cf46d50a7fa8e90abab186475b5215d1b2ceb /packages/trpc/lib
parentb41b5647aa10d22ca83cfd3ba97146681e9f28a3 (diff)
downloadkarakeep-b3d3602dc5af6768e5b03613a7ca22ad3b47ec8d.tar.zst
fix: Support nested smart lists with cycle detection (#2470)
* fix: Support nested smart lists and prevent infinite loops --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages/trpc/lib')
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts55
-rw-r--r--packages/trpc/lib/search.ts75
2 files changed, 108 insertions, 22 deletions
diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts
index ee8bfb60..d39e27a6 100644
--- a/packages/trpc/lib/__tests__/search.test.ts
+++ b/packages/trpc/lib/__tests__/search.test.ts
@@ -240,6 +240,61 @@ describe("getBookmarkIdsFromMatcher", () => {
expect(result.sort()).toEqual(["b2", "b3", "b4", "b5"]);
});
+ it("should handle listName matcher when multiple lists share the same name", async () => {
+ await mockCtx.db.insert(bookmarkLists).values({
+ id: "l5",
+ userId: testUserId,
+ name: "list1",
+ icon: "🚀",
+ type: "manual",
+ });
+ await mockCtx.db.insert(bookmarksInLists).values({
+ bookmarkId: "b2",
+ listId: "l5",
+ });
+
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "list1",
+ inverse: false,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result.sort()).toEqual(["b1", "b2", "b6"]);
+ });
+
+ it("should handle inverse listName matcher when multiple lists share the same name", async () => {
+ await mockCtx.db.insert(bookmarkLists).values({
+ id: "l5",
+ userId: testUserId,
+ name: "list1",
+ icon: "🚀",
+ type: "manual",
+ });
+ await mockCtx.db.insert(bookmarksInLists).values({
+ bookmarkId: "b2",
+ listId: "l5",
+ });
+
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "list1",
+ inverse: true,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result.sort()).toEqual(["b3", "b4", "b5"]);
+ });
+
+ it("should return empty when inverse listName references a missing list", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "does-not-exist",
+ inverse: true,
+ };
+
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual([]);
+ });
+
it("should handle archived matcher", async () => {
const matcher: Matcher = { type: "archived", archived: true };
const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts
index 51e51d1c..d0f529f5 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -4,6 +4,7 @@ import {
exists,
gt,
gte,
+ inArray,
isNotNull,
isNull,
like,
@@ -11,6 +12,7 @@ import {
lte,
ne,
notExists,
+ notInArray,
notLike,
or,
} from "drizzle-orm";
@@ -89,10 +91,13 @@ function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] {
}
async function getIds(
- db: AuthedContext["db"],
- userId: string,
+ ctx: AuthedContext,
matcher: Matcher,
+ visitedListIds = new Set<string>(),
): Promise<BookmarkQueryReturnType[]> {
+ const { db } = ctx;
+ const userId = ctx.user.id;
+
switch (matcher.type) {
case "tagName": {
const comp = matcher.inverse ? notExists : exists;
@@ -139,29 +144,54 @@ async function getIds(
);
}
case "listName": {
- const comp = matcher.inverse ? notExists : exists;
+ // First, look up the list by name
+ const lists = await db.query.bookmarkLists.findMany({
+ where: and(
+ eq(bookmarkLists.userId, userId),
+ eq(bookmarkLists.name, matcher.listName),
+ ),
+ });
+
+ if (lists.length === 0) {
+ // No matching lists
+ return [];
+ }
+
+ // Use List model to resolve list membership (manual and smart)
+ // Import dynamically to avoid circular dependency
+ const { List } = await import("../models/lists");
+ const listBookmarkIds = [
+ ...new Set(
+ (
+ await Promise.all(
+ lists.map(async (list) => {
+ const listModel = await List.fromId(ctx, list.id);
+ return await listModel.getBookmarkIds(visitedListIds);
+ }),
+ )
+ ).flat(),
+ ),
+ ];
+
+ if (listBookmarkIds.length === 0) {
+ if (matcher.inverse) {
+ return db
+ .selectDistinct({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, userId));
+ }
+ return [];
+ }
+
return db
.selectDistinct({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
eq(bookmarks.userId, userId),
- comp(
- db
- .select()
- .from(bookmarksInLists)
- .innerJoin(
- bookmarkLists,
- eq(bookmarksInLists.listId, bookmarkLists.id),
- )
- .where(
- and(
- eq(bookmarksInLists.bookmarkId, bookmarks.id),
- eq(bookmarkLists.userId, userId),
- eq(bookmarkLists.name, matcher.listName),
- ),
- ),
- ),
+ matcher.inverse
+ ? notInArray(bookmarks.id, listBookmarkIds)
+ : inArray(bookmarks.id, listBookmarkIds),
),
);
}
@@ -391,13 +421,13 @@ async function getIds(
}
case "and": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)),
);
return intersect(vals);
}
case "or": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)),
);
return union(vals);
}
@@ -411,7 +441,8 @@ async function getIds(
export async function getBookmarkIdsFromMatcher(
ctx: AuthedContext,
matcher: Matcher,
+ visitedListIds = new Set<string>(),
): Promise<string[]> {
- const results = await getIds(ctx.db, ctx.user.id, matcher);
+ const results = await getIds(ctx, matcher, visitedListIds);
return results.map((r) => r.id);
}