Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
This commit was merged in pull request #50.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// Inflight-token guard for stores whose async loads can be re-triggered
|
||||
// by rapid filter/navigation changes. Without this, late responses
|
||||
// from a prior load overwrite the store with stale data
|
||||
// (last-writer-wins, not request-order-wins). gallery.js had a
|
||||
// hand-rolled `inflightId` of the same shape; this composable
|
||||
// extracts it so every store can use the same pattern.
|
||||
//
|
||||
// Audit 2026-06-02 (workflow wf_bbe3fdb1-e62) found this missing in
|
||||
// modal/suggestions/artist/downloads/directory/posts. The two most
|
||||
// operator-impacting consequences: (1) modal tag mutations could
|
||||
// land DELETE/POST on the wrong image when the user navigated mid-
|
||||
// flight; (2) suggestions accept could push a tag to the wrong
|
||||
// image AND add it to the allowlist.
|
||||
//
|
||||
// Usage:
|
||||
// const inflight = useInflightToken()
|
||||
//
|
||||
// async function load() {
|
||||
// const t = inflight.claim()
|
||||
// const body = await api.get(...)
|
||||
// if (!t.isCurrent()) return // stale — abort write
|
||||
// items.value = body.items // safe to commit
|
||||
// }
|
||||
//
|
||||
// function setFilter(f) {
|
||||
// inflight.cancel() // any in-flight token is now stale
|
||||
// filter.value = f
|
||||
// load() // claims a fresh token
|
||||
// }
|
||||
//
|
||||
// For multi-await flows (POST then GET, optimistic mutation then
|
||||
// reconcile), check isCurrent() after EACH await — any intervening
|
||||
// claim() or cancel() invalidates the prior token.
|
||||
export function useInflightToken() {
|
||||
let _seq = 0
|
||||
let _current = 0
|
||||
|
||||
function claim() {
|
||||
_current = ++_seq
|
||||
const id = _current
|
||||
return {
|
||||
id,
|
||||
isCurrent: () => _current === id,
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
_current = ++_seq
|
||||
}
|
||||
|
||||
return { claim, cancel }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
import { usePostsStore } from './posts.js'
|
||||
|
||||
const PAGE = 60
|
||||
@@ -15,12 +16,17 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
const error = ref(null)
|
||||
const notFound = ref(false)
|
||||
let started = false
|
||||
// Rapid artist-to-artist navigation used to render the previous
|
||||
// artist's overview/images briefly when the second load resolved
|
||||
// after the third. Audit 2026-06-02.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
async function load (slug) {
|
||||
// Cross-artist reset: clear this store AND the posts store so the new
|
||||
// artist doesn't briefly render with the previous artist's content
|
||||
// when the user is on the Posts tab. (Gallery tab uses this artist
|
||||
// store's own images list — cleared above.)
|
||||
inflight.cancel()
|
||||
overview.value = null
|
||||
images.value = []
|
||||
nextCursor.value = null
|
||||
@@ -29,14 +35,18 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
error.value = null
|
||||
loading.value = true
|
||||
usePostsStore().$reset?.()
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
||||
const body = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
||||
if (!t.isCurrent()) return
|
||||
overview.value = body
|
||||
await loadMoreImages(slug)
|
||||
} catch (e) {
|
||||
if (!t.isCurrent()) return
|
||||
if (e.status === 404) notFound.value = true
|
||||
else error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,19 +54,22 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
if (imagesLoading.value) return
|
||||
if (started && nextCursor.value === null) return
|
||||
imagesLoading.value = true
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const params = { limit: PAGE }
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get(
|
||||
`/api/artist/${encodeURIComponent(slug)}/images`, { params }
|
||||
)
|
||||
if (!t.isCurrent()) return
|
||||
images.value.push(...body.images)
|
||||
nextCursor.value = body.next_cursor
|
||||
started = true
|
||||
} catch (e) {
|
||||
if (!t.isCurrent()) return
|
||||
error.value = e.message
|
||||
} finally {
|
||||
imagesLoading.value = false
|
||||
if (t.isCurrent()) imagesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
const PAGE = 60
|
||||
|
||||
@@ -13,16 +14,23 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
||||
const q = ref('')
|
||||
const platform = ref(null)
|
||||
let started = false
|
||||
// Typed "alice" then "alice bob" used to drop the second fetch
|
||||
// entirely (loading flag still true from the first), so the UI
|
||||
// showed alice results while the input said "alice bob". Inflight
|
||||
// token + reset() cancelling in-flight requests fixes both: the
|
||||
// first response is discarded, the second is fetched. Audit 2026-06-02.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value) return
|
||||
if (started && nextCursor.value === null) return
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const params = { limit: PAGE }
|
||||
if (q.value) params.q = q.value
|
||||
if (platform.value) params.platform = platform.value
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get('/api/artists/directory', { params })
|
||||
if (!t.isCurrent()) return
|
||||
cards.value.push(...body.cards)
|
||||
nextCursor.value = body.next_cursor
|
||||
started = true
|
||||
@@ -30,6 +38,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
inflight.cancel()
|
||||
cards.value = []
|
||||
nextCursor.value = null
|
||||
started = false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const api = useApi()
|
||||
@@ -22,6 +23,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
// the "active now" panel always reflects what's happening regardless of
|
||||
// how the operator has filtered the historical list below.
|
||||
const activeEvents = ref([])
|
||||
// Filter changes (applyFilter) and rapid pagination can interleave
|
||||
// responses; without an inflight guard the late response from a
|
||||
// prior filter overwrites the current view. Audit 2026-06-02.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
function _params(extra = {}) {
|
||||
const out = { limit: 50, ...extra }
|
||||
@@ -32,8 +37,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
}
|
||||
|
||||
async function loadFirst() {
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const body = await api.get('/api/downloads', { params: _params() })
|
||||
if (!t.isCurrent()) return
|
||||
events.value = body
|
||||
cursor.value = body.length ? body[body.length - 1].id : null
|
||||
hasMore.value = body.length === 50
|
||||
@@ -42,8 +49,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore.value || cursor.value == null) return
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
|
||||
if (!t.isCurrent()) return
|
||||
events.value.push(...body)
|
||||
cursor.value = body.length ? body[body.length - 1].id : cursor.value
|
||||
hasMore.value = body.length === 50
|
||||
@@ -71,6 +80,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
}
|
||||
|
||||
async function applyFilter(patch) {
|
||||
// Drop any in-flight loadFirst/loadMore from the previous filter
|
||||
// so its late response doesn't overwrite this filter's results.
|
||||
inflight.cancel()
|
||||
filter.value = { ...filter.value, ...patch }
|
||||
await loadFirst()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
||||
// 50-item request so items render as each batch lands. Total initial
|
||||
@@ -22,9 +23,12 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
const timelineBuckets = ref([])
|
||||
const timelineLoading = ref(false)
|
||||
|
||||
let inflightId = 0
|
||||
// Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out
|
||||
// moved this pattern into useInflightToken so every store can share it.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
async function loadInitial() {
|
||||
inflight.cancel()
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
nextCursor.value = null
|
||||
@@ -41,19 +45,19 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const myId = ++inflightId
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const params = { limit: PAGE, ...activeFilterParam() }
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get('/api/gallery/scroll', { params })
|
||||
if (myId !== inflightId) return // stale response
|
||||
if (!t.isCurrent()) return
|
||||
images.value.push(...body.images)
|
||||
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
|
||||
nextCursor.value = body.next_cursor
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
if (myId === inflightId) loading.value = false
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +72,14 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
}
|
||||
|
||||
async function jumpTo(year, month) {
|
||||
// Rapid timeline-jump clicks need the same race guard as
|
||||
// loadMore — first jump's late body could clobber the second
|
||||
// jump's already-applied state.
|
||||
inflight.cancel()
|
||||
const t = inflight.claim()
|
||||
const params = { year, month, ...activeFilterParam() }
|
||||
const body = await api.get('/api/gallery/jump', { params })
|
||||
if (!t.isCurrent()) return
|
||||
if (body.cursor) {
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
export const useModalStore = defineStore('modal', () => {
|
||||
const api = useApi()
|
||||
@@ -10,6 +11,11 @@ export const useModalStore = defineStore('modal', () => {
|
||||
const currentImageId = ref(null)
|
||||
const current = ref(null)
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
// Tag mutations interpolate the image id into the URL after an
|
||||
// await; without an inflight token, a fast prev/next can route the
|
||||
// DELETE/POST to the wrong image AND the response to the wrong
|
||||
// chip rail. Audit 2026-06-02.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
||||
@@ -19,6 +25,9 @@ export const useModalStore = defineStore('modal', () => {
|
||||
const postImageIndex = ref(0)
|
||||
|
||||
async function open (id, opts = {}) {
|
||||
// Cancel any in-flight tag mutation or reloadTags from the
|
||||
// previous image so its late response can't apply to this one.
|
||||
inflight.cancel()
|
||||
currentImageId.value = id
|
||||
current.value = null // cleared upfront so it stays null on error
|
||||
// Update post-scoped state if caller passed it; otherwise clear so
|
||||
@@ -31,12 +40,16 @@ export const useModalStore = defineStore('modal', () => {
|
||||
postImageIds.value = null
|
||||
postImageIndex.value = 0
|
||||
}
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
current.value = await api.get(`/api/gallery/image/${id}`)
|
||||
const body = await api.get(`/api/gallery/image/${id}`)
|
||||
if (!t.isCurrent()) return
|
||||
current.value = body
|
||||
})
|
||||
}
|
||||
|
||||
async function close () {
|
||||
inflight.cancel()
|
||||
currentImageId.value = null
|
||||
current.value = null
|
||||
error.value = null
|
||||
@@ -75,37 +88,74 @@ export const useModalStore = defineStore('modal', () => {
|
||||
}
|
||||
|
||||
async function reloadTags () {
|
||||
if (!currentImageId.value) return
|
||||
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
|
||||
if (current.value) current.value.tags = tags
|
||||
// Capture image id at call-time. After an await, currentImageId
|
||||
// may have advanced via prev/next navigation; without capture, the
|
||||
// GET would target the new image and write its tags onto a chip
|
||||
// rail the user didn't open. Audit 2026-06-02.
|
||||
const imageId = currentImageId.value
|
||||
if (!imageId) return
|
||||
const t = inflight.claim()
|
||||
const tags = await api.get(`/api/images/${imageId}/tags`)
|
||||
if (!t.isCurrent()) return
|
||||
// Only commit if the modal is still showing this image — guards
|
||||
// against close() / nav clearing current between the await and now.
|
||||
if (current.value && currentImageId.value === imageId) {
|
||||
current.value.tags = tags
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTag (tagId) {
|
||||
if (!currentImageId.value) return
|
||||
const imageId = currentImageId.value
|
||||
if (!imageId) return
|
||||
const prev = current.value.tags
|
||||
// Optimistic UI: drop the chip immediately.
|
||||
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
|
||||
// Split the two POSTs so a dismiss failure (secondary side-effect)
|
||||
// doesn't roll back the successful DELETE — previously the catch
|
||||
// unconditionally restored the chip rail even when only the
|
||||
// dismiss had failed, so the UI lied until refresh. Audit 2026-06-02.
|
||||
try {
|
||||
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
||||
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
|
||||
await api.delete(`/api/images/${imageId}/tags/${tagId}`)
|
||||
} catch (e) {
|
||||
// Real failure: roll back, surface, rethrow.
|
||||
if (current.value && currentImageId.value === imageId) {
|
||||
current.value.tags = prev
|
||||
}
|
||||
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
|
||||
throw e
|
||||
}
|
||||
// DELETE landed. The dismiss is fire-and-best-effort — log on
|
||||
// failure but DON'T roll back the chip rail; the tag is gone
|
||||
// server-side regardless.
|
||||
try {
|
||||
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: tagId },
|
||||
})
|
||||
} catch (e) {
|
||||
current.value.tags = prev
|
||||
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
|
||||
throw e
|
||||
toast({
|
||||
text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function addExistingTag (tagId) {
|
||||
if (!currentImageId.value) return
|
||||
await api.post(`/api/images/${currentImageId.value}/tags`, {
|
||||
const imageId = currentImageId.value
|
||||
if (!imageId) return
|
||||
await api.post(`/api/images/${imageId}/tags`, {
|
||||
body: { tag_id: tagId, source: 'manual' },
|
||||
})
|
||||
await reloadTags()
|
||||
}
|
||||
|
||||
async function createAndAdd ({ name, kind, fandom_id = null }) {
|
||||
// Capture imageId so the post-create reloadTags / addExistingTag
|
||||
// flow stays bound to the image the user clicked on, even if
|
||||
// they navigated during the /api/tags POST.
|
||||
const imageId = currentImageId.value
|
||||
if (!imageId) return
|
||||
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
||||
if (currentImageId.value !== imageId) return // navigated away
|
||||
await addExistingTag(tag.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
export const usePostsStore = defineStore('posts', () => {
|
||||
const api = useApi()
|
||||
@@ -19,6 +20,12 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
const doneOlder = ref(false)
|
||||
const doneNewer = ref(false)
|
||||
const anchorId = ref(null)
|
||||
// loadInitial, loadMore, loadAround, loadOlder, loadNewer all share
|
||||
// one `loading` flag and previously had no inflight guard. A filter
|
||||
// change (loadInitial) racing a still-in-flight loadMore would
|
||||
// append the prior filter's items into the new filter's feed.
|
||||
// Audit 2026-06-02.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
function _qs() {
|
||||
const q = {}
|
||||
@@ -36,6 +43,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
}
|
||||
|
||||
async function loadInitial(newFilters) {
|
||||
inflight.cancel()
|
||||
filters.value = {
|
||||
artist_id: newFilters?.artist_id ?? null,
|
||||
platform: newFilters?.platform ?? null,
|
||||
@@ -46,8 +54,10 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || done.value) return
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const body = await api.get('/api/posts', { params: _qs() })
|
||||
if (!t.isCurrent()) return
|
||||
items.value.push(...body.items)
|
||||
cursor.value = body.next_cursor
|
||||
if (body.next_cursor == null) done.value = true
|
||||
@@ -79,6 +89,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
// posts feed; without this the older/newer scroll loaded unfiltered
|
||||
// global posts instead of staying in the artist's stream).
|
||||
async function loadAround(postId, newFilters) {
|
||||
inflight.cancel()
|
||||
filters.value = {
|
||||
artist_id: newFilters?.artist_id ?? null,
|
||||
platform: newFilters?.platform ?? null,
|
||||
@@ -86,10 +97,12 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
anchorId.value = null
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const body = await api.get('/api/posts', {
|
||||
params: _aroundParams({ around: postId }),
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
items.value = body.items
|
||||
cursorOlder.value = body.cursor_older
|
||||
cursorNewer.value = body.cursor_newer
|
||||
@@ -97,47 +110,54 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
doneNewer.value = body.cursor_newer == null
|
||||
anchorId.value = body.anchor_id
|
||||
} catch (e) {
|
||||
if (!t.isCurrent()) return
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loading.value || doneOlder.value || cursorOlder.value == null) return
|
||||
loading.value = true
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const body = await api.get('/api/posts', {
|
||||
params: _aroundParams({
|
||||
cursor: cursorOlder.value, direction: 'older',
|
||||
}),
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
items.value.push(...body.items)
|
||||
cursorOlder.value = body.next_cursor
|
||||
if (body.next_cursor == null) doneOlder.value = true
|
||||
} catch (e) {
|
||||
if (!t.isCurrent()) return
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNewer() {
|
||||
if (loading.value || doneNewer.value || cursorNewer.value == null) return
|
||||
loading.value = true
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const body = await api.get('/api/posts', {
|
||||
params: _aroundParams({
|
||||
cursor: cursorNewer.value, direction: 'newer',
|
||||
}),
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
items.value.unshift(...body.items)
|
||||
cursorNewer.value = body.next_cursor
|
||||
if (body.next_cursor == null) doneNewer.value = true
|
||||
} catch (e) {
|
||||
if (!t.isCurrent()) return
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
// Category display order: people first, general last.
|
||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
|
||||
@@ -18,12 +19,25 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
const byCategory = ref({}) // { category: [suggestion, ...] }
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
let currentImageId = null
|
||||
// Audit 2026-06-02: this store had no inflight guard — a late
|
||||
// /suggestions response from a prior image could overwrite
|
||||
// byCategory while currentImageId pointed at a new one, and
|
||||
// accept() dereferenced currentImageId AFTER an awaited POST so
|
||||
// the subsequent /suggestions/accept could apply A's chosen tag
|
||||
// to image B (and push it to the allowlist). Both fixed below
|
||||
// by capturing imageId at call-time and gating writes on the token.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
async function load(imageId) {
|
||||
// Cancel any in-flight load from the previous image so its late
|
||||
// response can't overwrite this image's byCategory.
|
||||
inflight.cancel()
|
||||
currentImageId = imageId
|
||||
byCategory.value = {} // cleared upfront so it stays empty on error
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
||||
if (!t.isCurrent()) return
|
||||
byCategory.value = body.by_category || {}
|
||||
})
|
||||
}
|
||||
@@ -35,6 +49,11 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
|
||||
async function accept(suggestion) {
|
||||
// Capture imageId so a mid-flight prev/next can't reroute the
|
||||
// accept POST to a different image AND push the tag to that
|
||||
// image's allowlist.
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
|
||||
// accept endpoint needs a tag_id, so for raw tags we create the tag
|
||||
// first via the existing /api/tags endpoint, then accept by id.
|
||||
@@ -45,10 +64,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
})
|
||||
tagId = created.id
|
||||
}
|
||||
await api.post(`/api/images/${currentImageId}/suggestions/accept`, {
|
||||
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||
body: { tag_id: tagId }
|
||||
})
|
||||
_drop(suggestion.category, s => s === suggestion)
|
||||
// Only drop from THIS image's category list — if the user navigated,
|
||||
// the new image has its own suggestions and this drop would corrupt them.
|
||||
if (currentImageId === imageId) {
|
||||
_drop(suggestion.category, s => s === suggestion)
|
||||
}
|
||||
toast({
|
||||
text: `Tagged: ${suggestion.display_name}`,
|
||||
type: 'success'
|
||||
@@ -56,14 +79,18 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
|
||||
async function aliasAccept(suggestion, canonicalTagId) {
|
||||
await api.post(`/api/images/${currentImageId}/suggestions/alias`, {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
||||
body: {
|
||||
alias_string: suggestion.display_name,
|
||||
alias_category: suggestion.category,
|
||||
canonical_tag_id: canonicalTagId
|
||||
}
|
||||
})
|
||||
_drop(suggestion.category, s => s === suggestion)
|
||||
if (currentImageId === imageId) {
|
||||
_drop(suggestion.category, s => s === suggestion)
|
||||
}
|
||||
toast({
|
||||
text: `Aliased & tagged: ${suggestion.display_name}`,
|
||||
type: 'success'
|
||||
@@ -71,15 +98,19 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
|
||||
async function dismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
|
||||
// suggestion just hides it client-side (nothing to persist a rejection
|
||||
// against until the tag exists).
|
||||
if (suggestion.canonical_tag_id != null) {
|
||||
await api.post(`/api/images/${currentImageId}/suggestions/dismiss`, {
|
||||
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: suggestion.canonical_tag_id }
|
||||
})
|
||||
}
|
||||
_drop(suggestion.category, s => s === suggestion)
|
||||
if (currentImageId === imageId) {
|
||||
_drop(suggestion.category, s => s === suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
const PAGE = 60
|
||||
|
||||
@@ -13,16 +14,21 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
|
||||
const kind = ref(null)
|
||||
const q = ref('')
|
||||
let started = false
|
||||
// Same shape as artistDirectory — rapid setQuery/setKind dropped
|
||||
// the second fetch because loading was still true from the first.
|
||||
// Audit 2026-06-02.
|
||||
const inflight = useInflightToken()
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value) return
|
||||
if (started && nextCursor.value === null) return
|
||||
const t = inflight.claim()
|
||||
await run(async () => {
|
||||
const params = { limit: PAGE }
|
||||
if (kind.value) params.kind = kind.value
|
||||
if (q.value) params.q = q.value
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get('/api/tags/directory', { params })
|
||||
if (!t.isCurrent()) return
|
||||
cards.value.push(...body.cards)
|
||||
nextCursor.value = body.next_cursor
|
||||
started = true
|
||||
@@ -30,6 +36,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
inflight.cancel()
|
||||
cards.value = []
|
||||
nextCursor.value = null
|
||||
started = false
|
||||
|
||||
Reference in New Issue
Block a user