import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi' import { readErrorMessage } from '../utils/errors' import type { ExperimentSummary, PlatformContext } from '../types' function getPlatformApiUrl(): string | undefined { if (typeof window === 'undefined') return undefined return (window as unknown as { __MINT_PLATFORM__?: PlatformContext }).__MINT_PLATFORM__ ?.platformApiUrl } /** * The single source of truth for the experiment a plugin is currently working on. * * Both entry points land here: `ExperimentSelectorModal` calls `select()` with a * record the platform already returned, and deep links call `selectById()` with * nothing but an id. Because a name can only ever arrive from the platform, * there is no code path in which a caller can invent one. */ export const useExperimentStore = defineStore('mint-experiment', () => { const current = ref(null) const currentId = ref(null) const isResolving = ref(false) const error = ref(null) // In-flight requests only. Resolved records are deliberately not memoised, so // a rename is picked up on the next resolve instead of persisting for the // lifetime of the page. const inFlight = new Map>() // Monotonic token guarding every write to the shared selection. Responses can // land in any order, and a late one must never overwrite a newer choice — // that would display a name belonging to a different experiment, which is the // failure this store exists to prevent. let selectionToken = 0 function fetchExperiment(id: number): Promise { const pending = inFlight.get(id) if (pending) return pending const base = getPlatformApiUrl() ?? '' const request = useApi() .get(`${base}/experiments/${id}`) .finally(() => { inFlight.delete(id) }) inFlight.set(id, request) return request } /** Adopt a record the platform already returned, without a further request. */ function select(experiment: ExperimentSummary): void { selectionToken += 1 current.value = experiment currentId.value = experiment.id error.value = null isResolving.value = false } /** * Bind to an experiment known only by id, resolving its record from the * platform. On failure the binding is remembered but `current` stays null, so * callers can distinguish "not resolved yet" from "nothing selected". */ async function selectById(id: number): Promise { const joinsCurrentResolution = isResolving.value && currentId.value === id const token = joinsCurrentResolution ? selectionToken : (selectionToken += 1) if (!joinsCurrentResolution) { current.value = null currentId.value = id error.value = null isResolving.value = true } try { const experiment = await fetchExperiment(id) // A newer selection superseded this one while the request was in flight. // Returning null tells the caller its result is no longer the selection, // so it cannot be applied downstream either. if (token !== selectionToken) return null current.value = experiment return experiment } catch (err) { if (token !== selectionToken) return null current.value = null error.value = readErrorMessage(err, 'Failed to load experiment') return null } finally { // Only the newest request owns the loading flag; otherwise a superseded // response would clear it while the current one is still pending. if (token === selectionToken) isResolving.value = false } } function clear(): void { // Supersede any in-flight resolve so a detached experiment cannot be // repopulated by a response that was already on its way. selectionToken += 1 current.value = null currentId.value = null error.value = null isResolving.value = false } return { current, currentId, isResolving, error, select, selectById, clear, } })