diff --git a/packages/api/src/routers/__tests__/articles-filters.test.ts b/packages/api/src/routers/__tests__/articles-filters.test.ts index 8380479..e6eef4c 100644 --- a/packages/api/src/routers/__tests__/articles-filters.test.ts +++ b/packages/api/src/routers/__tests__/articles-filters.test.ts @@ -606,6 +606,41 @@ describe("Articles Router - Subscription Filters", () => { expect(page2.items).toHaveLength(3); expect(page2.items.every((a) => a.title.includes("Match"))).toBe(true); }); + + it("should continue scanning when aggressive filters skip the first chunk", async () => { + await db.insert(schema.subscriptionFilters).values({ + subscriptionId: testSubscription2.id, + field: "title", + matchType: "contains", + pattern: "needle", + caseSensitive: false, + }); + + const totalArticles = 160; + for (let i = 0; i < totalArticles; i++) { + await db.insert(schema.articles).values({ + sourceId: testSource2.id, + guid: `aggressive-filter-${i}`, + title: i === 0 || i === 150 ? `Needle ${i}` : `Noise ${i}`, + description: "Test description", + content: "Test content", + author: "Test Author", + publishedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, totalArticles - i)), + }); + } + + const caller = createCaller(); + + const page1 = await caller.list({ limit: 1, cursor: 0 }); + expect(page1.items).toHaveLength(1); + expect(page1.items[0].title).toBe("Needle 0"); + expect(page1.hasMore).toBe(true); + + const page2 = await caller.list({ limit: 1, cursor: 1 }); + expect(page2.items).toHaveLength(1); + expect(page2.items[0].title).toBe("Needle 150"); + expect(page2.hasMore).toBe(false); + }); }); describe("Category Filtering", () => { diff --git a/packages/api/src/routers/articles.ts b/packages/api/src/routers/articles.ts index 72b854f..a9e4702 100644 --- a/packages/api/src/routers/articles.ts +++ b/packages/api/src/routers/articles.ts @@ -349,59 +349,31 @@ export const articlesRouter = router({ ); total = uniqueArticleIds.size; } else { - // FILTERED PATH: Has subscription filters, must fetch more and filter - // NOTE: Cursor-based pagination is incompatible with post-query filtering - // because the cursor represents items seen by frontend (after filtering), - // but the backend offset operates on items before filtering. - // We ONLY use offset-based pagination here to avoid skipping articles. - const fetchLimit = Math.max(limit * 3, 100); + // FILTERED PATH: Has subscription filters, must scan in bounded + // database chunks and apply the cursor after in-memory filtering. + // Aggressive filters can reject most rows, so a one-shot fetch limit + // can falsely produce an empty later page even when matches exist. + const filteredOffset = cursor ?? offset; + const targetVisibleCount = filteredOffset + limit + 1; + const chunkSize = Math.max(limit * 3, 100); + const filteredResults: ArticleWithSubscription[] = []; + let scannedRowCount = 0; // Always order by publishedAt for chronological feed - let paginationQuery = queryBuilder.orderBy( + const paginationQuery = queryBuilder.orderBy( desc(schema.articles.publishedAt) ); - // IMPORTANT: Only use explicit offset parameter, ignore cursor - // When subscription filters are active, cursor values don't align with database offsets - if (offset > 0) { - paginationQuery = paginationQuery.offset(offset); - } - - const results = await withQueryMetrics( - "articles.list", - async () => paginationQuery.limit(fetchLimit), - { - "db.table": "articles", - "db.operation": "select", - "db.user_id": userId, - "db.has_category_filter": !!input.categoryId, - "db.has_subscription_filter": !!input.subscriptionId, - "db.has_read_filter": input.read !== undefined, - "db.has_saved_filter": input.saved !== undefined, - "db.has_subscription_filters": true, - "db.use_cursor": !!cursor, - } - ); - - // Transform results - const transformedResults = results.map(transformArticleRow); - - // Load filters for subscriptions that have filtering enabled - const subscriptionIdsWithFilters = new Set(); - transformedResults.forEach((article) => { - if (article._subscription.filterEnabled) { - subscriptionIdsWithFilters.add(article._subscription.id); - } - }); - - // Batch load all filters for these subscriptions + // Batch load all filters for the user's filtered subscriptions once. const filtersBySubscription = new Map< number, (typeof schema.subscriptionFilters.$inferSelect)[] >(); + const subscriptionIdsWithFilters = subscriptionsWithFilters.map( + (subscription) => subscription.id + ); - if (subscriptionIdsWithFilters.size > 0) { - const subscriptionIdsArray = Array.from(subscriptionIdsWithFilters); + if (subscriptionIdsWithFilters.length > 0) { const filters = await withQueryMetrics( "articles.list.loadFilters", async () => @@ -411,13 +383,13 @@ export const articlesRouter = router({ .where( inArray( schema.subscriptionFilters.subscriptionId, - subscriptionIdsArray + subscriptionIdsWithFilters ) ), { "db.table": "subscription_filters", "db.operation": "select", - "db.subscription_count": subscriptionIdsArray.length, + "db.subscription_count": subscriptionIdsWithFilters.length, } ); @@ -430,35 +402,74 @@ export const articlesRouter = router({ }); } - // Apply subscription filters - const filteredResults = transformedResults.filter((article) => { - if (!article._subscription.filterEnabled) { - // No filtering enabled for this subscription - return true; + while (filteredResults.length < targetVisibleCount) { + let chunkQuery = paginationQuery; + if (scannedRowCount > 0) { + chunkQuery = chunkQuery.offset(scannedRowCount); } - const filters = - filtersBySubscription.get(article._subscription.id) || []; - return matchesSubscriptionFilters( - article, - filters, - article._subscription.filterMode + const results = await withQueryMetrics( + "articles.list.filteredChunk", + async () => chunkQuery.limit(chunkSize), + { + "db.table": "articles", + "db.operation": "select", + "db.user_id": userId, + "db.has_category_filter": !!input.categoryId, + "db.has_subscription_filter": !!input.subscriptionId, + "db.has_read_filter": input.read !== undefined, + "db.has_saved_filter": input.saved !== undefined, + "db.has_subscription_filters": true, + "db.use_cursor": !!cursor, + "db.filtered_offset": filteredOffset, + "db.chunk_offset": scannedRowCount, + "db.chunk_size": chunkSize, + } ); - }); + + if (results.length === 0) { + break; + } + + const transformedResults = results.map(transformArticleRow); + const matchingResults = transformedResults.filter((article) => { + if (!article._subscription.filterEnabled) { + // No filtering enabled for this subscription + return true; + } + + const filters = + filtersBySubscription.get(article._subscription.id) || []; + return matchesSubscriptionFilters( + article, + filters, + article._subscription.filterMode + ); + }); + + filteredResults.push(...matchingResults); + scannedRowCount += results.length; + + if (results.length < chunkSize) { + break; + } + } // Remove the internal _subscription field before returning const cleanedResults = filteredResults.map( ({ _subscription, ...article }) => article ); + const visibleResults = cleanedResults.slice(filteredOffset); + // Check if we have more than requested (for hasMore) - hasMore = cleanedResults.length > limit; + hasMore = visibleResults.length > limit; // Return only the requested number of items - paginatedResults = cleanedResults.slice(0, limit); + paginatedResults = visibleResults.slice(0, limit); // Total is approximate when subscription filters are active - total = cleanedResults.length + offset; + total = filteredOffset + visibleResults.length; } return { diff --git a/packages/app/Dockerfile b/packages/app/Dockerfile index 8377244..ab5d1c5 100644 --- a/packages/app/Dockerfile +++ b/packages/app/Dockerfile @@ -20,8 +20,11 @@ COPY packages/tricorder/package.json ./packages/tricorder/ # Install dependencies RUN pnpm install --frozen-lockfile -# Copy source code +# Copy source code. The app imports API types through the workspace package, +# and the API package references tricorder types during TypeScript checking. COPY packages/app ./packages/app +COPY packages/api ./packages/api +COPY packages/tricorder ./packages/tricorder # Build arguments ARG VITE_API_URL=http://localhost:3001/trpc diff --git a/packages/app/src/components/app/article-item.test.tsx b/packages/app/src/components/app/article-item.test.tsx index af2cfe2..27b24f4 100644 --- a/packages/app/src/components/app/article-item.test.tsx +++ b/packages/app/src/components/app/article-item.test.tsx @@ -354,6 +354,24 @@ describe("ArticleItem", () => { expect(itemElement).toBeInTheDocument(); }); + it("updates read and saved controls when article props change", () => { + const { rerender } = render(); + + expect( + screen.getByRole("button", { name: /mark read/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument(); + + rerender( + + ); + + expect( + screen.getByRole("button", { name: /mark unread/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /saved/i })).toBeInTheDocument(); + }); + it("displays article image when imageUrl is provided", () => { render(); diff --git a/packages/app/src/components/app/article-item.tsx b/packages/app/src/components/app/article-item.tsx index 276728a..64040b8 100644 --- a/packages/app/src/components/app/article-item.tsx +++ b/packages/app/src/components/app/article-item.tsx @@ -49,10 +49,9 @@ export function ArticleItem({ article, className }: ArticleItemProps) { const saveArticle = useSaveArticle(); const unsaveArticle = useUnsaveArticle(); - // Local state for optimistic updates - initialize from article state - const [isSaved, setIsSaved] = useState(article.saved || false); - const [isRead, setIsRead] = useState(article.read || false); const [isDragging, setIsDragging] = useState(false); + const isSaved = article.saved || false; + const isRead = article.read || false; // Check if this is an audio article (after all hooks) if (article.audioUrl) { @@ -64,10 +63,8 @@ export function ArticleItem({ article, className }: ArticleItemProps) { e.stopPropagation(); if (article.id) { if (isRead) { - setIsRead(false); markUnread.mutate({ id: article.id }); } else { - setIsRead(true); markRead.mutate({ id: article.id }); } } @@ -77,10 +74,8 @@ export function ArticleItem({ article, className }: ArticleItemProps) { e.stopPropagation(); if (article.id) { if (isSaved) { - setIsSaved(false); unsaveArticle.mutate({ id: article.id }); } else { - setIsSaved(true); saveArticle.mutate({ id: article.id }); } } @@ -137,10 +132,8 @@ export function ArticleItem({ article, className }: ArticleItemProps) { setIsDragging(true); if (article.id) { if (isRead) { - setIsRead(false); markUnread.mutate({ id: article.id }); } else { - setIsRead(true); markRead.mutate({ id: article.id }); } } @@ -150,7 +143,6 @@ export function ArticleItem({ article, className }: ArticleItemProps) { onSwipeLeft={() => { setIsDragging(true); if (article.id) { - setIsSaved(!isSaved); if (isSaved) { unsaveArticle.mutate({ id: article.id }); } else { diff --git a/packages/app/src/lib/auth-client.ts b/packages/app/src/lib/auth-client.ts index 41bb817..be3572f 100644 --- a/packages/app/src/lib/auth-client.ts +++ b/packages/app/src/lib/auth-client.ts @@ -12,9 +12,20 @@ import type { createAuth } from "@tuvixrss/api"; // Better Auth needs to point to the API server, not the frontend // VITE_API_URL is like "http://localhost:3001/trpc", so we extract the origin const viteApiUrl = import.meta.env.VITE_API_URL; -const baseURL = viteApiUrl - ? new URL(viteApiUrl).origin - : "http://localhost:3001"; // Default to API server in development +const resolveAuthBaseURL = (apiUrl: string | undefined): string => { + if (!apiUrl) { + return "http://localhost:3001"; // Default to API server in development + } + + const fallbackOrigin = + typeof window !== "undefined" + ? window.location.origin + : "http://localhost:3001"; + + return new URL(apiUrl, fallbackOrigin).origin; +}; + +const baseURL = resolveAuthBaseURL(viteApiUrl); // Debug logging (always enabled for debugging cross-domain issues) console.log("🔧 Better Auth Client Configuration:", { diff --git a/packages/app/src/lib/hooks/useArticles.ts b/packages/app/src/lib/hooks/useArticles.ts index 653690a..5638b02 100644 --- a/packages/app/src/lib/hooks/useArticles.ts +++ b/packages/app/src/lib/hooks/useArticles.ts @@ -197,6 +197,7 @@ export const useMarkArticleRead = () => { onSuccess: () => { // Invalidate all article list queries to ensure filtered views are updated utils.articles.list.invalidate(); + utils.articles.getCounts.invalidate(); toast.success("Marked as read"); }, }); @@ -245,6 +246,7 @@ export const useMarkArticleUnread = () => { onSuccess: () => { // Invalidate all article list queries to ensure filtered views are updated utils.articles.list.invalidate(); + utils.articles.getCounts.invalidate(); toast.success("Marked as unread"); }, }); @@ -293,6 +295,7 @@ export const useSaveArticle = () => { onSuccess: () => { // Invalidate all article list queries to ensure filtered views are updated utils.articles.list.invalidate(); + utils.articles.getCounts.invalidate(); toast.success("Article saved"); }, }); @@ -341,6 +344,7 @@ export const useUnsaveArticle = () => { onSuccess: () => { // Invalidate all article list queries to ensure filtered views are updated utils.articles.list.invalidate(); + utils.articles.getCounts.invalidate(); toast.success("Article unsaved"); }, }); @@ -353,6 +357,7 @@ export const useBulkMarkRead = () => { return trpc.articles.bulkMarkRead.useMutation({ onSuccess: (data) => { utils.articles.list.invalidate(); + utils.articles.getCounts.invalidate(); toast.success(`${data.updated} articles updated`); }, onError: () => { @@ -367,6 +372,7 @@ export const useMarkAllRead = () => { return trpc.articles.markAllRead.useMutation({ onSuccess: (data) => { utils.articles.list.invalidate(); + utils.articles.getCounts.invalidate(); toast.success( `${data.updated} article${data.updated === 1 ? "" : "s"} marked as read` ); diff --git a/packages/app/src/lib/hooks/useData.ts b/packages/app/src/lib/hooks/useData.ts index 6f8fa61..248301c 100644 --- a/packages/app/src/lib/hooks/useData.ts +++ b/packages/app/src/lib/hooks/useData.ts @@ -162,6 +162,7 @@ export const useCreateSubscriptionWithRefetch = () => { // Stop any existing polling (synchronously to prevent race conditions) stopPolling(); + isExecutingRef.current = true; // Set polling state immediately to prevent race conditions from rapid clicks setIsPolling(true); diff --git a/packages/app/src/routes/app/subscriptions.tsx b/packages/app/src/routes/app/subscriptions.tsx index 60dc816..27fe934 100644 --- a/packages/app/src/routes/app/subscriptions.tsx +++ b/packages/app/src/routes/app/subscriptions.tsx @@ -61,6 +61,14 @@ export const Route = createFileRoute("/app/subscriptions")({ }), }); +type InitialFilterField = + | "title" + | "content" + | "description" + | "author" + | "any"; +type InitialFilterMatchType = "contains" | "exact" | "regex"; + function SubscriptionsPage() { const search = Route.useSearch(); const navigate = Route.useNavigate(); @@ -72,6 +80,8 @@ function SubscriptionsPage() { const feedDiscovery = useFeedDiscovery(); const { data: existingCategories = [] } = useCategories(); const utils = trpc.useUtils(); + const createSubscriptionFilter = + trpc.subscriptions.createFilter.useMutation(); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); @@ -97,8 +107,8 @@ function SubscriptionsPage() { ); const [initialFilters, setInitialFilters] = useState< Array<{ - field: string; - matchType: string; + field: InitialFilterField; + matchType: InitialFilterMatchType; pattern: string; caseSensitive: boolean; }> @@ -298,13 +308,40 @@ function SubscriptionsPage() { return; } + if (filterEnabled) { + const incompleteFilter = initialFilters.some((filter) => { + return !filter.pattern.trim(); + }); + + if (incompleteFilter) { + toast.error("Complete all filter patterns before adding"); + return; + } + + const invalidRegex = initialFilters.find((filter) => { + if (filter.matchType !== "regex") return false; + + try { + new RegExp(filter.pattern); + return false; + } catch { + return true; + } + }); + + if (invalidRegex) { + toast.error(`Invalid regex pattern: ${invalidRegex.pattern}`); + return; + } + } + try { // Create subscription const subscription = await createSubscription.mutateAsync({ url: newSubUrl, customTitle: newSubTitle || undefined, - iconUrl: feedPreview.data?.icon_url, - iconType: feedPreview.data?.icon_url ? "auto" : "none", + iconUrl: feedPreview.data?.iconUrl, + iconType: "auto", categoryIds: selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, newCategoryNames: @@ -313,15 +350,23 @@ function SubscriptionsPage() { // If filters are configured, set them up if (filterEnabled && initialFilters.length > 0 && subscription?.id) { - // Update subscription to enable filters await updateSubscription.mutateAsync({ id: subscription.id, filterEnabled: true, filterMode: filterMode, }); - // Create each filter - note: temporarily disabled until we have direct filter creation access - // This will be handled by the filter manager UI after subscription creation + await Promise.all( + initialFilters.map((filter) => + createSubscriptionFilter.mutateAsync({ + subscriptionId: subscription.id, + field: filter.field, + matchType: filter.matchType, + pattern: filter.pattern.trim(), + caseSensitive: filter.caseSensitive, + }) + ) + ); } // Clear form @@ -351,6 +396,7 @@ function SubscriptionsPage() { initialFilters, createSubscription, updateSubscription, + createSubscriptionFilter, feedPreview, feedDiscovery, ]); @@ -800,7 +846,8 @@ function SubscriptionsPage() { const newFilters = [...initialFilters]; const filterToUpdate = newFilters[index]; if (filterToUpdate) { - filterToUpdate.field = e.target.value; + filterToUpdate.field = e.target + .value as InitialFilterField; setInitialFilters(newFilters); } }} @@ -823,7 +870,8 @@ function SubscriptionsPage() { const newFilters = [...initialFilters]; const filterToUpdate = newFilters[index]; if (filterToUpdate) { - filterToUpdate.matchType = e.target.value; + filterToUpdate.matchType = e.target + .value as InitialFilterMatchType; setInitialFilters(newFilters); } }} @@ -930,7 +978,9 @@ function SubscriptionsPage() { onClick={handleAdd} disabled={ createSubscription.isPending || + createSubscriptionFilter.isPending || !newSubUrl || + (filterEnabled && initialFilters.some((f) => !f.pattern)) || (!feedPreview.data && discoveredFeeds.length > 1) } >