aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-21 16:27:21 +0000
committerMohamed Bassem <me@mbassem.com>2025-09-21 16:27:55 +0000
commit9fe09bfa9021c8d85d2d9aef591936101cab19f6 (patch)
treeac853b78e306afe5f37859b673d9583b6a94ee40 /packages/trpc
parentbbc5e6c2cdb07e66ea76df86ee8e01f37f290db1 (diff)
downloadkarakeep-9fe09bfa9021c8d85d2d9aef591936101cab19f6.tar.zst
fix: optimize memory usage of tag listing
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/models/tags.ts114
-rw-r--r--packages/trpc/routers/tags.test.ts74
2 files changed, 137 insertions, 51 deletions
diff --git a/packages/trpc/models/tags.ts b/packages/trpc/models/tags.ts
index a91dcbdf..dadb20f7 100644
--- a/packages/trpc/models/tags.ts
+++ b/packages/trpc/models/tags.ts
@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
-import { and, eq, inArray, notExists } from "drizzle-orm";
+import { and, count, eq, inArray, notExists } from "drizzle-orm";
import { z } from "zod";
import type { ZAttachedByEnum } from "@karakeep/shared/types/tags";
@@ -70,40 +70,45 @@ export class Tag implements PrivacyAware {
}
}
- static async getAll(ctx: AuthedContext): Promise<Tag[]> {
- const tags = await ctx.db.query.bookmarkTags.findMany({
- where: eq(bookmarkTags.userId, ctx.user.id),
- });
-
- return tags.map((t) => new Tag(ctx, t));
- }
-
static async getAllWithStats(ctx: AuthedContext) {
- const tags = await ctx.db.query.bookmarkTags.findMany({
- where: eq(bookmarkTags.userId, ctx.user.id),
- with: {
- tagsOnBookmarks: {
- columns: {
- attachedBy: true,
- },
- },
- },
- });
+ const tags = await ctx.db
+ .select({
+ id: bookmarkTags.id,
+ name: bookmarkTags.name,
+ attachedBy: tagsOnBookmarks.attachedBy,
+ count: count(),
+ })
+ .from(bookmarkTags)
+ .leftJoin(tagsOnBookmarks, eq(bookmarkTags.id, tagsOnBookmarks.tagId))
+ .where(and(eq(bookmarkTags.userId, ctx.user.id)))
+ .groupBy(bookmarkTags.id, tagsOnBookmarks.attachedBy);
- return tags.map(({ tagsOnBookmarks, ...rest }) => ({
- ...rest,
- numBookmarks: tagsOnBookmarks.length,
- numBookmarksByAttachedType: tagsOnBookmarks.reduce<
- Record<ZAttachedByEnum, number>
- >(
- (acc, curr) => {
- if (curr.attachedBy) {
- acc[curr.attachedBy]++;
- }
- return acc;
- },
- { ai: 0, human: 0 },
- ),
+ if (tags.length === 0) {
+ return [];
+ }
+
+ const tagsById = tags.reduce<
+ Record<
+ string,
+ {
+ id: string;
+ name: string;
+ attachedBy: "ai" | "human" | null;
+ count: number;
+ }[]
+ >
+ >((acc, curr) => {
+ if (!acc[curr.id]) {
+ acc[curr.id] = [];
+ }
+ acc[curr.id].push(curr);
+ return acc;
+ }, {});
+
+ return Object.entries(tagsById).map(([k, t]) => ({
+ id: k,
+ name: t[0].name,
+ ...Tag._aggregateStats(t),
}));
}
@@ -310,12 +315,34 @@ export class Tag implements PrivacyAware {
}
}
+ static _aggregateStats(
+ res: { attachedBy: "ai" | "human" | null; count: number }[],
+ ) {
+ const numBookmarksByAttachedType = res.reduce<
+ Record<ZAttachedByEnum, number>
+ >(
+ (acc, curr) => {
+ if (curr.attachedBy) {
+ acc[curr.attachedBy] += curr.count;
+ }
+ return acc;
+ },
+ { ai: 0, human: 0 },
+ );
+ return {
+ numBookmarks:
+ numBookmarksByAttachedType.ai + numBookmarksByAttachedType.human,
+ numBookmarksByAttachedType,
+ };
+ }
+
async getStats(): Promise<z.infer<typeof zGetTagResponseSchema>> {
const res = await this.ctx.db
.select({
id: bookmarkTags.id,
name: bookmarkTags.name,
attachedBy: tagsOnBookmarks.attachedBy,
+ count: count(),
})
.from(bookmarkTags)
.leftJoin(tagsOnBookmarks, eq(bookmarkTags.id, tagsOnBookmarks.tagId))
@@ -324,32 +351,17 @@ export class Tag implements PrivacyAware {
eq(bookmarkTags.id, this.tag.id),
eq(bookmarkTags.userId, this.ctx.user.id),
),
- );
+ )
+ .groupBy(tagsOnBookmarks.attachedBy);
if (res.length === 0) {
throw new TRPCError({ code: "NOT_FOUND" });
}
- const numBookmarksByAttachedType = res.reduce<
- Record<ZAttachedByEnum, number>
- >(
- (acc, curr) => {
- if (curr.attachedBy) {
- acc[curr.attachedBy]++;
- }
- return acc;
- },
- { ai: 0, human: 0 },
- );
-
return {
id: res[0].id,
name: res[0].name,
- numBookmarks: Object.values(numBookmarksByAttachedType).reduce(
- (s, a) => s + a,
- 0,
- ),
- numBookmarksByAttachedType,
+ ...Tag._aggregateStats(res),
};
}
diff --git a/packages/trpc/routers/tags.test.ts b/packages/trpc/routers/tags.test.ts
index a4d690ee..4004cc2c 100644
--- a/packages/trpc/routers/tags.test.ts
+++ b/packages/trpc/routers/tags.test.ts
@@ -20,6 +20,42 @@ describe("Tags Routes", () => {
expect(res.numBookmarks).toBeGreaterThanOrEqual(0);
});
+ test<CustomTestContext>("get tag returns bookmark stats", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ const firstBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/first",
+ type: BookmarkTypes.LINK,
+ });
+ const secondBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/second",
+ type: BookmarkTypes.LINK,
+ });
+
+ const firstAttachment = await bookmarksApi.updateTags({
+ bookmarkId: firstBookmark.id,
+ attach: [{ tagName: "stats-tag" }],
+ detach: [],
+ });
+
+ const tagId = firstAttachment.attached[0];
+
+ await bookmarksApi.updateTags({
+ bookmarkId: secondBookmark.id,
+ attach: [{ tagId }],
+ detach: [],
+ });
+
+ const stats = await tagsApi.get({ tagId });
+
+ expect(stats.numBookmarks).toBe(2);
+ expect(stats.numBookmarksByAttachedType.human).toBe(2);
+ expect(stats.numBookmarksByAttachedType.ai).toBe(0);
+ });
+
test<CustomTestContext>("get tag - not found", async ({ apiCallers }) => {
const api = apiCallers[0].tags;
await expect(() => api.get({ tagId: "nonExistentId" })).rejects.toThrow(
@@ -135,6 +171,44 @@ describe("Tags Routes", () => {
expect(res.tags.some((tag) => tag.name === "tag2")).toBeTruthy();
});
+ test<CustomTestContext>("list tags includes bookmark stats", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ const firstBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/list-first",
+ type: BookmarkTypes.LINK,
+ });
+ const secondBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/list-second",
+ type: BookmarkTypes.LINK,
+ });
+
+ const firstAttachment = await bookmarksApi.updateTags({
+ bookmarkId: firstBookmark.id,
+ attach: [{ tagName: "list-stats-tag" }],
+ detach: [],
+ });
+
+ const tagId = firstAttachment.attached[0];
+
+ await bookmarksApi.updateTags({
+ bookmarkId: secondBookmark.id,
+ attach: [{ tagId }],
+ detach: [],
+ });
+
+ const list = await tagsApi.list();
+ const tagStats = list.tags.find((tag) => tag.id === tagId);
+
+ expect(tagStats).toBeDefined();
+ expect(tagStats!.numBookmarks).toBe(2);
+ expect(tagStats!.numBookmarksByAttachedType.human).toBe(2);
+ expect(tagStats!.numBookmarksByAttachedType.ai).toBe(0);
+ });
+
test<CustomTestContext>("list tags - privacy", async ({ apiCallers }) => {
const apiUser1 = apiCallers[0].tags;
await apiUser1.create({ name: "user1Tag" });