| name | Explore Feed |
|---|---|
| description | Public discovery surfaces for projects and journals — the in-app /bulletin_board feed and the public /api/v1/explore JSON API. Covers public_for_explore scopes, cursor pagination, Meilisearch + ActiveRecord fallback, and live-update wiring. |
| type | project |
Two surfaces share the same underlying data and logic:
- In-app feed — embedded in
/bulletin_board(Inertia). See arch-bulletin-board.md for how the feed is composed into the community hub. Search hitsGET /bulletin_board/search(JSON, debounced). - Public API —
GET /api/v1/explore/{projects,journals}(JSON). Documented for API consumers in docs/developer/api.mdx; this page covers the internals an agent maintaining the feature needs to know.
Both surfaces are unauthenticated (no API key required). The bulletin board controller uses allow_unauthenticated_access/allow_trial_access/skip_onboarding_redirect and skips the Pundit verify callbacks (skip_after_action :verify_authorized/:verify_policy_scoped, only: %i[index search event events_feed event_ics]); the API controller uses skip_before_action :authenticate_api_key!, only: %i[projects journals] while keeping the rest of the API key-gated.
Every public query starts from one of two scopes — they are the trust boundary for the feed:
# app/models/project.rb
scope :public_for_explore, -> { kept.listed }
# app/models/journal_entry.rb
scope :public_for_explore, -> {
kept.where(project_id: Project.public_for_explore.select(:id))
}A journal entry is public iff its parent project is kept and not is_unlisted. Soft-deleting a project or flipping is_unlisted cascades to the journal feed without any explicit cleanup. Don't bypass these scopes when adding new queries — the controllers deliberately skip Pundit because these scopes ARE the policy.
BulletinBoardController (in-app) and Api::V1::ExploreController (public API) implement nearly identical pagination + search logic. They share these constants/conventions:
| Concept | Bulletin board | Public API |
|---|---|---|
| Sort options | active, newest |
active, newest |
| Default project sort | active |
active |
| Journal sort | always newest (no choice) |
always newest (no choice) |
| Default page limit | 5 (EXPLORE_LIMIT) |
20 (DEFAULT_LIMIT) |
| Max limit | 50 (EXPLORE_LIMIT_MAX) |
50 (MAX_LIMIT) |
| Search hard cap | 1000 (MEILISEARCH_SEARCH_LIMIT) |
1000 (MEILISEARCH_SEARCH_LIMIT) |
| Cursor format (browse) | `Base64.urlsafe_encode64("<iso8601 or 'none'> | ")` |
| Cursor format (search) | `Base64.urlsafe_encode64("search | ")` |
The duplication is intentional today — the two controllers serialize differently (the in-app feed renders markdown excerpts and resolves cover images more aggressively for the masonry layout; the API returns simpler payloads with absolute URLs). Don't merge them blindly; if you change pagination or search, update both and verify neither cursor format broke.
"Most recently active" — measured by the latest public journal entry's created_at. Projects with zero public journals sort last.
Implemented as a LEFT JOIN on a derived latest_activity subquery:
SELECT projects.*, latest_activity.last_activity_at AS explore_activity_at
FROM projects
LEFT JOIN (
SELECT project_id, MAX(journal_entries.created_at) AS last_activity_at
FROM journal_entries
WHERE … public_for_explore conditions …
GROUP BY project_id
) latest_activity ON latest_activity.project_id = projects.id
WHERE projects.kept AND NOT is_unlisted
ORDER BY latest_activity.last_activity_at DESC NULLS LAST, projects.id DESCThe explore_activity_at virtual attribute is read off each project row to encode the next cursor.
ORDER BY projects.created_at DESC, projects.id DESC.
DISTINCT ON (project_id) to dedupe to one entry per project (the latest), then ORDER BY created_at DESC, id DESC. This means a project never appears twice in the journals feed — only its newest public entry. Consumers wanting all entries should fetch by project_id.
Cursors are opaque base64 strings of the form <timestamp>|<id>. The id tiebreak is required because timestamps can collide.
For sort=newest: iso8601(created_at)|id. Decode raises ArgumentError if the timestamp is missing — the API returns 400 {"error": "Invalid cursor"}.
For sort=active: iso8601(latest_activity_at)|id, OR if a project has no public journals, the literal sentinel string none|id. Encoder substitutes PROJECT_ACTIVITY_NULL_CURSOR_VALUE = "none". Decoder treats "none" as nil and dispatches to a different WHERE clause:
# Has activity timestamp
WHERE last_activity_at < :cursor_at
OR (last_activity_at = :cursor_at AND projects.id < :cursor_id)
OR last_activity_at IS NULL
# No activity (cursor crossed into the NULL region)
WHERE last_activity_at IS NULL AND projects.id < :cursor_idThe three-clause OR is the explicit equivalent of NULLS LAST semantics in cursor form: rows with a timestamp come first (ordered DESC), then rows with NULL (ordered by id DESC).
Single mode: iso8601(created_at)|id. Same id-tiebreak pattern.
Search does paginate, but with a different cursor than browse. When a query is present the project path:
- Fetches up to
[offset + limit + 1, MEILISEARCH_SEARCH_LIMIT].minranked IDs from Meilisearch. - Slices
ranked_ids[offset, limit + 1]to get the page (plus the +1 lookahead forhas_more). - Loads those projects with
array_position(...)to preserve relevance order. - Encodes the next cursor as
encode_project_search_cursor(offset + limit)→Base64("search|<offset>"). Decode raisesArgumentError(→400 Invalid cursor) if the prefix isn't"search", the offset is missing, or it's negative.
Journal search does not offset-paginate the search itself — it filters the scope to the Meilisearch-matched IDs, then applies the normal timestamp journal cursor over the DISTINCT ON latest-entry set.
MEILISEARCH_SEARCH_LIMIT is 1000, so there's no way to paginate past the top 1000 project search hits. Acceptable today because relevance falls off quickly.
Every search path uses Meilisearch first and falls back to the model's search scope (pg_search) on connection failure or API error. The method is search_project_ids in the API controller and search_projects_for_explore in the bulletin board controller (the latter takes a limit:):
def search_project_ids(query, limit:)
project_ids = Project.ms_search(query, filter: "is_unlisted = false", sort: ["journal_count:desc", "created_at:desc"], limit: limit).map(&:id)
journal_project_ids = JournalEntry.ms_search(query, sort: ["created_at:desc"], limit: limit).map(&:project_id).uniq
(project_ids + (journal_project_ids - project_ids)).uniq # direct project hits first, then journal-only hits
rescue Meilisearch::ApiError, Meilisearch::CommunicationError, Errno::ECONNREFUSED
# pg_search fallback — same shape, slower, no relevance scoring; scoped to public_for_explore
project_matches = Project.public_for_explore.search(query).select(:id).limit(limit).map(&:id)
journal_matches = JournalEntry.public_for_explore.search(query).select(:project_id).limit(limit).map(&:project_id)
(project_matches + (journal_matches - project_matches)).uniq
endTwo-tier ranking for project search: direct matches on project name/description rank above projects only matched via their journal content. Inside each tier, Meilisearch's own score order is preserved.
The pg_search fallback is fast even without Meilisearch: GIN expression indexes (index_projects_on_search_tsvector, index_journal_entries_on_search_tsvector, index_users_on_search_tsvector, migration 20260723000000) match the exact tsvector expressions pg_search generates, so fallback queries are index-served. JournalEntry.search is content-only (an associated_against join would defeat the index); journal feed fallbacks that also need project-name matches use JournalEntry.search_including_project, which ORs in entries of projects whose name/description match — mirroring the Meilisearch index's project_name coverage. Admin users/projects search has the same rescue-to-pg_search fallback (losing only typo tolerance). If a searched column list changes in a pg_search_scope, the corresponding index expression must change with it.
The is_unlisted = false Meilisearch filter is required — without it, unlisted projects can be surfaced via journal-content matches even though they're filtered out of the public scope at SQL time. Belt-and-suspenders, but cheap.
The in-app feed subscribes to a static stream bulletin_explore that fires when public stats might have changed:
# app/models/project.rb (also similar in journal_entry.rb)
after_commit :broadcast_bulletin_explore_update
def broadcast_bulletin_explore_update
return unless bulletin_explore_stats_changed? # discarded_at, is_unlisted, or new/destroyed
return unless bulletin_explore_public_now? || bulletin_explore_public_before_last_save?
ActionCable.server.broadcast("live_updates:bulletin_explore", { stream: "bulletin_explore", action: "update" })
endThe "now or before" check is critical: a project transitioning out of public visibility (e.g., admin sets is_unlisted: true) must still broadcast so the feed can drop it. Conversely, a private project being edited (with is_unlisted unchanged) doesn't broadcast.
Frontend (pages/bulletin_board/index.tsx) debounces incoming broadcasts at 500ms (EXPLORE_LIVE_REFRESH_DEBOUNCE_MS) — a project soft-delete cascading to its journal entries fires multiple after_commits in quick succession, and the user only needs one refresh.
The public /api/v1/explore/... API is unaffected by ActionCable — clients poll on their own cadence.
serialize_project_for_explore, serialize_journal_for_explore — render markdown excerpts via Nokogiri, resolve cover images via uploaded images or markdown images, build relative hrefs for client navigation. Recording media is NOT exposed on the public explore feed — recordings are restricted to the journal author, project owner, and project collaborators.
serialize_project, serialize_journal — plain text excerpts, single cover_image_url field, absolute URLs (#{request.base_url}/...).
Project cards:
project.unified_thumbnail(cached zine/poster, see below)- The most recent public journal entry that has an attached image (in-app calls this the "cover entry")
- In-app only: the first markdown image extracted from the latest entry
- Otherwise
nil
Journal cards:
- The entry's own attached image
- In-app only: the first markdown image extracted from the entry
project.unified_thumbnail(so journal cards still anchor visually to the project even when the entry has no media)- Otherwise
nil
unified_thumbnail is a has_one_attached ActiveStorage attachment on Project populated by ComputeProjectUnifiedThumbnailJob — wraps ShipChecks::UnifiedScreenshotFinder.find_url (zine detection in repo, LLM-assisted; skip_nil: true so a "no zine" result isn't cached) + ShipChecks::UnifiedScreenshotProcessor (raster → JPEG bytes; PDF/SVG unsupported since Rails 8.1.3.1 blocks libvips's untrusted loaders). The job accepts perform(project_id, source_url:, force:, allow_representative:): a caller-supplied source_url (preflight) is used directly and skips the finder; otherwise force/allow_representative are forwarded to find_url. Cache key columns: unified_thumbnail_source_url (raw URL the rasterization is from), unified_thumbnail_etag (HTTP ETag for conditional GET), and unified_thumbnail_checked_at (last attempt timestamp). Both controllers preload via .preload(unified_thumbnail_attachment: :blob) on the project scope and { project: { unified_thumbnail_attachment: :blob } } on journal preloads — the bare with_attached_* macro also drags in variant_records and preview_image_attachment (Bullet's "AVOID eager loading" warning), which we don't use.
Zines are added near ship time, not at project creation, so discovery is triggered only when a zine plausibly exists. The job is enqueued from:
ProjectsController#refresh_cover— the owner-triggered "Check for my zine" button on the project page (verified owners only viaProjectPolicy#refresh_cover?;force: true,allow_representative: false; Rack::Attack throttledrefresh_cover/user). The frontend polls#cover_status, which reportsworking/found/noneby comparingunified_thumbnail_checked_atto the POST time.ShipPreflightJob— after preflight's checks finish, ifHasZinePagepassed it reuses the already-builtSharedContextto find the zine URL (no second GitHub fetch) and enqueues with thatsource_url(allow_representative: false).AttachShipUnifiedScreenshotJobafter a ship'sfrozen_screenshotis populated (keeps the representative-image fallback).Projectafter_commit whenrepo_linkchanges (cleared or swapped) — to purge the now-stale cover. It deliberately does NOT scan on repo add/change (a freshly linked repo has no zine yet).RefreshStaleUnifiedThumbnailsJob— recurring hourly, but only projects that already have aunified_thumbnailattached (a cheap etag refresh to catch zine updates), withunified_thumbnail_checked_at IS NULL OR < 24h ago, jittered across a 30-minute window, capped atPER_RUN_LIMIT = 200per run.
Inside the job, the freshness model is never purge without positive proof:
repo_linkblank → purge directly.- Finder returns a URL → conditional GET with
If-None-Match: <stored etag>.304bumpschecked_atonly.200re-rasterizes viaUnifiedScreenshotProcessor.transcode_to_jpegand stores the new etag.404/410or any error raisesTransientError(no purge —retry_onre-enqueues with polynomial backoff, capped at 5 attempts). - Finder returns
nilbut an attachment exists → probe the cached source URL via the same conditional GET.304/200keeps or refreshes the attachment.404is the only path that purges. Errors raiseTransientError. This is the regression guard against the prior false-purge bug where transient finder failures (SharedContextswallows all HTTP errors to nil) would purge a working attachment.
Other safety rails:
download_with_etagcaps body size atMAX_DOWNLOAD_BYTES = 50.megabytes(viaContent-Lengthand a defensive post-read check) so a giant file can't blow worker memory.- Solid Queue
limits_concurrency to: 1, key: "unified_thumbnail:<project_id>"serializes per-project runs so concurrent triggers don't race onActiveStorage.attach. - Kill switch:
Rails.cache.write("unified_thumbnail:paused", true)makes every run a no-op without a deploy.
Backfill rake task: bin/rake fallout:backfill_unified_thumbnails (envs BATCH_SIZE, DELAY_SECONDS) enqueues every kept project with a repo_link, throttled. Safe to re-run — repeat invocations hit the 304 fast-path.
journal_markdown_image_url rejects //evil.example (protocol-relative) and any data:/javascript: URLs. Only http(s):// and same-origin paths (/, ./, ../) are returned. Without this, a hostile journal entry could inject an <img src> into the public feed that leaks viewer IPs to attacker hosts.
| Risk | Handling |
|---|---|
| Cursor with missing/garbage timestamp | Time.iso8601 raises ArgumentError; controller rescues → 400 Invalid cursor |
| Search returning 0 IDs | Both controllers short-circuit and return an empty data: [] payload (no SQL IN () issue) |
Project with no public journals in active sort |
Sorts last via NULLS LAST; cursor uses "none" sentinel to advance through the NULL region without ambiguity |
| Unlisted project surfaced via journal-content match | Project.ms_search filters is_unlisted = false; final SQL uses public_for_explore regardless |
| Hostile markdown image in public excerpt | URL allowlist (http(s) + same-origin only) before passing to <img src> |
| Cascade deletes flooding the cable | Frontend debounces bulletin_explore at 500ms |
| Pundit accidentally enabled on these controllers | Explicit skip_after_action :verify_authorized, only: %i[index search event events_feed event_ics] (and same for :verify_policy_scoped); the public_for_explore scopes are the trust boundary |
| Drift between in-app and public API | The two controllers don't share helpers — when changing pagination/sort/search, audit both |
| Change | Controllers / Files |
|---|---|
| New sort option | BulletinBoardController#order_projects_for_explore + Api::V1::ExploreController#order_projects + EXPLORE_SORTS/SORTS constants in both |
| Change public visibility rules | Project#public_for_explore and/or JournalEntry#public_for_explore (cascades everywhere) |
| Add a new explore category | EXPLORE_CATEGORIES (bulletin board) / CATEGORIES (API) + new entries method/action + serializer |
| Adjust live-update sensitivity | Project#bulletin_explore_stats_changed? and the parallel method on JournalEntry |
| Change cursor format | Both controllers' encode_*_cursor / decode_*_cursor — clients hold cursors across requests, so format changes break in-flight pagination |
- arch-bulletin-board.md — community hub that hosts the in-app feed
- arch-projects-journals.md —
ProjectandJournalEntrymodel details - arch-services-infra.md — Meilisearch, ActionCable, Inertia
- docs/developer/api.mdx — consumer-facing API reference (the docs we publish)