//// Fold the append-only shelf.entry event log into the current crate view. //// The genesis event (no subject) IS the entry: its record TID identifies it, //// and later events reference it via a subject strongRef. Groups are ordered //// by rkey (TID) and reduced: status from the latest status-changing action, //// every other field last-write-wins. import crate/gen/catalog/release.{type CatalogRelease} import crate/gen/defs.{type CatalogRef, type Price, type Snapshot, type Source} import crate/gen/shelf/entry.{type ShelfEntry} import crate/storage.{type StoredItem} import crate_server/catalog/row.{type BrowseRow} import gleam/dict import gleam/json.{type Json} import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string pub type Status { Owned Wanted Gone } pub type CrateEntry { CrateEntry( entry_id: String, status: Status, snapshot: Option(Snapshot), media_grade: Option(String), sleeve_grade: Option(String), rating: Option(Int), folder: Option(String), notes: Option(String), release: Option(CatalogRef), price: Option(Price), counterparty: Option(String), source: Option(Source), created_at: String, updated_at: String, events: List(ShelfEntry), ) } pub fn fold(stored: List(StoredItem(ShelfEntry))) -> List(CrateEntry) { // Appends whose genesis is gone (partial purge) drop out of the view; the // group dies with its genesis record. let appends = stored |> list.fold(dict.new(), fn(acc, s) { case s.value.subject { Some(ref) -> dict.upsert(acc, ref.uri, fn(existing) { case existing { Some(xs) -> [s, ..xs] None -> [s] } }) None -> acc } }) stored |> list.filter(fn(s) { s.value.subject == None }) |> list.map(fn(genesis) { let group = [genesis, ..dict.get(appends, genesis.uri) |> result.unwrap([])] fold_group(genesis, group) }) } pub fn status_string(status: Status) -> String { case status { Owned -> "owned" Wanted -> "wanted" Gone -> "gone" } } /// The inverse of `status_string`, for folding a stored/flattened status /// column back into `Status`; an unrecognized value defaults to `Owned` /// (matching `fold_group`'s seed) rather than failing the read. pub fn status_from_string(status: String) -> Status { case status { "wanted" -> Wanted "gone" -> Gone _ -> Owned } } /// The genesis event action implied by a requested status string. pub fn action_from_status(status: String) -> String { case status { "wanted" -> "wanted" _ -> "acquired" } } fn fold_group( genesis: StoredItem(ShelfEntry), group: List(StoredItem(ShelfEntry)), ) -> CrateEntry { let events = group |> list.sort(fn(a, b) { string.compare(a.rkey, b.rkey) }) |> list.map(fn(s) { s.value }) let seed = CrateEntry( entry_id: genesis.rkey, status: Owned, snapshot: None, media_grade: None, sleeve_grade: None, rating: None, folder: None, notes: None, release: None, price: None, counterparty: None, source: genesis.value.source, created_at: genesis.value.created_at, updated_at: genesis.value.created_at, events: [], ) CrateEntry(..list.fold(events, seed, apply), events:) } fn apply(acc: CrateEntry, e: ShelfEntry) -> CrateEntry { CrateEntry( ..acc, status: case status_of(e.action) { Some(s) -> s None -> acc.status }, snapshot: or(e.snapshot, acc.snapshot), media_grade: or(e.media_grade, acc.media_grade), sleeve_grade: or(e.sleeve_grade, acc.sleeve_grade), rating: or(e.rating, acc.rating), folder: or(e.folder, acc.folder), notes: or(e.notes, acc.notes), release: or(e.release, acc.release), price: or(e.price, acc.price), counterparty: or(e.counterparty, acc.counterparty), updated_at: e.created_at, ) } fn status_of(action: String) -> Option(Status) { case action { "acquired" -> Some(Owned) "wanted" -> Some(Wanted) "sold" | "dropped" -> Some(Gone) _ -> None } } fn or(new: Option(a), old: Option(a)) -> Option(a) { case new { Some(_) -> new None -> old } } pub fn encode_entry(e: CrateEntry) -> Json { json.object( list.flatten([ [ #("entryId", json.string(e.entry_id)), #("status", json.string(status_string(e.status))), #("updatedAt", json.string(e.updated_at)), ], opt("snapshot", e.snapshot, defs.encode_snapshot), opt("release", e.release, defs.encode_catalog_ref), opt("mediaGrade", e.media_grade, json.string), opt("sleeveGrade", e.sleeve_grade, json.string), opt("rating", e.rating, json.int), opt("folder", e.folder, json.string), opt("notes", e.notes, json.string), opt("price", e.price, defs.encode_price), opt("counterparty", e.counterparty, json.string), opt("source", e.source, defs.encode_source), ]), ) } /// Encode an optional value as one JSON field, or nothing when absent. pub fn opt( key: String, value: Option(a), encode: fn(a) -> Json, ) -> List(#(String, Json)) { case value { Some(v) -> [#(key, encode(v))] None -> [] } } /// Display-relevant fields off a resolved catalog release, for read paths /// that only need to show them, not round-trip the whole record. pub fn encode_release_info(release: CatalogRelease) -> Json { json.object( list.flatten([ opt("artistDisplay", release.artist_display, json.string), opt("genres", release.genres, fn(items) { json.array(items, json.string) }), opt("styles", release.styles, fn(items) { json.array(items, json.string) }), opt("country", release.country, json.string), opt("released", release.released, json.string), ]), ) } /// `encode_release_info`'s sibling over the catalog index's `BrowseRow` /// (C5 of the appview-first roadmap): same field set and omission rules, so /// swapping the entry-timeline release read from a live fetch to the index /// changes nothing on the wire. `genres`/`styles` are flat (never absent) on /// `BrowseRow`, so an empty list maps to the same omitted-field shape a /// `None` genres/styles produces from a live `CatalogRelease`. pub fn encode_release_info_row(row: BrowseRow) -> Json { json.object( list.flatten([ opt("artistDisplay", row.artist_display, json.string), opt("genres", as_option(row.genres), fn(items) { json.array(items, json.string) }), opt("styles", as_option(row.styles), fn(items) { json.array(items, json.string) }), opt("country", row.country, json.string), opt("released", row.released, json.string), ]), ) } fn as_option(items: List(a)) -> Option(List(a)) { case items { [] -> None _ -> Some(items) } }