Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/api/src/routers/__tests__/articles-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
131 changes: 71 additions & 60 deletions packages/api/src/routers/articles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
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 () =>
Expand All @@ -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,
}
);

Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion packages/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/components/app/article-item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,24 @@ describe("ArticleItem", () => {
expect(itemElement).toBeInTheDocument();
});

it("updates read and saved controls when article props change", () => {
const { rerender } = render(<ArticleItem article={mockArticle} />);

expect(
screen.getByRole("button", { name: /mark read/i })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();

rerender(
<ArticleItem article={{ ...mockArticle, read: true, saved: true }} />
);

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(<ArticleItem article={mockArticle} />);

Expand Down
12 changes: 2 additions & 10 deletions packages/app/src/components/app/article-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 });
}
}
Expand All @@ -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 });
}
}
Expand Down Expand Up @@ -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 });
}
}
Expand All @@ -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 {
Expand Down
17 changes: 14 additions & 3 deletions packages/app/src/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:", {
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/lib/hooks/useArticles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
});
Expand Down Expand Up @@ -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");
},
});
Expand Down Expand Up @@ -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");
},
});
Expand Down Expand Up @@ -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");
},
});
Expand All @@ -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: () => {
Expand All @@ -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`
);
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/lib/hooks/useData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading