Round 4 was targeted at the surfaces rounds 2 and 3 changed. 29 findings raised, 26 survived deduplication, 12 survived three adversarial readings each. `set_delivery` renewed the claim but never checked it was still ours, while its sibling `release_send` always did. `expire_on_commit` is off, so the token it re-stamped came from the caller's own memory, not the row: a send that outlived its 600 s lease came back, overwrote the take-over send's committed links with a full-column write, and put its own dead token back. It now takes the token, reads the row's claim with a targeted scalar (not a refresh, which would discard the `issue_url` assigned one line earlier) and raises `SendClaimLost` rather than writing. An issue created by a send that had already lost its claim is logged with its number — that record is the only one that will exist. The tracker's settings are read above the pre-call checkpoint instead of inside `_client`. Reading them there was a SELECT one line after the commit that was meant to end the transaction, and held it — idle, snapshot pinned — across the repository check and the create. The comment and the module docstring said otherwise; measuring it in pg_stat_activity is what settled it. Two more comments were saying things the code had stopped doing: the `**state` spread does not carry the claim (`set_delivery` strips and re-stamps it), and react-query does not debounce the preview — the editor being unmounted is what paces it. The composer stopped stranding the focus on `<body>`: a send that creates the issue and then fails on an attachment unmounts the button that was just pressed, and only the success path handed the focus on. Export no longer saves into a refusal it does not need — the zip is the way out of a stuck send, and its pre-save was the one step that could not survive one. The delivery banner announces through a role instead of an `aria-live` created with its own message. `historyState` returns a union, so `chipTone` matching on the same keys is checked by tsc rather than by hope. PlexPlayer was the only full-screen overlay in the tree with no focus containment at all — Tab walked out of an opaque player into a page nobody can see. It now carries the shared trap. Closing that hole exposed the one underneath it: `useDismiss` registered as a layer that traps the Tab while installing no trap, so the player stood its own down for its own menus. Measured in a real browser: 30 Tabs with the settings panel open, 9 escapes into the nav rail, and 0 after. Escape ordering is unchanged — the panel first, then the player. Three tests that did not hold their line: the advisory lock's call site (deleting it left the suite green), the trail assertion whose `all()` was vacuous over an empty filter, and a `layerStack` assertion the stand-in dialog already forced. All eleven executable fixes mutation-checked red for the right reason; the two comment corrections have nothing to pin.
159 lines
9.5 KiB
TypeScript
159 lines
9.5 KiB
TypeScript
import type { FeedFilters } from "./api";
|
|
|
|
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
|
|
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
|
|
// (the R7 driver: `["feed"]` was hand-written in 15+ files). CONTRACT: each function returns the
|
|
// SAME array shape the call sites used before, so TanStack's partial (prefix) matching is
|
|
// unchanged — `qk.feed()` (`["feed"]`) still invalidates `qk.feed(filters)` (`["feed", filters]`).
|
|
// Deliberately NOT bundling co-invalidations into helpers here: the clusters differ per call site
|
|
// (some invalidate `feed` alone, others `feed` + `feed-count`), so collapsing them would change
|
|
// which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`).
|
|
// Extended per domain as R7 S2 migrates each area (S2a: feed/channels/playlists + shared roots).
|
|
export const qk = {
|
|
// --- shared roots (invalidated from more than one domain) ---
|
|
me: () => ["me"] as const,
|
|
myStatus: () => ["my-status"] as const,
|
|
tags: () => ["tags"] as const,
|
|
// Per-channel suggested user tags, learned from the manual-tag pattern (E8·S4). Separate root so
|
|
// accepting a suggestion can invalidate it without touching the whole tag catalog.
|
|
tagSuggestions: () => ["tag-suggestions"] as const,
|
|
|
|
// --- feed / video ---
|
|
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
|
|
// key" is the ONE rule across the factory. It also keeps a nullable segment intact: `q`/`id`
|
|
// accept null because some call sites key on nullable state (`selectedId`, the search string),
|
|
// and a NULL is a real key segment that must be preserved (`["playlist", null]`) rather than
|
|
// silently collapsed to the prefix.
|
|
feed: (filters?: FeedFilters) =>
|
|
filters !== undefined ? (["feed", filters] as const) : (["feed"] as const),
|
|
feedCount: (filters?: FeedFilters) =>
|
|
filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const),
|
|
facets: (filters?: FeedFilters) =>
|
|
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
|
|
feedSummary: (filters?: FeedFilters) =>
|
|
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
|
|
feedYears: (filters?: FeedFilters) =>
|
|
filters !== undefined ? (["feed-years", filters] as const) : (["feed-years"] as const),
|
|
ytSearch: (q?: string | null, count?: number) =>
|
|
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
|
videoDetail: (id: string) => ["video-detail", id] as const,
|
|
|
|
// --- channels ---
|
|
channels: () => ["channels"] as const,
|
|
channel: (id: string) => ["channel", id] as const,
|
|
discoveredChannels: () => ["discovered-channels"] as const,
|
|
blockedChannels: () => ["blocked-channels"] as const,
|
|
|
|
// --- playlists ---
|
|
playlists: () => ["playlists"] as const,
|
|
playlist: (id?: number | null) =>
|
|
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
|
|
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
|
|
// Keyed by source ("feed" | "plex") so each module's views cache independently; `savedViews()` (no
|
|
// arg) stays a prefix for a cross-source invalidation.
|
|
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
|
|
|
|
// --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above)
|
|
accounts: () => ["accounts"] as const,
|
|
version: () => ["version"] as const,
|
|
setupStatus: () => ["setup-status"] as const,
|
|
myUsage: () => ["my-usage"] as const,
|
|
scheduler: () => ["scheduler"] as const,
|
|
|
|
// --- notifications ---
|
|
notifications: () => ["notifications"] as const,
|
|
notifUnread: () => ["notif-unread"] as const,
|
|
|
|
// --- messaging ---
|
|
conversations: () => ["conversations"] as const,
|
|
thread: (partnerId: number | null) => ["thread", partnerId] as const,
|
|
messageKey: () => ["message-key"] as const,
|
|
messageUnread: () => ["message-unread"] as const,
|
|
messageUsers: () => ["message-users"] as const,
|
|
shareRecipients: () => ["share-recipients"] as const,
|
|
|
|
// --- downloads ---
|
|
downloads: () => ["downloads"] as const,
|
|
downloadsShared: () => ["downloads-shared"] as const,
|
|
downloadIndex: () => ["download-index"] as const,
|
|
downloadUsage: () => ["download-usage"] as const,
|
|
downloadProfiles: () => ["download-profiles"] as const,
|
|
downloadCredentials: () => ["download-credentials"] as const,
|
|
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
|
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
|
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
|
|
|
// --- admin ---
|
|
adminUsers: () => ["admin-users"] as const,
|
|
adminUserAccess: (id: number) => ["admin-user-access", id] as const,
|
|
adminInvites: () => ["admin-invites"] as const,
|
|
adminPendingAccess: () => ["admin-pending-access"] as const,
|
|
adminConfig: () => ["admin-config"] as const,
|
|
adminAudit: () => ["admin-audit"] as const,
|
|
// The console's filters are part of the key; `since` is not (it moves with the clock) — the
|
|
// window it came from is.
|
|
adminLogs: (filters: object) => ["admin-logs", filters] as const,
|
|
adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
|
|
adminStorage: () => ["admin-storage"] as const,
|
|
// --- debug sessions (E16) ---
|
|
debugSessions: () => ["debug-sessions"] as const,
|
|
debugSession: (id: string) => ["debug-sessions", id] as const,
|
|
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
|
|
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
|
|
// The tracker's rendering of a body: the text is the key. Nothing debounces it — react-query has
|
|
// no such thing — so one fetch per distinct text. What holds the count down is that the composer
|
|
// unmounts the editor while the preview is up, plus the cache for an unchanged draft; a preview
|
|
// shown BESIDE the fields would need a real debounce first (see routes/debug_sessions.py).
|
|
debugSessionPreview: (id: string, text: string) =>
|
|
["debug-sessions", id, "preview", text] as const,
|
|
adminDownloads: () => ["admin-downloads"] as const,
|
|
adminQuota: (days: number) => ["admin-quota", days] as const,
|
|
demoWhitelist: () => ["demo-whitelist"] as const,
|
|
|
|
// --- plex ---
|
|
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
|
|
// array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through:
|
|
// the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]`
|
|
// anyway. Single-id plex keys stay precisely typed.
|
|
// The Home surface (E24). `discover()` with no args is the prefix every mutation invalidates —
|
|
// pinning something changes the shelf AND can change the hero, so the two are never invalidated
|
|
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails —
|
|
// but it sits UNDER the same root, because a sibling root ("discover-pick") is not matched by
|
|
// that prefix and the rerolled hero silently outlived every invalidation on the page: play it,
|
|
// finish it, and it was still the answer on screen with a zeroed progress bar.
|
|
discover: (...rest: unknown[]) => ["discover", ...rest],
|
|
discoverPick: (...rest: unknown[]) => ["discover", "pick", ...rest],
|
|
comfortPins: () => ["comfort-pins"] as const,
|
|
plex: () => ["plex"] as const,
|
|
plexWatchLink: () => ["plex-watch-link"] as const,
|
|
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
|
// Discover surface (E5 S4): the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches
|
|
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
|
|
// refetches it alone.
|
|
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
|
|
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre
|
|
// filter narrows against the language-specific genre column, like the grid). The ↻ reshuffle is the
|
|
// query's own refetch, and nothing invalidates this key — deliberately: a watched toggle flips the
|
|
// card in place (and reverts it on a failure) rather than reshuffle the rail under the viewer.
|
|
plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest],
|
|
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
|
|
// Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN
|
|
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
|
|
plexShow: (showId?: string, lang?: string) =>
|
|
showId !== undefined
|
|
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
|
|
: (["plex-show"] as const),
|
|
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
|
|
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
|
|
// that steers the background extraction, not part of the track's identity.
|
|
plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const,
|
|
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
|
|
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
|
|
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
|
|
// facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too.
|
|
plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest],
|
|
plexPeopleCards: (scope: string, actors: string[]) =>
|
|
["plex-people-cards", scope, actors] as const,
|
|
plexPerson: (name: string) => ["plex-person", name] as const,
|
|
};
|