Skip to content

feat: custom feeds chips#3918

Merged
capJavert merged 5 commits into
mainfrom
custom-feeds-chips
May 29, 2026
Merged

feat: custom feeds chips#3918
capJavert merged 5 commits into
mainfrom
custom-feeds-chips

Conversation

@capJavert
Copy link
Copy Markdown
Contributor

@capJavert capJavert commented May 28, 2026

Adds backend support for "tag-chip feeds": the daily.dev experience where the explore-tag chip strip is replaced by per-tag custom feeds owned by the user, navigable like any other custom feed. Free users get
this surface too, so a chunk of what used to be Plus-only on custom feeds is now reachable for free on these specific feeds.

Rollout is fully client-controlled — the new feedList(includeTagChipFeeds) argument lets apps opt in behind its own GrowthBook flag, while old browser-extension versions that don't know about the argument keep
getting exactly the response they got before. Legacy feedByTags / feedTagsList / /explore/[tag] resolvers are intentionally kept for the same backwards-compatibility reason.

Tag-chip feeds are lazily seeded the first time a user opts in, sourced from the same place the old chip strip used, gated by a one-shot user flag so failures and concurrent calls don't double-seed, and bounded
by the per-user feed cap (bumped 20 → 35). Free users on a tag-chip feed get the legacy /explore/[tag] recommendation algorithm; Plus and grandfathered users keep the existing custom-feed algorithm, so this PR is recommendation-neutral for everyone who was already using custom feeds.

@capJavert capJavert self-assigned this May 28, 2026
@pulumi
Copy link
Copy Markdown

pulumi Bot commented May 28, 2026

🍹 The Update (preview) for dailydotdev/api/prod (at 73e3fa6) was successful.

✨ Neo Explanation

Routine container image rollout deploying the tag-chip feeds feature, which auto-seeds per-keyword custom feeds for non-Plus users and increases the per-user feed cap from 20 to 35; the access control change (removing the date-based Plus gate for custom feeds) is the main logic worth verifying. ✅ Low Risk

This deployment rolls out the tag-chip feeds feature: a new feedList(includeTagChipFeeds: true) GraphQL argument that lazily seeds per-keyword custom feeds for users on first opt-in, backed by feedClient.getUserTags with recswipe backfill. Key behavioral changes include:

  • Access control change: The customFeed resolver previously blocked non-Plus users from feeds created after 2024-12-11 (the customFeedsPlusDate gate). That date-based check is removed and replaced with an origin check — non-Plus users can now access TAG_CHIP-origin feeds, using the simpler ForYouByTag feed config instead of CustomFeedV1. This is intentional and correct per the tests, but it's a meaningful auth logic change worth confirming.
  • maxFeedsPerUser raised from 20 → 35: This accommodates the up-to-15 tag-chip feeds that can be seeded per user on top of existing feeds. Any existing per-user feed cap enforcement in other parts of the codebase that hardcodes 20 should be audited.
  • DB migration job (vpc-native-api-db-migration-95a31230): Will run against prod to support the new schema/data. The tagChipFeedsSeededAt field is added to User.flags (a JSONB column), which is schema-additive and safe.
  • Seeding is write-on-read: When includeTagChipFeeds: true is passed, the API writes new Feed, FeedTag, and ContentPreferenceKeyword rows inline during the query. This is guarded by an atomic flag (reserveSeedSlot) to prevent duplicate seeding, but it does add DB write latency to what would otherwise be a read path.

Resource Changes

    Name                                                       Type                           Operation
+   vpc-native-api-clickhouse-migration-95a31230               kubernetes:batch/v1:Job        create
~   vpc-native-clean-zombie-images-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-stale-user-transactions-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-update-source-public-threshold-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-expire-super-agent-trial-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-ws-deployment                                   kubernetes:apps/v1:Deployment  update
~   vpc-native-update-highlighted-views-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-gifted-plus-cron                          kubernetes:batch/v1:CronJob    update
~   vpc-native-rotate-weekly-quests-cron                       kubernetes:batch/v1:CronJob    update
~   vpc-native-bg-deployment                                   kubernetes:apps/v1:Deployment  update
~   vpc-native-deployment                                      kubernetes:apps/v1:Deployment  update
~   vpc-native-clean-channel-highlights-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-expired-better-auth-sessions-cron         kubernetes:batch/v1:CronJob    update
~   vpc-native-user-profile-analytics-clickhouse-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-old-notifications-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-user-profile-analytics-history-clickhouse-cron  kubernetes:batch/v1:CronJob    update
~   vpc-native-generic-referral-reminder-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-update-views-cron                               kubernetes:batch/v1:CronJob    update
~   vpc-native-post-analytics-clickhouse-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-update-trending-cron                            kubernetes:batch/v1:CronJob    update
~   vpc-native-sync-subscription-with-cio-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-rotate-daily-quests-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-temporal-deployment                             kubernetes:apps/v1:Deployment  update
~   vpc-native-personalized-digest-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-opportunities-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-squad-posts-analytics-refresh-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-calculate-top-readers-cron                      kubernetes:batch/v1:CronJob    update
~   vpc-native-user-posts-analytics-refresh-cron               kubernetes:batch/v1:CronJob    update
~   vpc-native-private-deployment                              kubernetes:apps/v1:Deployment  update
~   vpc-native-update-achievement-rarity-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-materialize-monthly-best-post-archives-cron     kubernetes:batch/v1:CronJob    update
~   vpc-native-validate-active-users-cron                      kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-users-cron                         kubernetes:batch/v1:CronJob    update
~   vpc-native-daily-digest-cron                               kubernetes:batch/v1:CronJob    update
~   vpc-native-channel-highlights-cron                         kubernetes:batch/v1:CronJob    update
~   vpc-native-user-profile-updated-sync-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-personalized-digest-deployment                  kubernetes:apps/v1:Deployment  update
~   vpc-native-post-analytics-history-day-clickhouse-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-update-tags-str-cron                            kubernetes:batch/v1:CronJob    update
~   vpc-native-channel-digests-cron                            kubernetes:batch/v1:CronJob    update
+   vpc-native-api-db-migration-95a31230                       kubernetes:batch/v1:Job        create
... and 12 other changes

@capJavert
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @capJavert's task in 2m 15s —— View job


Code review — feat: custom feeds chips

  • Read all changed files
  • Analyze src/common/seedTagChipFeeds.ts
  • Analyze schema changes in src/schema/feeds.ts
  • Check entity/migration alignment
  • Verify test coverage
  • Post review feedback

Overall this is a well-structured PR — the atomic flag reservation, the schema-additive entity changes (no migration needed), and the client-gated rollout via includeTagChipFeeds are all sound. A few things worth a closer look:


Findings

🟠 1. Possible regression for grandfathered free users (src/schema/feeds.ts:1843)

customFeedsPlusDate was the date-gate that let pre-2024-12-11 free users keep accessing their existing custom feeds. The new check drops that entirely:

// before
if (feed.createdAt > customFeedsPlusDate && !ctx.isPlus) {
  throw new ForbiddenError(...);
}

// after
if (!isTagChipFeed && !ctx.isPlus) {
  throw new ForbiddenError(...);
}

The PR body says "Plus and grandfathered users keep the existing custom-feed algorithm" but the actual code blocks all non-Plus, non-TagChip access — so grandfathered free users now get ForbiddenError on feeds they could previously view. If grandfathering is intentionally over, please confirm. Otherwise the date-gate (or an explicit flags.grandfathered) should be preserved alongside the new TagChip branch.

🟠 2. Silent permanent failure path in feedList seeding (src/common/seedTagChipFeeds.ts:140 + src/schema/feeds.ts:2349)

reserveSeedSlot writes flags.tagChipFeedsSeededAt before any seeding work. If anything after that throws (e.g., recswipe timeout, transaction failure, DB blip) the user is permanently marked "seeded" with zero chip feeds — the try/catch in feedList just logs and moves on, and the user has no path to recover without a manual flag reset. The function's docstring acknowledges this as intentional, but in practice it means a single transient upstream failure produces a permanent empty state.

Two safer options:

  • Reserve the slot after a successful seed write (move reserveSeedSlot into the transaction or after it). Concurrency is then guarded by a different mechanism (e.g. advisory lock or upsert-with-conflict on the first feed write).
  • Keep current ordering but only set the flag once the transaction has committed; treat reservation failures as "already in flight" only for the duration of this request.

🟡 3. SQL string interpolation in reserveSeedSlot (src/common/seedTagChipFeeds.ts:74-78)

flags: () =>
  `flags || '${JSON.stringify({ tagChipFeedsSeededAt: new Date().toISOString() })}'::jsonb`,

Currently safe — toISOString() produces only [0-9T:.\-Z]. But the project's AGENTS.md explicitly calls out the setParameter pattern for nested JSONB writes:

.set({ flags: () => `flags || :seededJson::jsonb` })
.setParameter('seededJson', JSON.stringify({ tagChipFeedsSeededAt: new Date().toISOString() }))

It also lets future edits add fields without re-evaluating escape safety.

🟡 4. First feedList call now blocks on 1–2 external HTTP calls (src/schema/feeds.ts:2342-2356)

seedTagChipFeedsIfNeeded is awaited inline in the resolver. On the first opt-in, that's a feedClient.getUserTags call + potentially a recswipeClient.recommendTags call + a multi-statement transaction — all in the user's request path. The catch is good defensively, but you might want to:

  • Set a tight timeout on the seeding, or
  • Kick it off as a fire-and-forget worker (the current request returns whatever feeds already exist; next refresh sees the seeded set).

The current implementation is fine for correctness, just worth confirming the latency hit is acceptable for the rollout.

🟡 5. Function should accept DataSource | EntityManager (src/common/seedTagChipFeeds.ts:131-139)

Per AGENTS.md "For reusable functions: Accept DataSource | EntityManager as the connection parameter so the function can participate in a caller's transaction." Right now con: DataSource precludes any caller from running this inside an outer transaction. The internal con.transaction(...) would need to become if con is DataSource ? con.transaction(...) : pass-through manager, but it's a small lift.

🟢 6. Minor: resolveLabel does O(N²) lookups (src/common/seedTagChipFeeds.ts:50-53)

const => k.value === value)?.flags?.title;

With N=15 this is fine, but a Map is both cleaner and matches the AGENTS.md guidance for batch lookups:

const keywordMap = new Map(keywords.map((k) => [k.value, k]));
// ...
const

🟢 7. Minor: existingFeedsCount could use read replica (src/common/seedTagChipFeeds.ts:145)

Not consistency-critical (the cap is soft + the seed runs once per user), so queryReadReplica would be the recommended fit.


Things that look good

  • Atomic reserveSeedSlot via conditional UPDATE … WHERE flags->>'tagChipFeedsSeededAt' IS NULL — correct way to do this without an advisory lock.
  • Schema-additive entity changes (FeedOrigin, User.flags.tagChipFeedsSeededAt) avoid a migration — appropriate for JSONB columns.
  • TagChip-only branch correctly routes non-Plus users to the simpler ForYouByTag config while preserving CustomFeedV1 for Plus users; tests cover both paths.
  • feedList filter using IS DISTINCT FROM correctly excludes chip feeds when the client opts out (handles the NULL case).
  • Old feedByTags / feedTagsList / /explore/[tag] resolvers kept for backwards compatibility — matches the rollout strategy described in the PR body.
  • Integration test coverage for both the seed function and the feedList / customFeed resolvers is good — both the access-control change and the seeding side effects are exercised.

The grandfathered-user check (#1) is the only one I'd consider blocking; the rest are improvements / nits.
• Branch: custom-feeds-chips

@capJavert capJavert merged commit ea0bcea into main May 29, 2026
9 checks passed
@capJavert capJavert deleted the custom-feeds-chips branch May 29, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant