diff options
Diffstat (limited to 'packages/trpc/lib')
| -rw-r--r-- | packages/trpc/lib/__tests__/ruleEngine.test.ts | 70 | ||||
| -rw-r--r-- | packages/trpc/lib/__tests__/search.test.ts | 55 | ||||
| -rw-r--r-- | packages/trpc/lib/attachments.ts | 10 | ||||
| -rw-r--r-- | packages/trpc/lib/ruleEngine.ts | 31 | ||||
| -rw-r--r-- | packages/trpc/lib/search.ts | 91 | ||||
| -rw-r--r-- | packages/trpc/lib/tracing.ts | 63 |
6 files changed, 285 insertions, 35 deletions
diff --git a/packages/trpc/lib/__tests__/ruleEngine.test.ts b/packages/trpc/lib/__tests__/ruleEngine.test.ts index ede22ec6..b737e3a5 100644 --- a/packages/trpc/lib/__tests__/ruleEngine.test.ts +++ b/packages/trpc/lib/__tests__/ruleEngine.test.ts @@ -126,6 +126,7 @@ describe("RuleEngine", () => { .values({ userId, type: BookmarkTypes.LINK, + title: "Example Bookmark Title", favourited: false, archived: false, }) @@ -171,10 +172,9 @@ describe("RuleEngine", () => { expect(engine).toBeInstanceOf(RuleEngine); }); - it("should throw an error if bookmark is not found", async () => { - await expect( - RuleEngine.forBookmark(ctx, "nonexistent-bookmark"), - ).rejects.toThrow("Bookmark nonexistent-bookmark not found"); + it("should return null if bookmark is not found", async () => { + const engine = await RuleEngine.forBookmark(ctx, "nonexistent-bookmark"); + expect(engine).toBeNull(); }); it("should load rules associated with the bookmark's user", async () => { @@ -188,7 +188,7 @@ describe("RuleEngine", () => { actions: [{ type: "addTag", tagId: tagId2 }], }); - const engine = await RuleEngine.forBookmark(ctx, bookmarkId); + const engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; // @ts-expect-error Accessing private property for test verification expect(engine.rules).toHaveLength(1); // @ts-expect-error Accessing private property for test verification @@ -200,7 +200,7 @@ describe("RuleEngine", () => { let engine: RuleEngine; beforeEach(async () => { - engine = await RuleEngine.forBookmark(ctx, bookmarkId); + engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; }); it("should return true for urlContains condition", () => { @@ -219,6 +219,54 @@ describe("RuleEngine", () => { expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); }); + it("should return false for urlDoesNotContain condition when URL contains string", () => { + const condition: RuleEngineCondition = { + type: "urlDoesNotContain", + str: "example.com", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for urlDoesNotContain condition when URL does not contain string", () => { + const condition: RuleEngineCondition = { + type: "urlDoesNotContain", + str: "nonexistent", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return true for titleContains condition", () => { + const condition: RuleEngineCondition = { + type: "titleContains", + str: "Example", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for titleContains condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "titleContains", + str: "nonexistent", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return false for titleDoesNotContain condition when title contains string", () => { + const condition: RuleEngineCondition = { + type: "titleDoesNotContain", + str: "Example", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for titleDoesNotContain condition when title does not contain string", () => { + const condition: RuleEngineCondition = { + type: "titleDoesNotContain", + str: "nonexistent", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + it("should return true for importedFromFeed condition", () => { const condition: RuleEngineCondition = { type: "importedFromFeed", @@ -271,7 +319,7 @@ describe("RuleEngine", () => { .update(bookmarks) .set({ favourited: true }) .where(eq(bookmarks.id, bookmarkId)); - const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId); + const updatedEngine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; const condition: RuleEngineCondition = { type: "isFavourited" }; expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true); }); @@ -286,7 +334,7 @@ describe("RuleEngine", () => { .update(bookmarks) .set({ archived: true }) .where(eq(bookmarks.id, bookmarkId)); - const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId); + const updatedEngine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; const condition: RuleEngineCondition = { type: "isArchived" }; expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true); }); @@ -354,7 +402,7 @@ describe("RuleEngine", () => { } as Omit<RuleEngineRule, "id"> & { userId: string }; ruleId = await seedRule(tmp); testRule = { ...tmp, id: ruleId }; - engine = await RuleEngine.forBookmark(ctx, bookmarkId); + engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; }); it("should evaluate rule successfully when event and conditions match", async () => { @@ -443,7 +491,7 @@ describe("RuleEngine", () => { let engine: RuleEngine; beforeEach(async () => { - engine = await RuleEngine.forBookmark(ctx, bookmarkId); + engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; }); it("should execute addTag action", async () => { @@ -625,7 +673,7 @@ describe("RuleEngine", () => { actions: [{ type: "addToList", listId: listId1 }], }); - engine = await RuleEngine.forBookmark(ctx, bookmarkId); + engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!; }); it("should process event and return only results for matching, enabled rules", async () => { 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/attachments.ts b/packages/trpc/lib/attachments.ts index 25d9be94..f3170c22 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -9,6 +9,7 @@ import { export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType { const map: Record<AssetTypes, z.infer<typeof zAssetTypesSchema>> = { [AssetTypes.LINK_SCREENSHOT]: "screenshot", + [AssetTypes.LINK_PDF]: "pdf", [AssetTypes.ASSET_SCREENSHOT]: "assetScreenshot", [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive", [AssetTypes.LINK_PRECRAWLED_ARCHIVE]: "precrawledArchive", @@ -17,6 +18,7 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType { [AssetTypes.LINK_HTML_CONTENT]: "linkHtmlContent", [AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset", [AssetTypes.USER_UPLOADED]: "userUploaded", + [AssetTypes.AVATAR]: "avatar", [AssetTypes.BACKUP]: "unknown", // Backups are not displayed as regular assets [AssetTypes.UNKNOWN]: "bannerImage", }; @@ -28,6 +30,7 @@ export function mapSchemaAssetTypeToDB( ): AssetTypes { const map: Record<ZAssetType, AssetTypes> = { screenshot: AssetTypes.LINK_SCREENSHOT, + pdf: AssetTypes.LINK_PDF, assetScreenshot: AssetTypes.ASSET_SCREENSHOT, fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE, precrawledArchive: AssetTypes.LINK_PRECRAWLED_ARCHIVE, @@ -36,6 +39,7 @@ export function mapSchemaAssetTypeToDB( bookmarkAsset: AssetTypes.BOOKMARK_ASSET, linkHtmlContent: AssetTypes.LINK_HTML_CONTENT, userUploaded: AssetTypes.USER_UPLOADED, + avatar: AssetTypes.AVATAR, unknown: AssetTypes.UNKNOWN, }; return map[assetType]; @@ -44,6 +48,7 @@ export function mapSchemaAssetTypeToDB( export function humanFriendlyNameForAssertType(type: ZAssetType) { const map: Record<ZAssetType, string> = { screenshot: "Screenshot", + pdf: "PDF", assetScreenshot: "Asset Screenshot", fullPageArchive: "Full Page Archive", precrawledArchive: "Precrawled Archive", @@ -52,6 +57,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) { bookmarkAsset: "Bookmark Asset", linkHtmlContent: "HTML Content", userUploaded: "User Uploaded File", + avatar: "Avatar", unknown: "Unknown", }; return map[type]; @@ -60,6 +66,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) { export function isAllowedToAttachAsset(type: ZAssetType) { const map: Record<ZAssetType, boolean> = { screenshot: true, + pdf: true, assetScreenshot: true, fullPageArchive: false, precrawledArchive: true, @@ -68,6 +75,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) { bookmarkAsset: false, linkHtmlContent: false, userUploaded: true, + avatar: false, unknown: false, }; return map[type]; @@ -76,6 +84,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) { export function isAllowedToDetachAsset(type: ZAssetType) { const map: Record<ZAssetType, boolean> = { screenshot: true, + pdf: true, assetScreenshot: true, fullPageArchive: true, precrawledArchive: true, @@ -84,6 +93,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) { bookmarkAsset: false, linkHtmlContent: false, userUploaded: true, + avatar: false, unknown: false, }; return map[type]; diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts index c191619b..acfd747e 100644 --- a/packages/trpc/lib/ruleEngine.ts +++ b/packages/trpc/lib/ruleEngine.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import { bookmarks, tagsOnBookmarks } from "@karakeep/db/schema"; import { LinkCrawlerQueue } from "@karakeep/shared-server"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { RuleEngineAction, RuleEngineCondition, @@ -21,6 +22,7 @@ async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) { link: { columns: { url: true, + title: true, }, }, text: true, @@ -60,13 +62,26 @@ export class RuleEngine { private rules: RuleEngineRule[], ) {} - static async forBookmark(ctx: AuthedContext, bookmarkId: string) { + private get bookmarkTitle(): string { + return ( + this.bookmark.title ?? + (this.bookmark.type === BookmarkTypes.LINK + ? this.bookmark.link?.title + : "") ?? + "" + ); + } + + static async forBookmark( + ctx: AuthedContext, + bookmarkId: string, + ): Promise<RuleEngine | null> { const [bookmark, rules] = await Promise.all([ fetchBookmark(ctx.db, bookmarkId), RuleEngineRuleModel.getAll(ctx), ]); if (!bookmark) { - throw new Error(`Bookmark ${bookmarkId} not found`); + return null; } return new RuleEngine( ctx, @@ -83,6 +98,18 @@ export class RuleEngine { case "urlContains": { return (this.bookmark.link?.url ?? "").includes(condition.str); } + case "urlDoesNotContain": { + return ( + this.bookmark.type == BookmarkTypes.LINK && + !(this.bookmark.link?.url ?? "").includes(condition.str) + ); + } + case "titleContains": { + return this.bookmarkTitle.includes(condition.str); + } + case "titleDoesNotContain": { + return !this.bookmarkTitle.includes(condition.str); + } case "importedFromFeed": { return this.bookmark.rssFeeds.some( (f) => f.rssFeedId === condition.feedId, diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index 88f10f22..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), ), ); } @@ -373,15 +403,31 @@ async function getIds( ), ); } + case "source": { + return db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, userId), + matcher.inverse + ? or( + ne(bookmarks.source, matcher.source), + isNull(bookmarks.source), + ) + : eq(bookmarks.source, matcher.source), + ), + ); + } 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); } @@ -395,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); } diff --git a/packages/trpc/lib/tracing.ts b/packages/trpc/lib/tracing.ts new file mode 100644 index 00000000..7b4fb39f --- /dev/null +++ b/packages/trpc/lib/tracing.ts @@ -0,0 +1,63 @@ +import { SpanKind } from "@opentelemetry/api"; + +import { + getTracer, + setSpanAttributes, + withSpan, +} from "@karakeep/shared-server"; +import serverConfig from "@karakeep/shared/config"; + +import type { Context } from "../index"; + +const tracer = getTracer("@karakeep/trpc"); + +/** + * tRPC middleware that creates a span for each procedure call. + * This integrates OpenTelemetry tracing into the tRPC layer. + */ +export function createTracingMiddleware() { + return async function tracingMiddleware<T>(opts: { + ctx: Context; + type: "query" | "mutation" | "subscription"; + path: string; + input: unknown; + next: () => Promise<T>; + }): Promise<T> { + // Skip if tracing is disabled + if (!serverConfig.tracing.enabled) { + return opts.next(); + } + + const spanName = `trpc.${opts.type}.${opts.path}`; + + return withSpan( + tracer, + spanName, + { + kind: SpanKind.SERVER, + attributes: { + "rpc.system": "trpc", + "rpc.method": opts.path, + "rpc.type": opts.type, + "user.id": opts.ctx.user?.id ?? "anonymous", + "user.role": opts.ctx.user?.role ?? "none", + }, + }, + async () => { + return await opts.next(); + }, + ); + }; +} + +/** + * Helper to add tracing attributes within a tRPC procedure. + * Use this to add custom attributes to the current span. + */ +export function addTracingAttributes( + attributes: Record<string, string | number | boolean>, +): void { + if (serverConfig.tracing.enabled) { + setSpanAttributes(attributes); + } +} |
