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 { 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