Repository navigation
fix: deduplicate crawl jobs using idempotency key - #2678
MohamedBassem merged 4 commits into
Conversation
Prevent duplicate crawl jobs from being queued when a bookmark is refreshed multiple times. Uses the existing idempotencyKey support in the queue to silently drop duplicates. Closes karakeep-app#1012
WalkthroughAdded idempotency key support to crawler queue enqueues: enqueue calls now compute and pass an Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryDeduplicates recrawl jobs by adding payload-derived idempotency keys to all four Confidence Score: 5/5Safe to merge — clean, targeted fix with no blocking issues. All findings are P2 or lower. The key generation logic is correct: JSON.stringify with a sorted-keys array replacer produces canonical output regardless of insertion order, and distinct flag combinations yield distinct keys. The previous concern about archive jobs sharing keys with plain recrawls is fully resolved. Tests pass and cover the new expectation. No files require special attention. Important Files Changed
Reviews (2): Last reviewed commit: "refactor: extract buildCrawlIdempotencyK..." | Re-trigger Greptile |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
packages/trpc/routers/bookmarks.ts (1)
635-645:⚠️ Potential issue | 🟠 MajorCurrent key dedupes non-equivalent recrawls (
archiveFullPage/storePdf)At Line 644, two recrawl requests for the same bookmark but different options are treated as duplicates. That can silently drop the more specific request (e.g., full-page archive/PDF).
Suggested fix
await LowPriorityCrawlerQueue.enqueue( { bookmarkId: input.bookmarkId, archiveFullPage: input.archiveFullPage, storePdf: input.storePdf, }, { groupId: ctx.user.id, priority: QueuePriority.Low, - idempotencyKey: `crawl:${input.bookmarkId}`, + idempotencyKey: + `crawl:${input.bookmarkId}:intent:userRecrawl:archive:${input.archiveFullPage ? 1 : 0}:pdf:${input.storePdf ? 1 : 0}`, }, );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/trpc/routers/bookmarks.ts` around lines 635 - 645, The idempotency key used in LowPriorityCrawlerQueue.enqueue (`idempotencyKey: \`crawl:${input.bookmarkId}\``) collapses distinct recrawl requests that differ by archiveFullPage/storePdf; change the idempotency key to include those options so each unique option set is treated separately (e.g., incorporate input.archiveFullPage and input.storePdf values or a deterministic serialization of the options into the idempotencyKey) in the call to LowPriorityCrawlerQueue.enqueue.packages/trpc/routers/admin.ts (1)
231-239:⚠️ Potential issue | 🟠 MajorBookmark-only idempotency keys collapse distinct admin recrawl intents
At Line 238 and Line 681, keys are scoped only by bookmark ID. In
recrawlLinks,runInferenceis part of behavior but not part of the key, so different requested behaviors can be silently dropped as “duplicates.”Suggested fix
bookmarkIds.map((b) => LowPriorityCrawlerQueue.enqueue( { bookmarkId: b.id, runInference: input.runInference, }, { priority: QueuePriority.Low, - idempotencyKey: `crawl:${b.id}`, + idempotencyKey: `crawl:${b.id}:intent:adminBulkRecrawl:infer:${input.runInference ? 1 : 0}`, }, ), ), );await LowPriorityCrawlerQueue.enqueue( { bookmarkId: input.bookmarkId, }, { priority: QueuePriority.Low, groupId: "admin", - idempotencyKey: `crawl:${input.bookmarkId}`, + idempotencyKey: `crawl:${input.bookmarkId}:intent:adminRecrawl`, }, );Also applies to: 674-682
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/trpc/routers/admin.ts` around lines 231 - 239, The idempotency key used in LowPriorityCrawlerQueue.enqueue (currently `crawl:${b.id}` in recrawlLinks) only scopes by bookmarkId and causes different admin recrawl intents (e.g., differing runInference values) to collapse; update the idempotencyKey to include the runInference flag (or other distinguishing input properties) so each distinct intent is unique (for example incorporate input.runInference into the key generation used in LowPriorityCrawlerQueue.enqueue and any other enqueue sites such as the other occurrence around lines 674-682).packages/trpc/lib/ruleEngine.ts (1)
290-301:⚠️ Potential issue | 🟠 MajorIdempotency key is too coarse and can suppress full-page archive jobs
At Line 300, using only
crawl:${this.bookmark.id}makes this specialized action collide with other crawl intents for the same bookmark. If a normal recrawl is already queued, thisdownloadFullPageArchivejob can be dropped, so archive generation may never happen.Suggested fix
await LowPriorityCrawlerQueue.enqueue( { bookmarkId: this.bookmark.id, archiveFullPage: true, runInference: false, }, { groupId: this.bookmark.userId, priority: QueuePriority.Low, - idempotencyKey: `crawl:${this.bookmark.id}`, + idempotencyKey: `crawl:${this.bookmark.id}:intent:downloadFullPageArchive`, }, );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/trpc/lib/ruleEngine.ts` around lines 290 - 301, The idempotency key used when enqueuing the downloadFullPageArchive job is too generic ("crawl:${this.bookmark.id}") which causes LowPriorityCrawlerQueue.enqueue to deduplicate this specialized full-page archive task against other crawl jobs; update the idempotencyKey to include a specific marker for full-page archive (e.g., include "downloadFullPageArchive" or "archiveFullPage=true") so the enqueue call in the downloadFullPageArchive case produces a unique key and will not be suppressed by other crawl intents for the same bookmark (modify the idempotencyKey passed to LowPriorityCrawlerQueue.enqueue in the downloadFullPageArchive branch accordingly).
🧹 Nitpick comments (1)
packages/trpc/lib/__tests__/ruleEngine.test.ts (1)
602-617: Add a regression test for option-sensitive idempotency behaviorThis expectation is fine, but there’s no test guarding that different crawl intents/options for the same bookmark do not dedupe together. Please add coverage for at least one pair of different option sets.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/trpc/lib/__tests__/ruleEngine.test.ts` around lines 602 - 617, Add a regression test to ensure option-sensitive idempotency for crawl enqueues: call engine.executeAction (or trigger the same logic using RuleEngineAction) twice for the same bookmarkId with two different option sets (e.g., archiveFullPage: true vs archiveFullPage: false or runInference: true vs false) and assert that LowPriorityCrawlerQueue.enqueue is invoked for both calls with distinct idempotency keys (do not let both use the same `crawl:${bookmarkId}` key) — update or add an it(...) near the existing "downloadFullPageArchive" test to verify two separate enqueue calls or different idempotencyKey values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@packages/trpc/lib/ruleEngine.ts`:
- Around line 290-301: The idempotency key used when enqueuing the
downloadFullPageArchive job is too generic ("crawl:${this.bookmark.id}") which
causes LowPriorityCrawlerQueue.enqueue to deduplicate this specialized full-page
archive task against other crawl jobs; update the idempotencyKey to include a
specific marker for full-page archive (e.g., include "downloadFullPageArchive"
or "archiveFullPage=true") so the enqueue call in the downloadFullPageArchive
case produces a unique key and will not be suppressed by other crawl intents for
the same bookmark (modify the idempotencyKey passed to
LowPriorityCrawlerQueue.enqueue in the downloadFullPageArchive branch
accordingly).
In `@packages/trpc/routers/admin.ts`:
- Around line 231-239: The idempotency key used in
LowPriorityCrawlerQueue.enqueue (currently `crawl:${b.id}` in recrawlLinks) only
scopes by bookmarkId and causes different admin recrawl intents (e.g., differing
runInference values) to collapse; update the idempotencyKey to include the
runInference flag (or other distinguishing input properties) so each distinct
intent is unique (for example incorporate input.runInference into the key
generation used in LowPriorityCrawlerQueue.enqueue and any other enqueue sites
such as the other occurrence around lines 674-682).
In `@packages/trpc/routers/bookmarks.ts`:
- Around line 635-645: The idempotency key used in
LowPriorityCrawlerQueue.enqueue (`idempotencyKey:
\`crawl:${input.bookmarkId}\``) collapses distinct recrawl requests that differ
by archiveFullPage/storePdf; change the idempotency key to include those options
so each unique option set is treated separately (e.g., incorporate
input.archiveFullPage and input.storePdf values or a deterministic serialization
of the options into the idempotencyKey) in the call to
LowPriorityCrawlerQueue.enqueue.
---
Nitpick comments:
In `@packages/trpc/lib/__tests__/ruleEngine.test.ts`:
- Around line 602-617: Add a regression test to ensure option-sensitive
idempotency for crawl enqueues: call engine.executeAction (or trigger the same
logic using RuleEngineAction) twice for the same bookmarkId with two different
option sets (e.g., archiveFullPage: true vs archiveFullPage: false or
runInference: true vs false) and assert that LowPriorityCrawlerQueue.enqueue is
invoked for both calls with distinct idempotency keys (do not let both use the
same `crawl:${bookmarkId}` key) — update or add an it(...) near the existing
"downloadFullPageArchive" test to verify two separate enqueue calls or different
idempotencyKey values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b6ec43f8-e53f-4773-ace8-6ab7af399929
📒 Files selected for processing (4)
packages/trpc/lib/__tests__/ruleEngine.test.tspackages/trpc/lib/ruleEngine.tspackages/trpc/routers/admin.tspackages/trpc/routers/bookmarks.ts
| { | ||
| groupId: ctx.user.id, | ||
| priority: QueuePriority.Low, | ||
| idempotencyKey: `crawl:${input.bookmarkId}`, |
There was a problem hiding this comment.
This key also need to take into account the flags of the recrawlCall
There was a problem hiding this comment.
Makes sense — should I include all the flags in the key like
crawl:${bookmarkId}:${archiveFullPage}:${storePdf} so each unique crawl configuration gets its own dedup? Or
would you prefer a simpler approach like only differentiating when archiveFullPage or storePdf is true?
There was a problem hiding this comment.
Maybe we can stable JSON serialize the input as the safest most readable way?
There was a problem hiding this comment.
that's actually better stable JSON serialize is cleaner and future-proof. I'll update bookmarks.ts to use this
approach.
Quick question: should I do the same for the admin bulk recrawl? It has a similar pattern with a variant
flag:
// admin.ts line 231-238
LowPriorityCrawlerQueue.enqueue(
{ bookmarkId: b.id, runInference: input.runInference },
{ priority: QueuePriority.Low, idempotencyKey: `crawl:${b.id}` }
)
Currently runInference isn't part of the key either. Want me to apply the same stable-serialize approach
here too?
There was a problem hiding this comment.
done, pushed
| }; | ||
| return LowPriorityCrawlerQueue.enqueue(payload, { | ||
| priority: QueuePriority.Low, | ||
| idempotencyKey: `crawl:${JSON.stringify(payload, Object.keys(payload).sort())}`, |
There was a problem hiding this comment.
can you extract this into a shared util beside the queue definition called (buildCrawlIdempotencyKey) and use it in all the callsites for consistency?
There was a problem hiding this comment.
@MohamedBassem happy to extract the shared util. Did a full audit of all *CrawlerQueue.enqueue call-sites first — wanted to flag a few things before I touch code:
Call-sites that can use buildCrawlIdempotencyKey:
- bookmarks.ts:640 (user recrawl) — already JSON format ✅
- admin.ts:235 (bulk recrawl) — already JSON format ✅
- admin.ts:672 (admin single recrawl) — currently crawl:${bookmarkId}, legacy format
- ruleEngine.ts:291 (archive action) — currently crawl:archive:${bookmarkId}, different prefix
Call-site that cannot use it:
- bookmarks.ts:338 (fresh bookmark create) — no idempotency key today, and I don't think we want one here. Each new bookmark has a freshly-generated bookmarkId, so dedup is implicit. Adding a key would block legitimate retries
if the initial enqueue fails. Leaving this untouched unless you disagree.
Behavior changes I want to confirm before unifying:
- Site 3 (admin.ts:672): key changes from crawl:abc123 → crawl:{"bookmarkId":"abc123"}. In-memory queue state only, so impact is basically zero, but wanted to flag it. OK to switch?
- Site 4 (ruleEngine.ts): loses the archive: prefix and joins the generic crawl: namespace. As far as I can tell this is actually correct — a user-triggered recrawl with {archiveFullPage: true, storePdf: false} and a
rule-engine archive with {archiveFullPage: true, runInference: false} still serialize to different JSON, so they won't collide. And two rapid rule-engine fires would still dedupe (same as today). Net: same or better. OK to
drop the prefix?
Once you confirm (or correct) the above I'll push the refactor — util next to the queue definition in packages/shared-server/src/queues.ts, imports updated in all 4 call-sites + the rule engine test.
Addresses review feedback: extract the stable-serialized idempotency key construction into a single helper beside the crawler queue definition and use it from every crawler enqueue callsite (bookmarks recrawl, admin bulk recrawl, admin single recrawl, and the rule engine archive action). Keeps the key format consistent everywhere and lets future ZCrawlLinkRequest fields flow into the dedup key automatically.
|
Done — extracted Two behaviour notes worth flagging:
Full |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/shared-server/src/queues.ts (1)
115-117: Normalize payload before serialization to avoid dedupe misses on equivalent requests.Using raw
z.inputcan generate different keys for equivalent payloads (e.g., omitted vs explicit defaulted fields), which can reduce dedupe effectiveness.Suggested patch
export function buildCrawlIdempotencyKey(payload: ZCrawlLinkRequest): string { - return `crawl:${JSON.stringify(payload, Object.keys(payload).sort())}`; + const normalized = zCrawlLinkRequestSchema.parse(payload); + const stable = Object.fromEntries( + Object.keys(normalized) + .sort() + .map((k) => [k, normalized[k as keyof typeof normalized]]), + ); + return `crawl:${JSON.stringify(stable)}`; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/shared-server/src/queues.ts` around lines 115 - 117, The idempotency key uses the raw payload which can differ for semantically equivalent requests; update buildCrawlIdempotencyKey to first normalize the payload using the ZCrawlLinkRequest schema (e.g., call ZCrawlLinkRequest.parse or safeParse to apply defaults and coerce types) and then stringify it deterministically (e.g., JSON.stringify over Object.keys(sorted) or a stable serializer) so equivalent requests produce the same key; reference the buildCrawlIdempotencyKey function and ZCrawlLinkRequest schema when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/shared-server/src/queues.ts`:
- Around line 115-117: The idempotency key uses the raw payload which can differ
for semantically equivalent requests; update buildCrawlIdempotencyKey to first
normalize the payload using the ZCrawlLinkRequest schema (e.g., call
ZCrawlLinkRequest.parse or safeParse to apply defaults and coerce types) and
then stringify it deterministically (e.g., JSON.stringify over
Object.keys(sorted) or a stable serializer) so equivalent requests produce the
same key; reference the buildCrawlIdempotencyKey function and ZCrawlLinkRequest
schema when making the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4b4b3a17-6b3a-43b4-b55b-c81c420ad017
📒 Files selected for processing (5)
packages/shared-server/src/queues.tspackages/trpc/lib/__tests__/ruleEngine.test.tspackages/trpc/lib/ruleEngine.tspackages/trpc/routers/admin.tspackages/trpc/routers/bookmarks.ts
✅ Files skipped from review due to trivial changes (1)
- packages/trpc/lib/ruleEngine.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/trpc/routers/bookmarks.ts
- packages/trpc/routers/admin.ts
MohamedBassem
left a comment
There was a problem hiding this comment.
Thank you. The changes looks good to me. Will run CI, and another round of AI review and then merge if all good
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0cd9e40ac0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // key, and differing flags (archiveFullPage, runInference, storePdf) yield | ||
| // distinct keys so non-equivalent crawls are not deduped together. | ||
| export function buildCrawlIdempotencyKey(payload: ZCrawlLinkRequest): string { | ||
| return `crawl:${JSON.stringify(payload, Object.keys(payload).sort())}`; |
There was a problem hiding this comment.
Normalize crawl payload before computing idempotency key
buildCrawlIdempotencyKey hashes the raw enqueue payload, so semantically equivalent crawl requests can get different keys and bypass deduplication. For example, bookmarks.recrawlBookmark enqueues { bookmarkId, archiveFullPage: false, storePdf: false } while adminRecrawlBookmark enqueues { bookmarkId }; in crawlerWorker.runCrawler those missing flags are defaulted/treated equivalently, so both jobs do the same work but won't dedupe because their serialized payload differs. The same mismatch exists for omitted runInference vs runInference: true.
Useful? React with 👍 / 👎.
|
Thank you! |
* fix: add support for instapaper archived status and folders * fix(mobile): truncate bookmark title with tailwind shrink (karakeep-app#2699) * fix(mobile): remove react-native-keyboard-controller patch (karakeep-app#2697) The upstream fix for the status bar inset issue was merged and released in v1.21.5, so our local patch is no longer needed. Closes karakeep-app#2581 * docs(mobile): add SENTRY_DISABLE_AUTO_UPLOAD=true to build apk without sentry dsn (karakeep-app#2700) * docs(mobile): add SENTRY_DISABLE_AUTO_UPLOAD=true to build apk without sentry dsn * include pr suggestion * put SENTRY_DISABLE_AUTO_UPLOAD=true in package.json scripts instead * deps: upgrade to zod 4 (karakeep-app#2689) * deps: upgrade to zod 4 * fix: preserve zod 4 schema compatibility Use safeExtend where refined signup schemas are extended so the affected modules can initialize safely under Zod 4. Keep the generated OpenAPI output aligned with runtime validation and centralize the zod-to-openapi initialization. * chore: freshen up docker-compose.yml for local dev purposes (karakeep-app#2149) * feat(mobile): add keep screen on toggle for bookmark viewing (karakeep-app#2691) * feat(mobile): add keep screen on toggle for bookmark viewing Adds a Reading-section toggle that activates expo-keep-awake while a bookmark is open, covering all in-app views (reader, browser, screenshot, archive, pdf, text, asset). Defaults off; releases the wake lock as soon as the user navigates away from the bookmark detail screen. Closes karakeep-app#2650 * fix(mobile): disable keep-screen-on toggle while settings are hydrating Prevents writing stale default settings to SecureStore if the user toggles before hydration completes. * fix: add clawhub metadata to karakeep skill * fix: fix clawhub skill yaml * docs: add skills to docs * docs: Update worker list in the environment variable reference (karakeep-app#2710) * deps: upgrade oxlint and oxfmt * fix: add missing source to createBookmarks from import * feat: Add Readwise Reader import (karakeep-app#2690) Co-authored-by: Luis Zuniga <wichogzf@gmail.com> * fix: deduplicate crawl jobs using idempotency key (karakeep-app#2678) * feat: add OneTab import support (karakeep-app#2495) * feat(import): add OneTab import support Adds a OneTab import card to the ImportExport settings page. Implements a parser for OneTab's "URL | Title" line format with deduplication, and registers "onetab" as an import source. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add tests for the inference worker * fix: add ability to manually finalize/delete staged import sessions (karakeep-app#2713) * fix(docker): add `ffmpeg` as required by `yt-dlp` (karakeep-app#2721) * fix: invalidate existing password reset tokens before issuing a new one (karakeep-app#2722) - Wrap reset token replacement in a transaction - Delete any prior tokens for the user before inserting the new token * fix: add ratelimiting for the changePassword endpoint (karakeep-app#2723) * fix: sanitize user names before storing them (karakeep-app#2724) * Sanitize user names before storing them - Strip HTML from signup and OAuth display names - Enforce non-empty plain-text names in validation - Add tests for sanitized and HTML-only name inputs * more changes * Harden user name validation * more changes * fix: add data: to style-src and font-src CSP directives in serveAsset (karakeep-app#2657) Monolith-generated full-page archives inline stylesheets and fonts as data: URIs. The previous CSP blocked these, causing broken rendering. Adding data: to style-src and a new font-src directive unblocks them, matching the precedent set by img-src which already includes data:. Fixes: karakeep-app#2621 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: fix pnpm symlink conflict (karakeep-app#2725) * fix: include userId in ratelimit key if the user is authed (karakeep-app#2726) * feat(mcp): add update-bookmark tool (karakeep-app#2717) Wraps PATCH /bookmarks/{bookmarkId} so MCP clients can modify an existing bookmark instead of only creating/reading one. Exposes the commonly useful patchable fields (title, note, summary, archived, favourited, url, description, author, publisher, createdAt); less common/destructive ones (text, assetContent, datePublished, dateModified) are intentionally omitted to reduce accidental clobbering of crawler-populated data. The PATCH response schema does not include tags/content/assets, so the tool follows up with a GET to return a full compactBookmark view of the updated record. * fix: use relative path for logout redirect to avoid port mismatch (karakeep-app#2655) signOut({ redirect: false }) returns an absolute URL constructed from NEXTAUTH_URL (e.g. http://localhost:3000/). When Docker remaps an external port to the internal one, this sends users to the wrong port. Using router.push('/') lets Next.js resolve against the user's origin. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): auto-add bookmark to list on selection (karakeep-app#2629) * feat(web): auto-add bookmark to list on selection Remove the 'Add' button from Manage Lists modal. When a user selects a list from the dropdown, the bookmark is now automatically added to that list, simplifying the user experience. Users can still remove a bookmark from a list by clicking the X button next to the list name. Fixes karakeep-app#2604 * fix: add loading state and prevent duplicate submission in ManageListsModal - Added isPending state from useAddBookmarkToList hook - Added duplicate submission prevention in onChange handler - Added disabled prop to BookmarkListSelector component * style: fix indentation in useAddBookmarkToList callback - Fix incorrect indentation of onSuccess and onError callbacks - Add trailing newline at end of file * fix: address review comments for PR karakeep-app#2629 - Add trailing newline at end of file - Remove redundant in-flight guard check (disabled prop handles it) - Add guard for getPathById to prevent crash on stale cache - Disable interactions while list membership data is loading * fix: use translation for DialogTitle in ManageListsModal * fix: add isLoading guard to onChange callback in BookmarkListSelector * fix(web): improve ManageListsModal accessibility and rendering - Add aria-label to remove button for accessibility (CodeRabbit comment 3006737430) - Remove allLists gating to allow list.name fallback to render (CodeRabbit comment 3006737429) - Use optional chaining for path lookup * feat(extension): add manual save toggle for browser extension (karakeep-app#2693) * feat: add manual save toggle for browser extension * fix: add check for appending ellipses * chore: add PR template inspired from immich * chore: fix PR template path * feat: Add native single-file support to browser extension (karakeep-app#2197) * feat: Add native SingleFile support to browser extension This commit integrates single-file-core into the browser extension, allowing users to capture complete web page archives when saving bookmarks. * fix: truncate urls in crawler logs * feat: change the default new list emoji to folder * feat: add support for configuring OpenAI reasoning effort (karakeep-app#2718) * Add support for configuring OpenAI reasoning effort * Update docs' formatting Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * minor fixes * minor fixes --------- Co-authored-by: Luis Zuniga <wichogzf@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: skip url getContentType fetch if precrawledArchiveAssetId exists (karakeep-app#2580) * deps: upgrade astro to 6.1.6. fixes karakeep-app#2728 * fix: fix broken cache invalidation requiring refresh after adding a bookmark to a tag or a list * feat: add granular API key scopes (karakeep-app#2731) * fix: fix promise race leaks in the worker (karakeep-app#2737) * fix: change the archive button in the manage lists dialog to be a secondary button * chore: add portless pnpm scripts * feat(mobile): customizable reader toolbar (karakeep-app#2527) * feat(mobile): add auto-hiding reader toolbar with customizable actions and overflow menu Redesign the bookmark reader bottom bar with configurable toolbar actions, an overflow ellipsis menu using native MenuView, and auto-hide on scroll. Add toolbar settings page for reordering visible/overflow actions. Support iOS 26 glass pill styling and transparent headers with edge-to-edge content. * Update apps/mobile/components/bookmarks/BottomActions.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix comment * fix(mobile): address PR review findings for reader toolbar - Fix silent fall-through when sharing non-image/non-pdf assets - Pass contentInsetTop to browser/archive WebViews for iOS 26 - Auto-inject missing action IDs into overflow on settings load - Extract NAV_BAR_HEIGHT constant to replace magic number 44 - Remove unused ownerOnly/linkOnly from ToolbarActionMeta - Extract triggerHaptic() helper to handle async catch - Deduplicate image/PDF share logic with finally cleanup - Add console.warn for unknown menu action events - Simplify headerPlatformOptions to plain isIOS26 ternary - Fix inaccurate comment about top/bottom padding * remove margin around text view * fix bottom inset * fix reading progress bar regression, add small gap * Add redesigned reading progress pill, blur effect for iOS 18 and lower * small review fixes * fix icon colors in dark mode * Add missing @/lib/ios module for isIOS26 platform detection * downgrade expo-blur to match expo * Update to stable expo-glass-effect version * fix(mobile): close reader view after archiving bookmark * chore(mobile): remove auto-hiding header/footer and pill redesign from toolbar PR Split these concerns out so this branch lands only the customizable toolbar. Auto-hiding native header, auto-hiding bottom toolbar, and the redesigned continue-reading pill will return together in a follow-up reader-chrome PR, where they belong architecturally as a single feature. * fix footer padding on iOS 26 * unify handling of archive and fav in bottom actions * abstract share logic * dynamic labels depending on bookmark state * fix overflow submenu in android * review comments * feat: add metrics for sub status * chore: add infra for the event logger * feat: add exhaustive event logging * fix: fix nodejs bundling for the eventLogger * fix: upgrade zod resolver to fix form validations (karakeep-app#2744) * fix: bump up zod resolver * fix: follow greptile review * fix: reject user deletion with active subscription * chore: add event logging for bookmark summarization * fix: fix subscription tranisiton event logging * feat(mobile): revamp search and navigation chrome (karakeep-app#2685) * FAB styling looks good, still working on search * Finish pr * use latest expo-glass-effect version * feat(mobile): auto-focus search bar when iOS search tab gains focus * fix(mobile): address PR review feedback - Fix search history: save new terms on blur via useEffect in BookmarkSearchResults and index.ios.tsx onBlur handler - Fix keepPreviousData: use isPending instead of isFetching for spinner guards so stale results stay visible during refetches - Add enabled guard on search queries to avoid firing on empty input - Increase debounce from 10ms to 300ms on iOS search tab - Add accessibility labels/roles to close buttons in search and InlineSearch - Increase ProfileAvatarButton tap target to 44px minimum, add a11y props, convert static styles to Tailwind - Fix tags empty state to show "No tags match your search" when filtering - Fix stale comment in Android search stub * feat(mobile): prevent iOS tab bar from shrinking and resize FAB Disable tab bar minimize behavior on iOS so it stays at full height when scrolling. Enlarge the FAB to better match the native search button size and adjust its positioning. * fix broken ProfileAvatarButton on iOS due to bad CodeRabbit suggestion * fix(mobile): lock FAB glass effect to app color scheme Pin GlassView and BlurView to the app's current color scheme instead of adapting to background content, preventing the FAB from switching to light appearance over white content in dark mode. * fix(mobile): add tintColor to FAB glass effect for consistent appearance Use GlassView tintColor to prevent the FAB from adapting to background content, keeping it dark in dark mode and light in light mode regardless of what's behind it. * fix(mobile): use PlatformColor for FAB icon vibrancy on iOS Replace hardcoded foreground color with PlatformColor("label") so the FAB icon participates in UIKit vibrancy and stays legible against the adaptive liquid glass effect. Revert FAB GlassView to its clean default with no colorScheme or tintColor overrides. * fix(mobile): trim search term in onBlur before persisting The onBlur handler checked trim().length but passed the untrimmed value to addTerm(), allowing whitespace-padded terms into search history. * revert bad change in lists * revert tag spacing change Co-authored-by: Copilot <copilot@github.com> * remove unused search component * fix broken view switching in ios search results * drop the search screen stack from root layout * remove global search * search on type * remove duplicate expo-blur from package.json * restore header for ios --------- Co-authored-by: Copilot <copilot@github.com> * fix: debounce bulk query invalidations (karakeep-app#2745) * Debounce bulk query invalidations * Avoid scheduling predicate invalidations * Document predicate invalidation bypass * feat: Allow multiple lists for Rule Engine karakeep-app#2490 (karakeep-app#2620) * feat: Allow multiple lists for Rule Engine karakeep-app#2490 feat: Allow multiple lists for Rule Engine karakeep-app#2490 feat: Allow multiple lists for Rule Engine karakeep-app#2490 * re-add db migration * codex review comments * chore: use .gitignore as source of truth for oxfmt and oxlint (karakeep-app#2740) * chore: use .gitignore as source of truth for oxfmt and oxlint Wire `--ignore-path=../../.gitignore` into every per-package format/lint script and prune the duplicated `ignorePatterns` arrays from each oxfmt/oxlint config. For oxfmt in packages with a local `.gitignore` (mobile, browser-extension, cli, mcp, sdk, benchmarks, e2e_tests), pass the local file as a second `--ignore-path` too. oxlint only accepts one `--ignore-path`, so mobile retains `ios/`, `android/` etc. in its `ignorePatterns`. Observed issue: files in the `.astro/` directory in apps/landing were getting flagged by the format check in the precommit hook, because oxfmt running from the package cwd does not auto-discover the root `.gitignore` (it only reads a `.gitignore` in cwd). The explicit `--ignore-path` makes the gitignore policy consistent regardless of the invocation directory. * tests: add a test for browser rendering * fix(ui): resolve text clipping in bookmark cards (karakeep-app#2748) * fix: prevent text overflow * Update apps/web/components/dashboard/bookmarks/NotePreview.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * refactor: correct formatting --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * feat: redesign list and tag page headers (karakeep-app#2749) * feat: redesign list and tag page headers Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(landing): add safari links to landing * deps: upgrade protobufjs * fix: fix allow the adblocker to run after request interception * feat: Add agentic skills links to the landing page (karakeep-app#2761) * fix: fix SSRF handling with redirects in crawler worker (karakeep-app#2759) * feat(mobile): open links and images in in-app browser (karakeep-app#2696) * feat(mobile): open links and images in in-app browser instead of navigating away Links and media clicked within bookmark reader, browser, and archive views now open in an in-app browser overlay (expo-web-browser) or fullscreen image viewer instead of navigating the current view away from the bookmark content. * fix(mobile): address PR review comments for link handling Use origin-based comparison instead of one-shot hasLoaded flag in BookmarkLinkBrowserPreview to correctly handle HTTP redirects on iOS. Remove dead href.startsWith("#") branch in BookmarkHtmlHighlighterDom. Combine duplicate imports from react-native-webview/lib/WebViewTypes. * fix: format BookmarkLinkPreview import * fix: extract bookmarkUrl to preserve type narrowing in useCallback * fix(mobile): allow text selection on links for highlighting Strip href from links and store in data-href so the browser treats them as regular selectable text instead of activating native link gestures (iOS preview sheet, Android drag-and-drop). A MutationObserver re-strips hrefs after DOM changes from highlight effects. * feat: Add CLI auth config support (karakeep-app#2751) * fix: typo in docker docs (karakeep-app#2762) * fix: fix timezone handling in date displays across the app (karakeep-app#2682) * fix: use safe favicon resolution in metascraper (karakeep-app#2763) * fix: use correct env variable syntax for perplexity (karakeep-app#2765) * fix: use correct env variable syntax * fix: env syntax for perplexity base url * fix: harden video worker redirect validation against SSRF (karakeep-app#2760) * refactor: refactor bulk actions to store bookmark ids (karakeep-app#2767) * fix(mobile): fix scrolling in manage tags page in ios * docs: add a comment to security docs about SSRF mitigations * feat: add keyboard shortcuts (karakeep-app#2750) * i18n: Sync weblate translations Co-authored-by: CircleCrop <guhengting@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: OpenAI <noreply-mt-openai@weblate.org> Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/ar/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/cs/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/da/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/de/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/el/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/en_US/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/es/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/fa/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/fi/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/fr/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/ga/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/gl/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/hr/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/hu/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/it/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/ja/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/ko/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/nb_NO/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/nl/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/pl/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/pt/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/ru/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/sk/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/sl/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/sv/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/tr/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/uk/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/vi/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/karakeep/karakeep/zh_Hant/ Translation: Karakeep/Karakeep * i18n: fix automated i18n strings * feat(web): add more details to the all lists page * fix(crawler): proper handling for proxy auth with CDP interceptions * release(docs): release the 0.32 docs * release: release cli, sdk, mobile and extension * release(cli): release CLI 0.32.0 * docs: minor docs update to mention the skills and the new singlefile integration * feat(browser-extension): request host permission at runtime for client side crawling (karakeep-app#2784) Move `<all_urls>` from `host_permissions` to `optional_host_permissions` so it isn't requested at install time. The permission is now requested via `chrome.permissions.request()` when the user enables client-side crawling in the extension settings, and removed when they disable it. The settings toggle and the capture path both gate on whether the permission is actually granted. https://claude.ai/code/session_01NnQnr1B9KSRriTaQkZM6AP Co-authored-by: Claude <noreply@anthropic.com> * release(extension): Release version 1.2.11 * docs: update screenshots in the docs * fix(mobile): fix insets on new iOS settings formSheet modal (karakeep-app#2793) The settings screen is presented as an iOS formSheet with an opaque, non-large header (since 649efb8). iOS sizes the formSheet's inner content view ~headerHeight pixels taller than the visible sheet area, so the bottom of the settings list was clipped behind the sheet's bottom edge. contentInsetAdjustmentBehavior="automatic" correctly handles the top (adding the right top inset to clear the opaque header), but does not add a corresponding bottom inset for the formSheet's invisible bottom region. Adding useHeaderHeight() to paddingBottom pushes the trailing content up to a visible position. * fix(mobile): stack Upload Image Quality slider on two lines (karakeep-app#2794) The MEDIA → Upload Image Quality row in mobile settings crammed the label, percentage, and slider onto one horizontal line, leaving the slider only a few dozen pixels wide on phone widths. Switch the row to a vertical layout: label + percentage on top, full-width slider beneath. * feat(mobile): tablet/iPad UI polish for toolbar and FAB (karakeep-app#2788) * Set max width for reader toolbar on tablets * fix(mobile): remove FAB bottom inset on iPad iPad's tab bar geometry doesn't need the extra 57–60pt lift the iPhone FAB uses. * fix(mobile): give reader toolbar icons proper spacing on iPad Replace the inner row's width/maxWidth + space-between approach with an intrinsic gap. The earlier cap caused the row to collapse to its natural icon width inside the hug-fit GlassView, leaving icons cramped against each other. Using `gap: 28` gives each icon real breathing room and lets the pill size to its contents naturally. * fix(mobile): fix formsheet padding in manage_lists, manage_tags and info sheets * fix(mobile): fix color of list separator in recent searches list * fix(mobile): improve perf of manage_tags page * release(mobile): release mobile v1.9.4 * fix: serve unoptimized nextjs images This is a temporary fix to be able to patch the recent NextJS security update until a proper fix is available. * deps: upgrade nextjs to 15.5.18 * docs: update SECURITY.md * feat(db): add PostgreSQL migration for upstream schema sync Adds PG equivalent of the SQLite migrations 0083 (apiKey scopes) and 0084 (rule engine multi-list refactor) brought in by the upstream merge. The auto-generated migration was missing two things needed for parity with the SQLite version: 1. A SQL DEFAULT for apiKey.scopes to backfill existing rows (matches SQLite's `DEFAULT '["fullaccess"]'`). 2. Data migration for the event JSON in ruleEngineRules: delete rules with empty listId in addedToList/removedFromList events, then transform single listId to a listIds array. Both data-migration steps mirror what upstream's SQLite migration does using SQLite's json_extract/json_set, translated to PG's jsonb operators. Verified with `drizzle-kit check` (schema/migration consistent) and the schema-sync test (column lists match across dialects). * fix(docker): pin postgres overlay to v18 and fix volume mount Bumps the PostgreSQL overlay from postgres:16-alpine to postgres:18-alpine to match the production deployment, and fixes the volume mount that PG18 rejects. PG18+ stores data in a version-specific subdirectory under /var/lib/postgresql (e.g. /var/lib/postgresql/18/docker). The image refuses to start if the volume is mounted at the legacy /var/lib/postgresql/data path, even on a fresh install. Mounting one level up lets PG18 manage its own subdirectory layout. Also documents the migration-smoke-test command pattern in the overlay header: pair the overlay with a base compose file and `up postgres -d` to bring up just postgres, then run `pnpm --filter=@karakeep/db migrate` from the host. Verified end-to-end against postgres:18-alpine (PG 18.4). Upgrading an existing PG16 fork deployment to this overlay is a breaking change — the volume layout is incompatible and requires pg_upgrade or a dump/restore. --------- Co-authored-by: Mohamed Bassem <me@mbassem.com> Co-authored-by: xuatz <xzlow10@gmail.com> Co-authored-by: Evan Simkowitz <esimkowitz@users.noreply.github.com> Co-authored-by: Max <43565398+maxim-mityutko@users.noreply.github.com> Co-authored-by: Luis G. Zúñiga <github@nuevenueves.com> Co-authored-by: Luis Zuniga <wichogzf@gmail.com> Co-authored-by: Nikhil Chowdhury <54472147+NikhilChowdhury27@users.noreply.github.com> Co-authored-by: apo-mak <apo-mak@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Maxime Pinot <contact@maximepinot.com> Co-authored-by: Om Mishra <152969928+howwohmm@users.noreply.github.com> Co-authored-by: Vladimir Balko <147043796+vbalko-claimate@users.noreply.github.com> Co-authored-by: 冰水泡枸杞 <87858694+xingzihai@users.noreply.github.com> Co-authored-by: Morgan Kesler <keslerm@aegiszero.is> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: GSAlex <GSAlex@users.noreply.github.com> Co-authored-by: Beluga <mikhailbialouwork@gmail.com> Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Ahmed Abdelkarim <ahmad.abdelkarim89@gmail.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Mark :) <Mxrk@users.noreply.github.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: CircleCrop <guhengting@gmail.com> Co-authored-by: OpenAI <noreply-mt-openai@weblate.org>
Closes #1012
Summary
If you hit "Refresh" on a bookmark a few times quickly, each click queues
a separate crawl job — so the same page gets crawled 3, 5, or 10 times
doing the exact same work. This wastes resources since crawling is the
most expensive operation (browser launch, navigation, screenshots, archival).
The fix is straightforward: pass
idempotencyKey: crawl:${bookmarkId}whenenqueuing crawl jobs. Both queue backends (liteque and restate) already
support this — if a job with the same key is already queued, the duplicate
gets silently dropped. Same pattern that
triggerSearchReindexalreadyuses with
index:${bookmarkId}.What changed
Added
idempotencyKeyto all 4 places that enqueue crawl jobs:packages/trpc/routers/bookmarks.ts— user "Refresh" buttonpackages/trpc/routers/admin.ts— admin bulk recrawl + single recrawlpackages/trpc/lib/ruleEngine.ts— rule engine "download full page archive"packages/trpc/lib/__tests__/ruleEngine.test.ts— updated test expectationTesting
bookmark → 1 pending job, 9 dropped