diff --git a/ROADMAP.md b/ROADMAP.md index 757ce27..811a383 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,9 +116,12 @@ fold but appear nowhere. barcodes land in `catalog.release.identifiers`. The payoff half is open: making the scan flow match the network catalog first and Discogs second, which compounds as the catalog grows. -- **Browsable catalog** ([spec sketch](./docs/catalog-browse.md)): v1 over - at-record's own known users, v2 a Jetstream-filtered indexer; want-it / - have-it straight from the browse grid. +- **Browsable catalog (v1 built 2026-07)** ([spec](./docs/catalog-browse.md)): + `/browse` grid over at-record's known users (fan-out of public + `listRecords`, dedup by Discogs id, owned/wanted badges), want-it / + have-it as one-tap adoption writes. v2 (Jetstream-filtered indexer, + likely quickslice) waits for its trigger: more publishers or a slow + fan-out. - **Adoption counts** via Constellation backlinks: "N people have this", browse sorting, and the answer to the open canonical-selection decision. - **`catalog.edit` inbox:** amending a foreign record files a proposal @@ -190,6 +193,16 @@ a schema break). Closed unions stay unsupported until something needs one. Phase 1 is mostly vendoring the method lexicons and emitting the fn per params/input/output. +### Lexicon toolchain: faithful AST + mlf-style DSL (sketch filed 2026-07) + +Sketch lives in +[gleam-atproto/docs/lexicon-toolchain.md](https://tangled.org/@mokkenstorm.dev/gleam-atproto/blob/main/docs/lexicon-toolchain.md): +an `atproto_lexicon` package (bidirectional, constraint-carrying lexicon AST) +as the shared core, `atproto_codegen` shrinking to a back-end on it, and an +[mlf](https://mlf.lol/)-syntax front-end once upstream leaves pre-release. +Sequencing: AST core first (upgrades `make gen` regardless), DSL later. The +method-generation back-end remains specced by the section above. + ### API schema / OpenAPI (planned) - **Lexicon-driven OpenAPI:** hand-write the thin `paths` + cookie auth + error @@ -240,11 +253,13 @@ Remaining: - Confidential deploys need a stable `OAUTH_CLIENT_JWK` and `BASE_URL` (`SECRET_KEY_BASE` and `STORE_KEY` are now required everywhere). - **Whether to stay confidential at all is now an open question** — see - [bff-architecture.md](./docs/bff-architecture.md) (sketch): most of the - server's logic doesn't need a server secret, and a public-client, browser-custodied - session is the more atproto-native shape. Blocked today on DPoP crypto having - no WebCrypto (browser) backing anywhere in the dependency chain, only - Node's `crypto`. + [bff-architecture.md](./docs/bff-architecture.md): most of the server's + logic doesn't need a server secret, and a public-client, + browser-custodied session is the more atproto-native shape. The former + blocker (no WebCrypto DPoP anywhere in the dependency chain) fell + 2026-07: `atproto_browser` in gleam-atproto ships an async fetch client + plus WebCrypto DPoP/PKCE. Remaining gate before committing: the + real-browser OAuth proof against the e2e devnet. ### API surface gaps diff --git a/docs/bff-architecture.md b/docs/bff-architecture.md index 03af2c4..31f69f1 100644 --- a/docs/bff-architecture.md +++ b/docs/bff-architecture.md @@ -1,170 +1,117 @@ -# Sketch: how thin can the BFF get? - -Status: **sketch, not designed in detail** (a "what would it take" pass, same -spirit as [catalog-browse.md](./catalog-browse.md)). Prompted by a fair -question: in an atproto app, ideally the browser talks to the user's own PDS -directly and there's no backend at all, unless something genuinely needs one -(a secret, or cross-user indexing). - -## Goal - -Figure out how much of `server/` is load-bearing versus incidental — built -server-side because that's where the original OAuth work landed, not because -it has to be there — and what it would take to move the rest into the -browser. - -Non-goals: a decision to actually do this. This is scoping, not a plan. - -## The honest answer: most of it is incidental, one piece is load-bearing - -Everything that reads/writes the user's own PDS — crate folding -(`crate.gleam`), adopt-or-mint (`promotion.gleam`), amend, blob upload, cover -backfill — already only needs the user's own DPoP-bound access token, not a -server secret. This shows up cleanly in the code: `promotion.gleam`'s `Deps` -port (`catalog_deps.gleam`) is two pure functions over **public** reads -(Constellation backlinks, `getRecord` on any repo); the actual write still -needs the session, but that's the user's own credential, not the server's. -The DI refactor that separated these into ports (`Context`/`Deps`, a few -commits back) turns out to double as exactly the seam a browser port would -need — the business logic doesn't know or care that it's running server-side -today. - -**The one thing that's genuinely, unavoidably server-only: Discogs OAuth.** -`discogs_oauth.gleam` signs every request with -`oauth_signature = consumer_secret & token_secret` (OAuth 1.0a PLAINTEXT — -literally the app's secret concatenated into the request, not an HMAC of -it). Shipping `DISCOGS_CONSUMER_SECRET` to a browser bundle means shipping it -to every user. As long as Discogs import is a feature, something server-side -has to hold that secret. - -Everything else server-side today is there because the app's atproto OAuth -is a **confidential client with server-custodied sessions** — a deliberate, -reasonable choice, but a choice, not a requirement. A **public client** -(PKCE + DPoP, no client secret, tokens held by the browser) is the standard -shape for a pure atproto SPA, and this app already runs in that mode for -local dev (`config.gleam`'s public/confidential toggle keys off `BASE_URL`) -— production just hasn't flipped it. - -## What's actually missing to go there - -Checked how much of the OAuth machinery in the `atproto_client` package (the -one this app already depends on, extracted from an earlier version of this -same codebase) would carry over to a browser build: - -- **OAuth flow orchestration, PAR/PKCE/token-exchange logic**: plain Gleam, - no `@external` calls anywhere in the package. Compiles for the JS target - today (confirmed: `web/build` already has working `.mjs` output for these - modules, from an incidental transitive dependency — nothing in `web/src` - calls them yet, but the compiler already proves they build). -- **HTTP transport**: already abstracted (`xrpc.Client(send: fn(Request) -> -Result(Response, String))`, explicitly documented as "bring your own - backend"). A browser build needs a `fetch`-based `Client` — doesn't exist - yet, but is a small, mechanical thing to write (the server's own - `atproto_client.gleam` is the `httpc`-based equivalent, ~20 lines, to - mirror). -- **DPoP key crypto — the actual blocker.** DPoP signing goes through `gose` - → `kryptos` (third-party packages, not ours). `kryptos`'s JS FFI is real - and dual-target-shaped, but every operation (`generateKeyPairSync`, - `sign`, `createPrivateKey`) goes through Node's `crypto` module — there is - no WebCrypto (`SubtleCrypto`) implementation anywhere in it. This is not - "untested," it's "will not run in an actual browser" as shipped today. The - PKCE half of this same problem is smaller: the random-bytes side already - uses real WebCrypto (`crypto.getRandomValues`), only the SHA-256 challenge - hashing goes through Node's `createHash`. -- **DPoP key persistence**: entirely unaddressed by the library (it signs; - storage is "the caller's job," per its own docs) — a browser build needs - to hand-roll IndexedDB storage of a non-extractable `CryptoKey`, from - scratch, either way. - -None of this is _hard_ — DPoP only needs a tiny crypto surface (generate one -P-256 key, sign a compact JWS, export the public JWK), far smaller than -`kryptos`'s full JOSE/COSE scope — but it's real, unstarted work, and it -lives in a dependency this project doesn't own. The pragmatic path is -probably a from-scratch minimal browser DPoP signer inside `atproto_client` -itself (a JS-target-only `@external` function, bypassing `kryptos` -entirely for this one use), not waiting on an upstream PR. +# How thin can the BFF get? + +Status: **direction affirmed, migration planned, not started** (2026-07). +The original question stands: in an atproto app, ideally the browser talks +to the user's own PDS directly and there's no backend at all, unless +something genuinely needs one (a secret, or cross-user indexing). The +browser-crypto blocker identified in the first pass has since been built +(`atproto_browser` in gleam-atproto); the remaining entry gate is the +real-browser proof described below. + +## What the server is, honestly + +`server/` is three things in a trenchcoat: + +1. **Session custody, plus everything trapped behind it** (the bulk). + The whole `oauth/` directory exists because the atproto session lives + server-side, and because the session is there, the business logic got + built there too: `crate`, `event_log`, `promotion`, `provenance`, + `covers`, `catalog_entities`, `musicbrainz_client`, the shelf/amend + handlers. None of it needs a server secret. It needs the user's own + DPoP-bound session plus public reads that are all confirmed CORS-open + (Constellation, Slingshot, `plc.directory`, foreign-PDS + `getRecord`/`getBlob`, the configurable AppView, MusicBrainz). The + `Context`/`Deps` port seams double as exactly the seams a browser port + needs. +2. **The Discogs proxy** (irreducible, but small). `discogs_oauth.gleam` + signs every request with `consumer_secret & token_secret` (OAuth 1.0a + PLAINTEXT — literally the app's secret concatenated into the request). + Shipping `DISCOGS_CONSUMER_SECRET` to a browser bundle means shipping it + to every user, and Discogs' API sends no CORS headers anyway, so Discogs + reads proxy through a server no matter where sessions live. Per-user + Discogs tokens stay sealed server-side with it, since the server is + what signs with them. +3. **The appview seed** (kept on purpose). `known_users` plus the browse + read path ([catalog-browse](./catalog-browse.md)) is the indexer in + embryonic form: the one genuinely cross-user piece, and the part v2 + (Jetstream indexer / quickslice) later replaces from the inside without + changing its API. + +Already done from the earlier "thin it now" pass: the handle-typeahead +proxy is deleted and the web app calls the AppView directly +(`web/src/at_record_web/appview.gleam` holds the origin), after verifying +the CORS headers for real. + +## What the spike proved (2026-07) + +`atproto_browser` (gleam-atproto) exists and answers the two unknowns the +first version of this doc called blockers: + +- **Transport:** a sync `xrpc.Client` cannot be honestly satisfied by + `fetch`, so the package is JS-target with an async + `Client(send: fn(Request(BitArray)) -> Promise(Result(...)))`, and the + PAR/PKCE/token/DPoP-nonce orchestration is ported onto it. +- **DPoP crypto:** a from-scratch WebCrypto FFI (ECDSA P-256, + non-extractable keys, raw R‖S signatures, which is simpler than the + Node DER path) with sign+verify smoke tests, plus a WebCrypto SHA-256 + digest for the PKCE challenge. + +Remaining before committing to the migration (the **entry gate**): + +- The end-to-end proof in a real browser tab against the e2e devnet + (`make e2e-up`): key generation → PAR → redirect → code exchange → one + DPoP-authed PDS call, all through `atproto_browser`. +- IndexedDB persistence of a non-extractable `CryptoKey` across reloads + (structured clone, no export round-trip), so sessions survive a tab + close. ## A security nuance worth being honest about -The instinct is "browser-held keys are less safe than server-held ones." The -real picture is more mixed. DPoP's whole point is sender-constraint: even -under full XSS, a **non-extractable** `CryptoKey` can be used to sign +The instinct is "browser-held keys are less safe than server-held ones." +The real picture is more mixed. DPoP's whole point is sender-constraint: +even under full XSS, a non-extractable `CryptoKey` can be used to sign requests live from the compromised page, but the raw key material can't be -exfiltrated for offline replay — which is roughly the same blast radius as -XSS against a server-cookie session (attacker acts as the user while present -either way). The genuine costs of moving custody to the browser are -different from "less secure": it's unbuilt engineering (above), weaker -multi-device session continuity (no natural sync of browser-local storage -across devices), and losing one layer of defense-in-depth (tokens simply -never entering the JS runtime at all, today, is worth something even if the -marginal risk reduction is smaller than intuition suggests). - -## Options - -**A. Status quo.** No work. Misaligned with "ideally no backend," and the -server keeps doing session/token custody for zero architectural reason -beyond "it was easiest to build this way." - -**B. Thin the BFF now, defer full browser-DPoP.** Move everything that -doesn't need a secret _and_ doesn't need the atproto session — i.e. calls -against genuinely public, CORS-open services — directly into the browser, -without touching how atproto OAuth/sessions work at all: - -- **Done:** the handle-typeahead proxy (`identity.gleam`) was built - server-side purely for "web only talks to the BFF" tidiness, not - necessity. Verified `public.api.bsky.app` sends - `access-control-allow-origin: *` (curl bypasses CORS, so this needed an - actual header check, not just reachability) — deleted the server handler - and route, and `web/src/at_record_web/effects.gleam` now calls it - directly, with the AppView origin kept in one named constant - (`web/src/at_record_web/appview.gleam`) rather than inlined, so pointing - at a different AppView later is a one-line change. -- Same likely applies to `handle_resolver.gleam`'s PLC directory lookup (the - attribution chip) — `plc.directory` is typically CORS-open too, though - worth confirming rather than assuming (see Open decisions). - -Small, immediately actionable, zero risk to the OAuth security model. Doesn't -touch the bigger question at all, but stops making it worse. - -**C. Full public-client rearchitecture.** Browser holds the DPoP key and -tokens; server shrinks to Discogs OAuth/API + whatever the -[catalog-browse](./catalog-browse.md) appview eventually needs, nothing -else. Real payoff (this really would be "no backend, atproto-native"), real -cost (the DPoP crypto + storage work above, plus rewriting every handler in -`server/src/at_record_server/handlers/` as browser-side calls against the -user's own session — genuinely most of the app's current logic). Also -changes the Discogs-import shape for the better regardless of whether the -rest of C happens: the server's role there could shrink to "authenticated -fetch of collection pages," with the browser doing the actual PDS -writes/promotion/blob-upload using its own session — meaning even Discogs -doesn't require the full import _pipeline_ server-side, just the -OAuth-gated read. - -**Recommendation: B now, C as a deliberate future bet, not a foregone -conclusion.** This app is personal-scale; the DPoP-in-browser work is real -but bounded, and doing it well (non-extractable keys, careful IndexedDB -handling) is worth taking slowly rather than rushing to hit "zero backend." -B costs almost nothing and is directionally correct either way. +exfiltrated for offline replay, which is roughly the same blast radius as +XSS against a server-cookie session. The genuine costs are different: +weaker multi-device session continuity (nothing syncs browser-local +storage), and losing the defense-in-depth of tokens never entering the JS +runtime at all. + +## The migration, phased + +Each phase ships independently; the app keeps working throughout. + +1. **Public client for real.** Public-client metadata document, browser + login via `atproto_browser`, session (tokens + key) in IndexedDB. + Confidential mode stays available during the transition (the + public/confidential toggle already exists). +2. **Move the user-scoped logic.** `crate`, `event_log`, `provenance`, + `promotion`, `covers` move to a dual-target shared package (the same + trick `shared/` already proves for codecs); shelf/amend/scan handlers + dissolve into browser-side calls against the user's own session. +3. **Restructure import.** The server can no longer write to anyone's + repo (that's the point), so the browser drives the PDS writes while the + server shrinks to an authenticated Discogs page/details/image proxy. + Side effect: rate limiting becomes genuinely per-user, since each + user's Discogs token is its own bucket. +4. **Retire the confidential machinery.** The `oauth/` directory, session + stores, and session sealing go. `known_users` loses its feeding tube + (the server OAuth callback), so registration becomes an explicit "add + me to the shared catalog" call after browser login: indexing as opt-in + rather than a login side effect. + +End state: the backend is a Discogs proxy, an index service +(`known_users` + browse read, later the v2 indexer), and static hosting. +Postgres keeps `known_users` and the sealed Discogs credentials, and drops +sessions. ## Open decisions -- **Public vs. confidential client, for real, in production** — the app - already supports both modes; C requires committing to public. Confidential - gets a bit more trust in some AS consent UIs; public is the actually-honest - shape for a client with no server component. -- **Does `plc.directory` send CORS headers?** Assumed yes (it's a public - read-only directory), not verified. Worth a quick check before moving - `handle_resolver.gleam`'s logic client-side. -- **Does `i.discogs.com` (cover images) send CORS headers?** If not, even - under option C the browser can't `fetch()` the image bytes directly to - re-upload as a PDS blob, and _that specific step_ (not the whole import) - would need to stay server-proxied. Unverified. -- **Where does the browser DPoP signer live?** Inside `atproto_client` - (benefits the published package, and any other Gleam atproto app) vs. - local to this repo (faster, no coordination with the upstream package's - maintainer). Leans toward upstream, given this codebase's own history of - extracting generically-useful atproto plumbing into `gleam-atproto`. -- **Session durability under browser custody.** No multi-device sync, - logout-everywhere, or session recovery after clearing site data — is that - an acceptable UX regression for what this app is, or does it need a - fallback (e.g., still allow confidential-mode login as an option)? +- **When.** The gate is technical readiness, not a trigger; the migration + competes with product work (design, catalog) for the same hours. Nothing + forces it; the current setup works. +- **Does `i.discogs.com` (cover images) send CORS headers?** If not, the + cover-fetch step of import stays behind the Discogs proxy even in phase 3. Unverified. +- **Session durability UX.** No multi-device sync, no logout-everywhere, + sessions die with site data. Acceptable for what this app is, or does + confidential-mode login survive as a fallback option? diff --git a/docs/catalog-browse.md b/docs/catalog-browse.md index 8947947..2ae3180 100644 --- a/docs/catalog-browse.md +++ b/docs/catalog-browse.md @@ -1,8 +1,8 @@ # Sketch: browsable catalog -Status: **sketch, not designed in detail** (no lexicon or build order locked -yet — this is the "what would it take" pass requested before committing to -one). Builds on [catalog authority](./catalog-authority.md) (canonical +Status: **v1 built 2026-07** (known-users fan-out, `/browse` grid, one-tap +adoption); v2 (Jetstream indexer) sketched with its trigger conditions +below. Builds on [catalog authority](./catalog-authority.md) (canonical `catalog.release` records, adopt-or-mint, the attribution chip) and reuses the amend flow's blob-copy machinery. @@ -42,22 +42,22 @@ stack answers that: Two ways to get an index, worth picking based on scale rather than theorizing: -### v1: index only the users at-record already knows about +### v1: index only the users at-record already knows about (built 2026-07) -The server already learns a DID + handle on every OAuth login -(`oauth/flow.gleam`); it just doesn't persist that list anywhere durable -today (it's decrypted per-session, not queryable). Add a small `known_users` -Postgres table (`did`, `handle`, `first_seen_at` — same idiom as -`sessions_postgres.gleam`'s `table_store`), upserted on login. A browse -endpoint then does a live (or periodically cached) `listRecords` against -`catalog.release` for each known DID via their PDS and merges the results. +Built as sketched: a `known_users` store (memory + Postgres, `did`/ +`handle`/`pds` upserted at OAuth-callback time; storing the pds at login +means browse never resolves identities), and `GET /api/browse` fanning out +public `listRecords` per known user, deduping by discogs external id, with +`owned`/`wanted` badges computed against the viewer's crate fold. +`POST /api/browse/add` is the want-it/have-it write: verify-refetch the +release, snapshot from the canonical record, foreign cover bytes copied +into the adopter's repo, genesis with `origin: "adoption"` and a cid-pinned +ref. -Cheap, no new infrastructure, and honest about what it shows ("catalog -entries from at-record users," not "the whole network") — which matches -catalog-authority's already-stated posture: _"Non-goals (v1): a central -catalog service, global dedup... convergence is social, not enforced."_ This -degrades gracefully: it's just slow and repeats work as the user count -grows, which is exactly the trigger to move to v2. +Honest about what it shows ("catalog entries from at-record users," not +"the whole network"), which matches catalog-authority's stated posture. +Degrades gracefully: slower as the user count grows, which is exactly the +trigger to move to v2. ### v2: Jetstream-filtered indexer @@ -67,31 +67,52 @@ When "known users" stops being enough users to be useful, or the live writes into a Postgres materialized table (uri, cid, did, title, released, cover ref, indexed_at). This is the same shape of infra the e2e devnet already runs locally (jetstream + constellation, see -[e2e/README.md](../e2e/README.md)) — running one in production is the +[e2e/README.md](../e2e/README.md)); running one in production is the actual scope increase, not the concept. -**Recommendation: build v1 now, revisit v2 when the known-users fan-out -demonstrably doesn't scale.** Don't build the indexer speculatively. - -## Gap: `catalog.release` has no queryable artist field - -`promotion.gleam`'s `mint` already has the artist as a plain string -(`discogs_client.Release.artist`) at the point it writes the record, but -`catalog.release`'s only artist field is `creditedArtists` — an array of -`#credit` objects where each credit points at _another_ `catalogRef`, -meaning artists would need to be first-class minted catalog entities before -that field means anything. That's a bigger feature (and echoes the -already-open "master vs release" entity question in catalog-authority's open -decisions) — not worth doing just to render "Title — Artist" in a browse -grid. - -**Cheaper fix:** add a plain `artistDisplay: string` field to -`catalog.release` (additive, mirrors `shelf.entry.snapshot.artist_display`) -and set it from `release.artist` in `mint`. One lexicon field, one line at -the mint call site. `creditedArtists` stays reserved for whenever artist -entities actually get built. - -## Actions from the browse page +Probably not even hand-rolled: +[quickslice](https://tangled.org/slices.network/quickslice) (microcosm- +adjacent, early-stage, Apache-2.0) is exactly this shape off the shelf. +Feed it lexicons and it gives Jetstream ingestion filtered to those +collections, CAR-file backfill for pre-existing records, and a generated +GraphQL query API over SQLite/Postgres. It removes the write-the-consumer +work but not the run-it-forever obligation: it is self-hosted software, so +the liveness contract in point 2 below still has to be signed by a real +deployment. The other microcosm services stay complementary rather than +substitutes: Constellation is backlink-shaped (adoption counts, not +collection enumeration) and [UFOs](https://ufos.microcosm.blue/) exposes +per-NSID stats and sample records (useful for noticing foreign `crate.*` +publishers, not a queryable index). + +**Why v1 first, spelled out** (the "why not just listen to the firehose?" +question): + +1. **The firehose has no history.** Jetstream streams from now, with a + short replay window; every record that already exists is invisible to + it. Any indexer therefore needs a backfill path: a list of repos to + crawl before the stream takes over. `known_users` is that seed list, so + it gets built either way; v1 just uses it directly. +2. **A consumer is a liveness contract.** Miss more than the replay window + and the index silently lies (looks complete, is not). That contract + needs an always-on deployment, which a dev-loop-on-a-laptop cannot + sign. The fan-out's failure mode is slower-but-truthful at read time. +3. **The payoff needs publishers that don't exist yet.** The indexer wins + at scale and catches non-at-record publishers of `crate.*` records; at + one known user, both properties are moot. The fan-out inside the browse + handler is the only piece v2 replaces; the response shape, the + `known_users` store, and the adoption write path all carry over. + +## The artist field (resolved 2026-07) + +Both halves of the original gap closed at once: `catalog.release` gained a +plain `artistDisplay` string (additive, set from the Discogs artist at mint +time; what the browse grid renders), and the "bigger feature" happened +anyway: artist entities exist as a PoC and `creditedArtists` is populated +with real refs at mint (see [catalog-entities](./catalog-entities.md)). +Browse-by-artist as a first-class page remains open, as the natural first +read path for those entities. + +## Actions from the browse page (built 2026-07) Both actions mint a `shelf.entry` genesis with `release: ` already set — no Discogs search, no adopt-or-mint discovery step, because @@ -100,16 +121,11 @@ the ref is already known from the index: - **Want it** → genesis with `action: "wanted"`. - **I have this** → genesis with `action: "acquired"`. -Still worth a `getRecord` fetch against the ref before writing (the index -can be stale or, in the v1 fan-out design, briefly wrong) — same -verify-before-trust posture adopt-or-mint already uses, and the same -blob-copy path `amend.gleam`'s `copy_foreign_cover` already implements -(foreign cover bytes copied into the user's own repo so the entry stays -self-contained even though the release wasn't minted by them). - -This is a thin addition to the existing add path — a variant of -`add_shelf_item` that takes a `release` ref instead of a Discogs id, skips -straight to genesis-with-known-ref instead of adopt-or-mint's discovery. +Built as sketched: `POST /api/browse/add` verify-refetches the ref before +writing (the fan-out can be briefly stale), snapshots from the canonical +record, copies foreign cover bytes into the adopter's repo via the shared +`covers.copy_cover_by_uri`, and writes the genesis with +`origin: "adoption"` and a cid-pinned ref. ## Adoption count (nice to have) @@ -122,26 +138,31 @@ by adoption count" decision. ## UI sketch -A `/browse` route reusing the existing visual language: the same -`cover_card` grid the crate view uses, a text filter over the (client-held -or server-searched) title/artist strings, and per-card quick actions instead -of a link into the add form — `WANT IT` / `I HAVE THIS` buttons, with a -badge instead of the buttons when the release is already in the viewer's own -crate (owned or wanted). Picking either action is a single write, no -intermediate form, since every field the add form normally collects already -exists on the canonical record. +A `/browse` route reusing the existing visual language: the same cover-tile +grid the crate view uses, per-card quick actions instead of a link into the +add form — `WANT IT` / `I HAVE THIS` buttons, with a badge instead of the +buttons when the release is already in the viewer's own crate (owned or +wanted). Picking either action is a single write, no intermediate form, +since every field the add form normally collects already exists on the +canonical record. Built as described, plus a `via @handle` publisher line +per card; the text filter over title/artist strings is still open (worth +adding once the grid outgrows a screenful). ## Open decisions - **v1 fan-out cost:** live `listRecords`-per-known-user on every browse load vs a periodic cache refresh (cron-ish, or refresh-on-login). Live is simplest; cache is the obvious next step once the known-users list is more - than a handful of people. + than a handful of people. The fan-out is also currently sequential (one + blocking request per known user; reviewed 2026-07, left as is because + gleam_otp no longer ships a task module, so concurrency means a small + hand-rolled spawn-and-collect helper). Parallelizing it is the cheap first + lever, before caching and long before v2. - **Dedup across users' `catalog.release`s:** v1 will show near-duplicate releases minted by different users pointing at the same Discogs id (no global dedup, per catalog-authority's stated posture). Worth grouping by `externalIds` match in the browse view even before real dedup exists — cheap, and avoids an obviously repetitive grid. -- **`artistDisplay` vs waiting for `creditedArtists`:** recommended above; - flagging as a decision since it's a lexicon change that should be made - deliberately, not as a drive-by. +- **Browse-by-artist:** the entity PoC and `artistDisplay` both exist now; + an artist page (grid filtered by credited artist) would be the first UI + consumer of the entity records. diff --git a/docs/discogs-import.md b/docs/discogs-import.md index e88f9b5..df955d6 100644 --- a/docs/discogs-import.md +++ b/docs/discogs-import.md @@ -180,8 +180,10 @@ see the cut at the top).** ## Open decisions -- **Wantlist too?** `GET users/{u}/wants` → genesis with `action: "wanted"`. - Cheap to add; still v1-optional, not built. +- **Wantlist (built 2026-07):** `GET users/{u}/wants` → genesis with + `action: "wanted"`, sharing the collection pipeline (same dedup, caps, + rate-limit handling) via a parameterized page fetcher; IMPORT WANTLIST + button next to IMPORT COLLECTION. - **Folder selection?** Users have custom collection folders. Still imports folder 0 (all); folder picker not built. - **Per-instance fields?** Discogs collection instances can carry rating /