fix(audit-g2): async race / state-leak across eight stores
Extracts gallery.js's hand-rolled inflightId pattern into a new useInflightToken composable; adopts in every store that previously had no guard against late-response overwrites or wrong-image URL interpolation. Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62) flagged: - modal.removeTag rolled back the chip rail unconditionally even when only the secondary dismiss POST had failed — UI lied until refresh. And all tag-mutation URLs interpolated currentImageId AFTER an await, so a fast prev/next could route DELETE/POST to the wrong image. Both fixed: split try/catch (dismiss failure surfaces a warning, doesn't roll back the delete); imageId captured at call-time and used in URLs throughout. - suggestions.accept dereferenced currentImageId after the awaited POST /api/tags, so the subsequent /suggestions/accept could apply A's chosen tag to image B AND push it to B's allowlist. Fixed by capturing imageId at click-time + inflight guard on load(). Same shape across artist / downloads / artistDirectory / tagDirectory / posts stores: rapid filter/nav changes used to interleave responses (last-writer-wins). Now the late response is discarded and the most-recent request wins. Filter-change-during- search no longer drops the second fetch because the loading flag was still true from the first. gallery.js's inflightId removed in favor of the shared composable so the pattern stays consistent.
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 { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
import { usePostsStore } from './posts.js'
|
import { usePostsStore } from './posts.js'
|
||||||
|
|
||||||
const PAGE = 60
|
const PAGE = 60
|
||||||
@@ -15,12 +16,17 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
let started = 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) {
|
async function load (slug) {
|
||||||
// Cross-artist reset: clear this store AND the posts store so the new
|
// Cross-artist reset: clear this store AND the posts store so the new
|
||||||
// artist doesn't briefly render with the previous artist's content
|
// artist doesn't briefly render with the previous artist's content
|
||||||
// when the user is on the Posts tab. (Gallery tab uses this artist
|
// when the user is on the Posts tab. (Gallery tab uses this artist
|
||||||
// store's own images list — cleared above.)
|
// store's own images list — cleared above.)
|
||||||
|
inflight.cancel()
|
||||||
overview.value = null
|
overview.value = null
|
||||||
images.value = []
|
images.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
@@ -29,14 +35,18 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
usePostsStore().$reset?.()
|
usePostsStore().$reset?.()
|
||||||
|
const t = inflight.claim()
|
||||||
try {
|
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)
|
await loadMoreImages(slug)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!t.isCurrent()) return
|
||||||
if (e.status === 404) notFound.value = true
|
if (e.status === 404) notFound.value = true
|
||||||
else error.value = e.message
|
else error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (t.isCurrent()) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,19 +54,22 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
if (imagesLoading.value) return
|
if (imagesLoading.value) return
|
||||||
if (started && nextCursor.value === null) return
|
if (started && nextCursor.value === null) return
|
||||||
imagesLoading.value = true
|
imagesLoading.value = true
|
||||||
|
const t = inflight.claim()
|
||||||
try {
|
try {
|
||||||
const params = { limit: PAGE }
|
const params = { limit: PAGE }
|
||||||
if (nextCursor.value) params.cursor = nextCursor.value
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
const body = await api.get(
|
const body = await api.get(
|
||||||
`/api/artist/${encodeURIComponent(slug)}/images`, { params }
|
`/api/artist/${encodeURIComponent(slug)}/images`, { params }
|
||||||
)
|
)
|
||||||
|
if (!t.isCurrent()) return
|
||||||
images.value.push(...body.images)
|
images.value.push(...body.images)
|
||||||
nextCursor.value = body.next_cursor
|
nextCursor.value = body.next_cursor
|
||||||
started = true
|
started = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!t.isCurrent()) return
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
imagesLoading.value = false
|
if (t.isCurrent()) imagesLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
const PAGE = 60
|
const PAGE = 60
|
||||||
|
|
||||||
@@ -13,16 +14,23 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
|||||||
const q = ref('')
|
const q = ref('')
|
||||||
const platform = ref(null)
|
const platform = ref(null)
|
||||||
let started = false
|
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() {
|
async function loadMore() {
|
||||||
if (loading.value) return
|
|
||||||
if (started && nextCursor.value === null) return
|
if (started && nextCursor.value === null) return
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const params = { limit: PAGE }
|
const params = { limit: PAGE }
|
||||||
if (q.value) params.q = q.value
|
if (q.value) params.q = q.value
|
||||||
if (platform.value) params.platform = platform.value
|
if (platform.value) params.platform = platform.value
|
||||||
if (nextCursor.value) params.cursor = nextCursor.value
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
const body = await api.get('/api/artists/directory', { params })
|
const body = await api.get('/api/artists/directory', { params })
|
||||||
|
if (!t.isCurrent()) return
|
||||||
cards.value.push(...body.cards)
|
cards.value.push(...body.cards)
|
||||||
nextCursor.value = body.next_cursor
|
nextCursor.value = body.next_cursor
|
||||||
started = true
|
started = true
|
||||||
@@ -30,6 +38,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
|
inflight.cancel()
|
||||||
cards.value = []
|
cards.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
started = false
|
started = false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
export const useDownloadsStore = defineStore('downloads', () => {
|
export const useDownloadsStore = defineStore('downloads', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -22,6 +23,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
// the "active now" panel always reflects what's happening regardless of
|
// the "active now" panel always reflects what's happening regardless of
|
||||||
// how the operator has filtered the historical list below.
|
// how the operator has filtered the historical list below.
|
||||||
const activeEvents = ref([])
|
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 = {}) {
|
function _params(extra = {}) {
|
||||||
const out = { limit: 50, ...extra }
|
const out = { limit: 50, ...extra }
|
||||||
@@ -32,8 +37,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFirst() {
|
async function loadFirst() {
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const body = await api.get('/api/downloads', { params: _params() })
|
const body = await api.get('/api/downloads', { params: _params() })
|
||||||
|
if (!t.isCurrent()) return
|
||||||
events.value = body
|
events.value = body
|
||||||
cursor.value = body.length ? body[body.length - 1].id : null
|
cursor.value = body.length ? body[body.length - 1].id : null
|
||||||
hasMore.value = body.length === 50
|
hasMore.value = body.length === 50
|
||||||
@@ -42,8 +49,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (!hasMore.value || cursor.value == null) return
|
if (!hasMore.value || cursor.value == null) return
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
|
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
|
||||||
|
if (!t.isCurrent()) return
|
||||||
events.value.push(...body)
|
events.value.push(...body)
|
||||||
cursor.value = body.length ? body[body.length - 1].id : cursor.value
|
cursor.value = body.length ? body[body.length - 1].id : cursor.value
|
||||||
hasMore.value = body.length === 50
|
hasMore.value = body.length === 50
|
||||||
@@ -71,6 +80,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyFilter(patch) {
|
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 }
|
filter.value = { ...filter.value, ...patch }
|
||||||
await loadFirst()
|
await loadFirst()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
||||||
// 50-item request so items render as each batch lands. Total initial
|
// 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 timelineBuckets = ref([])
|
||||||
const timelineLoading = ref(false)
|
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() {
|
async function loadInitial() {
|
||||||
|
inflight.cancel()
|
||||||
images.value = []
|
images.value = []
|
||||||
dateGroups.value = []
|
dateGroups.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
@@ -41,19 +45,19 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
const myId = ++inflightId
|
const t = inflight.claim()
|
||||||
try {
|
try {
|
||||||
const params = { limit: PAGE, ...activeFilterParam() }
|
const params = { limit: PAGE, ...activeFilterParam() }
|
||||||
if (nextCursor.value) params.cursor = nextCursor.value
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
const body = await api.get('/api/gallery/scroll', { params })
|
const body = await api.get('/api/gallery/scroll', { params })
|
||||||
if (myId !== inflightId) return // stale response
|
if (!t.isCurrent()) return
|
||||||
images.value.push(...body.images)
|
images.value.push(...body.images)
|
||||||
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
|
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
|
||||||
nextCursor.value = body.next_cursor
|
nextCursor.value = body.next_cursor
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} 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) {
|
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 params = { year, month, ...activeFilterParam() }
|
||||||
const body = await api.get('/api/gallery/jump', { params })
|
const body = await api.get('/api/gallery/jump', { params })
|
||||||
|
if (!t.isCurrent()) return
|
||||||
if (body.cursor) {
|
if (body.cursor) {
|
||||||
images.value = []
|
images.value = []
|
||||||
dateGroups.value = []
|
dateGroups.value = []
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
export const useModalStore = defineStore('modal', () => {
|
export const useModalStore = defineStore('modal', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -10,6 +11,11 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
const currentImageId = ref(null)
|
const currentImageId = ref(null)
|
||||||
const current = ref(null)
|
const current = ref(null)
|
||||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
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
|
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||||
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
||||||
@@ -19,6 +25,9 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
const postImageIndex = ref(0)
|
const postImageIndex = ref(0)
|
||||||
|
|
||||||
async function open (id, opts = {}) {
|
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
|
currentImageId.value = id
|
||||||
current.value = null // cleared upfront so it stays null on error
|
current.value = null // cleared upfront so it stays null on error
|
||||||
// Update post-scoped state if caller passed it; otherwise clear so
|
// Update post-scoped state if caller passed it; otherwise clear so
|
||||||
@@ -31,12 +40,16 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
postImageIds.value = null
|
postImageIds.value = null
|
||||||
postImageIndex.value = 0
|
postImageIndex.value = 0
|
||||||
}
|
}
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
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 () {
|
async function close () {
|
||||||
|
inflight.cancel()
|
||||||
currentImageId.value = null
|
currentImageId.value = null
|
||||||
current.value = null
|
current.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -75,37 +88,74 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reloadTags () {
|
async function reloadTags () {
|
||||||
if (!currentImageId.value) return
|
// Capture image id at call-time. After an await, currentImageId
|
||||||
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
|
// may have advanced via prev/next navigation; without capture, the
|
||||||
if (current.value) current.value.tags = tags
|
// 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) {
|
async function removeTag (tagId) {
|
||||||
if (!currentImageId.value) return
|
const imageId = currentImageId.value
|
||||||
|
if (!imageId) return
|
||||||
const prev = current.value.tags
|
const prev = current.value.tags
|
||||||
|
// Optimistic UI: drop the chip immediately.
|
||||||
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
|
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 {
|
try {
|
||||||
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
await api.delete(`/api/images/${imageId}/tags/${tagId}`)
|
||||||
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
|
} 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 },
|
body: { tag_id: tagId },
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
current.value.tags = prev
|
toast({
|
||||||
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
|
text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
|
||||||
throw e
|
type: 'warning',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addExistingTag (tagId) {
|
async function addExistingTag (tagId) {
|
||||||
if (!currentImageId.value) return
|
const imageId = currentImageId.value
|
||||||
await api.post(`/api/images/${currentImageId.value}/tags`, {
|
if (!imageId) return
|
||||||
|
await api.post(`/api/images/${imageId}/tags`, {
|
||||||
body: { tag_id: tagId, source: 'manual' },
|
body: { tag_id: tagId, source: 'manual' },
|
||||||
})
|
})
|
||||||
await reloadTags()
|
await reloadTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndAdd ({ name, kind, fandom_id = null }) {
|
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 } })
|
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
||||||
|
if (currentImageId.value !== imageId) return // navigated away
|
||||||
await addExistingTag(tag.id)
|
await addExistingTag(tag.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
export const usePostsStore = defineStore('posts', () => {
|
export const usePostsStore = defineStore('posts', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -19,6 +20,12 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
const doneOlder = ref(false)
|
const doneOlder = ref(false)
|
||||||
const doneNewer = ref(false)
|
const doneNewer = ref(false)
|
||||||
const anchorId = ref(null)
|
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() {
|
function _qs() {
|
||||||
const q = {}
|
const q = {}
|
||||||
@@ -36,6 +43,7 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitial(newFilters) {
|
async function loadInitial(newFilters) {
|
||||||
|
inflight.cancel()
|
||||||
filters.value = {
|
filters.value = {
|
||||||
artist_id: newFilters?.artist_id ?? null,
|
artist_id: newFilters?.artist_id ?? null,
|
||||||
platform: newFilters?.platform ?? null,
|
platform: newFilters?.platform ?? null,
|
||||||
@@ -46,8 +54,10 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading.value || done.value) return
|
if (loading.value || done.value) return
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const body = await api.get('/api/posts', { params: _qs() })
|
const body = await api.get('/api/posts', { params: _qs() })
|
||||||
|
if (!t.isCurrent()) return
|
||||||
items.value.push(...body.items)
|
items.value.push(...body.items)
|
||||||
cursor.value = body.next_cursor
|
cursor.value = body.next_cursor
|
||||||
if (body.next_cursor == null) done.value = true
|
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
|
// posts feed; without this the older/newer scroll loaded unfiltered
|
||||||
// global posts instead of staying in the artist's stream).
|
// global posts instead of staying in the artist's stream).
|
||||||
async function loadAround(postId, newFilters) {
|
async function loadAround(postId, newFilters) {
|
||||||
|
inflight.cancel()
|
||||||
filters.value = {
|
filters.value = {
|
||||||
artist_id: newFilters?.artist_id ?? null,
|
artist_id: newFilters?.artist_id ?? null,
|
||||||
platform: newFilters?.platform ?? null,
|
platform: newFilters?.platform ?? null,
|
||||||
@@ -86,10 +97,12 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
anchorId.value = null
|
anchorId.value = null
|
||||||
|
const t = inflight.claim()
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
const body = await api.get('/api/posts', {
|
||||||
params: _aroundParams({ around: postId }),
|
params: _aroundParams({ around: postId }),
|
||||||
})
|
})
|
||||||
|
if (!t.isCurrent()) return
|
||||||
items.value = body.items
|
items.value = body.items
|
||||||
cursorOlder.value = body.cursor_older
|
cursorOlder.value = body.cursor_older
|
||||||
cursorNewer.value = body.cursor_newer
|
cursorNewer.value = body.cursor_newer
|
||||||
@@ -97,47 +110,54 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
doneNewer.value = body.cursor_newer == null
|
doneNewer.value = body.cursor_newer == null
|
||||||
anchorId.value = body.anchor_id
|
anchorId.value = body.anchor_id
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!t.isCurrent()) return
|
||||||
error.value = e
|
error.value = e
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (t.isCurrent()) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOlder() {
|
async function loadOlder() {
|
||||||
if (loading.value || doneOlder.value || cursorOlder.value == null) return
|
if (loading.value || doneOlder.value || cursorOlder.value == null) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const t = inflight.claim()
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
const body = await api.get('/api/posts', {
|
||||||
params: _aroundParams({
|
params: _aroundParams({
|
||||||
cursor: cursorOlder.value, direction: 'older',
|
cursor: cursorOlder.value, direction: 'older',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
if (!t.isCurrent()) return
|
||||||
items.value.push(...body.items)
|
items.value.push(...body.items)
|
||||||
cursorOlder.value = body.next_cursor
|
cursorOlder.value = body.next_cursor
|
||||||
if (body.next_cursor == null) doneOlder.value = true
|
if (body.next_cursor == null) doneOlder.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!t.isCurrent()) return
|
||||||
error.value = e
|
error.value = e
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (t.isCurrent()) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNewer() {
|
async function loadNewer() {
|
||||||
if (loading.value || doneNewer.value || cursorNewer.value == null) return
|
if (loading.value || doneNewer.value || cursorNewer.value == null) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const t = inflight.claim()
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
const body = await api.get('/api/posts', {
|
||||||
params: _aroundParams({
|
params: _aroundParams({
|
||||||
cursor: cursorNewer.value, direction: 'newer',
|
cursor: cursorNewer.value, direction: 'newer',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
if (!t.isCurrent()) return
|
||||||
items.value.unshift(...body.items)
|
items.value.unshift(...body.items)
|
||||||
cursorNewer.value = body.next_cursor
|
cursorNewer.value = body.next_cursor
|
||||||
if (body.next_cursor == null) doneNewer.value = true
|
if (body.next_cursor == null) doneNewer.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!t.isCurrent()) return
|
||||||
error.value = e
|
error.value = e
|
||||||
} finally {
|
} 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 { ref } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
// Category display order: people first, general last.
|
// Category display order: people first, general last.
|
||||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
|
// '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 byCategory = ref({}) // { category: [suggestion, ...] }
|
||||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||||
let currentImageId = null
|
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) {
|
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
|
currentImageId = imageId
|
||||||
byCategory.value = {} // cleared upfront so it stays empty on error
|
byCategory.value = {} // cleared upfront so it stays empty on error
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
||||||
|
if (!t.isCurrent()) return
|
||||||
byCategory.value = body.by_category || {}
|
byCategory.value = body.by_category || {}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -35,6 +49,11 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function accept(suggestion) {
|
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
|
// 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
|
// 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.
|
// first via the existing /api/tags endpoint, then accept by id.
|
||||||
@@ -45,10 +64,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
})
|
})
|
||||||
tagId = created.id
|
tagId = created.id
|
||||||
}
|
}
|
||||||
await api.post(`/api/images/${currentImageId}/suggestions/accept`, {
|
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||||
body: { tag_id: tagId }
|
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({
|
toast({
|
||||||
text: `Tagged: ${suggestion.display_name}`,
|
text: `Tagged: ${suggestion.display_name}`,
|
||||||
type: 'success'
|
type: 'success'
|
||||||
@@ -56,14 +79,18 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function aliasAccept(suggestion, canonicalTagId) {
|
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: {
|
body: {
|
||||||
alias_string: suggestion.display_name,
|
alias_string: suggestion.display_name,
|
||||||
alias_category: suggestion.category,
|
alias_category: suggestion.category,
|
||||||
canonical_tag_id: canonicalTagId
|
canonical_tag_id: canonicalTagId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
if (currentImageId === imageId) {
|
||||||
|
_drop(suggestion.category, s => s === suggestion)
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
text: `Aliased & tagged: ${suggestion.display_name}`,
|
text: `Aliased & tagged: ${suggestion.display_name}`,
|
||||||
type: 'success'
|
type: 'success'
|
||||||
@@ -71,15 +98,19 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function dismiss(suggestion) {
|
async function dismiss(suggestion) {
|
||||||
|
const imageId = currentImageId
|
||||||
|
if (imageId == null) return
|
||||||
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
|
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
|
||||||
// suggestion just hides it client-side (nothing to persist a rejection
|
// suggestion just hides it client-side (nothing to persist a rejection
|
||||||
// against until the tag exists).
|
// against until the tag exists).
|
||||||
if (suggestion.canonical_tag_id != null) {
|
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 }
|
body: { tag_id: suggestion.canonical_tag_id }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
if (currentImageId === imageId) {
|
||||||
|
_drop(suggestion.category, s => s === suggestion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
|
||||||
const PAGE = 60
|
const PAGE = 60
|
||||||
|
|
||||||
@@ -13,16 +14,21 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
|
|||||||
const kind = ref(null)
|
const kind = ref(null)
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
let started = false
|
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() {
|
async function loadMore() {
|
||||||
if (loading.value) return
|
|
||||||
if (started && nextCursor.value === null) return
|
if (started && nextCursor.value === null) return
|
||||||
|
const t = inflight.claim()
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const params = { limit: PAGE }
|
const params = { limit: PAGE }
|
||||||
if (kind.value) params.kind = kind.value
|
if (kind.value) params.kind = kind.value
|
||||||
if (q.value) params.q = q.value
|
if (q.value) params.q = q.value
|
||||||
if (nextCursor.value) params.cursor = nextCursor.value
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
const body = await api.get('/api/tags/directory', { params })
|
const body = await api.get('/api/tags/directory', { params })
|
||||||
|
if (!t.isCurrent()) return
|
||||||
cards.value.push(...body.cards)
|
cards.value.push(...body.cards)
|
||||||
nextCursor.value = body.next_cursor
|
nextCursor.value = body.next_cursor
|
||||||
started = true
|
started = true
|
||||||
@@ -30,6 +36,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
|
inflight.cancel()
|
||||||
cards.value = []
|
cards.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
started = false
|
started = false
|
||||||
|
|||||||
Reference in New Issue
Block a user