diff --git a/docs/architecture.md b/docs/architecture.md index 672f99e..8ee35be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,6 +92,7 @@ GSI = Global Secondary Index | Timeline item | `u##timeline` | `#` | — | — | Fan-in/fan-out items written when a subscription is created or a new post is published. Deleted (all items for that author) when the subscription is removed. Expires via `ttl` (30 days). Carries `description` and `textContent` (max 300 chars) from the source post so the following feed can render excerpts without a secondary lookup. | | Subscriber count | `u#` | `count` | — | — | `subscribers` attribute incremented on each new subscription. Counted per DID, not per publication — if a reader subscribes to two publications by the same author, the count increments twice. Used by the API to identify celebrity authors (threshold: 1000) who bypass fan-out in favour of live post queries at read time. | | User info | `u#` | `info` | — | — | Written on first sign-in via `POST /users`. Attributes: `entityType` (`user-info`), `acceptedTermsAt` (ISO timestamp, set once), `updatedAt`. | +| Suggestion | `u##sugg` | `pub#` | — | — | One item per suggested publication. `show` (boolean) controls visibility: `true` = active suggestion, `false` = dismissed tombstone. Attributes: `pubAtUri`, `authorDid`, `pubName`, `pubUrl`, `pubDescription`. Written by `POST /suggestions`; updated to `show=false` by `PATCH /suggestions`. Never deleted — tombstones prevent re-adding dismissed or subscribed-to publications on future "Find suggestions" runs. | | iframely cache | `iframely#cache` | SHA-256 hex of the embed URL | — | — | Expires via `ttl` attribute (DynamoDB TTL enabled) | Global secondary index `FeedIndex` (GSI1PK=`blog-post#published`, GSI1SK=`#`) enables a time-ordered global feed across all users. @@ -156,6 +157,47 @@ The page loads publication details for 20 subscriptions at a time: Clicking "Subscribed" on a card calls `DELETE /subscriptions/:rkey` on the API and then calls `removeSubscription(rkey)` on the context, which filters the record out of the shared `subscriptions` array. The card is also removed from the local `pubs` state. The `initializedRef` guard ensures the removed subscription does not re-trigger the initial fetch loop. +## Suggestions page + +The `/suggestions` route (`app/routes/suggestions.tsx`) surfaces publications from people the signed-in user follows on Bluesky, helping them discover content to subscribe to. + +### Data model + +Suggestions are stored in DynamoDB under `PK=u##sugg`, `SK=pub#` (where `rkey` is the rkey of the publication on the author's PDS). The `show` boolean controls whether an item appears in the list: + +- `show=true` — active suggestion, returned by `GET /suggestions` +- `show=false` — dismissed tombstone, excluded from query results but kept in the table to prevent the same publication from being re-added on future "Find suggestions" runs + +Records are never deleted; the tombstone is the permanent record of a user's decision to dismiss or subscribe. + +### "Find suggestions" flow + +1. Fetch all Bluesky follows for the signed-in user via `app.bsky.graph.getFollows` on the public Bluesky API (paginated until exhausted). +2. For each followed DID, call `GET /publications?did=` to look up their publications in the Lemma DynamoDB index (5 concurrent requests at a time). +3. Filter out the user's own publications and any pubs they are already subscribed to (checked client-side against `subscriptions` from `DataContext`). +4. Bulk-upsert candidates in batches of 100 via `POST /suggestions`. Each item is written with `ConditionExpression: 'attribute_not_exists(PK)'` so already-present items (including `show=false` tombstones) are silently skipped — the `added` count in the response reflects only net-new items. +5. Reload the suggestions list from the API and update `DataContext`. + +### Filtering + +Two layers of filtering keep the list clean: + +- **Server-side** — `GET /suggestions` queries only items where `show=true` (via `FilterExpression`), paginating through all DynamoDB pages to avoid under-fetching. +- **Client-side** — items whose `pubAtUri` matches any entry in the user's `subscriptions` list are hidden at render time, catching publications subscribed to outside the suggestions page without waiting for a server round-trip. + +### Subscribe and dismiss + +- **Subscribe** — calls `POST /subscriptions` (writes the subscription to the PDS and to DynamoDB), then immediately calls `PATCH /suggestions` to set `show=false` for that item. The card is removed from `DataContext` state at the same time so it disappears from the list without a reload. This prevents the subscribed publication from reappearing on the next "Find suggestions" run. +- **Dismiss (X button)** — calls `PATCH /suggestions` directly to set `show=false` and removes the item from local state. + +### Stale index entries + +Suggestions are sourced from the Lemma DynamoDB index, not the live PDS. If a publication has been deleted from the PDS but the indexer missed the delete event (e.g. the indexer was down beyond the Jetstream 72-hour replay window), the stale DynamoDB record will still surface as a suggestion candidate. Such orphaned records must be removed manually by verifying the record against `com.atproto.repo.getRecord` on the author's PDS and then deleting the DynamoDB item. + +### State caching + +The loaded suggestions list is stored in `DataContext` (`suggestions: SuggestionItem[] | undefined`). The initial fetch fires once per session on first visit; subsequent navigation to `/suggestions` skips the fetch and renders the cached list instantly. All items are loaded in a single API call; infinite scroll reveals them 20 at a time client-side using an `IntersectionObserver` on a sentinel element. + ## Jetstream indexer ### What it does