2026-10-11 14:31:32 +03:30
|
|
|
// Shared read cache for API responses.
|
|
|
|
|
//
|
|
|
|
|
// Every screen used to fetch on mount, so each tab switch repeated the same
|
|
|
|
|
// requests — the whole catalogue alone was fetched from ten places. Reads now go
|
|
|
|
|
// through `fetch(key, …)`:
|
|
|
|
|
//
|
|
|
|
|
// - cached entry → returned at once, no request
|
|
|
|
|
// - request in flight → the same promise is shared, never a second request
|
|
|
|
|
// - `force` → always asks the server (pull-to-refresh, the
|
|
|
|
|
// profile tab's balance, reaching the end of a list)
|
|
|
|
|
//
|
2026-10-12 12:42:42 +03:30
|
|
|
// Entries never expire on a timer, deliberately (owner ruling 2026-10-12: «هربار
|
|
|
|
|
// با رفتن به اون صفحه اون مقادیر مورد نیاز رو درخواست بزنه … هم سربار برای سرور
|
|
|
|
|
// نداره، هم درخواست الکی نمیخوره، هم دیتاها بهروز میمونه»). A TTL guesses how
|
|
|
|
|
// long data stays true; entering a screen is the moment it actually matters.
|
|
|
|
|
//
|
|
|
|
|
// So the policy is:
|
|
|
|
|
//
|
|
|
|
|
// - a screen that shows server-owned data re-reads it with `force` when it is
|
|
|
|
|
// opened (`onMounted`, plus `onActivated` for the kept-alive views), while
|
|
|
|
|
// `peek` paints the previous copy instantly so the refresh is invisible;
|
|
|
|
|
// - the cached copy exists for that first paint and to collapse concurrent
|
|
|
|
|
// callers into one request, NOT to avoid asking again;
|
|
|
|
|
// - the catalogue is the exception: it is the whole product list in one
|
|
|
|
|
// response, changes when an admin edits it, and is already invalidated by
|
|
|
|
|
// pull-to-refresh and by placing an order.
|
|
|
|
|
//
|
|
|
|
|
// `force` is what makes this work for anything changed OUTSIDE the app — an
|
|
|
|
|
// order's status, a ledger entry, the gold price. Nothing here can know those
|
|
|
|
|
// changed, so not asking means never seeing them.
|
2026-10-11 14:31:32 +03:30
|
|
|
//
|
|
|
|
|
// Entries live in a plain Map, not reactive state: the catalogue is large and
|
|
|
|
|
// making it deeply reactive would cost more than the request it saves.
|
|
|
|
|
//
|
|
|
|
|
// Keys under `me:` hold one customer's data. They are dropped whenever the
|
2026-10-12 11:48:59 +03:30
|
|
|
// session token changes — or the act-as target does, which changes whose data
|
|
|
|
|
// they hold without changing the token — so one user never sees another's.
|
2026-10-11 14:31:32 +03:30
|
|
|
import { defineStore } from 'pinia'
|
|
|
|
|
import { watch } from 'vue'
|
|
|
|
|
import { useSessionStore } from './session'
|
|
|
|
|
|
|
|
|
|
export const KEYS = {
|
|
|
|
|
catalog: 'catalog',
|
|
|
|
|
goldPrice: 'goldPrice',
|
|
|
|
|
homeSlider: 'homeSlider',
|
|
|
|
|
profile: 'me:profile',
|
|
|
|
|
orders: 'me:orders',
|
|
|
|
|
balance: 'me:balance',
|
|
|
|
|
ledger: 'me:ledger',
|
|
|
|
|
} as const
|
|
|
|
|
|
|
|
|
|
const USER_PREFIX = 'me:'
|
|
|
|
|
|
|
|
|
|
interface Entry {
|
|
|
|
|
data?: unknown
|
|
|
|
|
/** False until the first successful response. */
|
|
|
|
|
has: boolean
|
|
|
|
|
inflight?: Promise<unknown>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FetchOptions {
|
|
|
|
|
/** Skip the cached value. An identical request already in flight is still shared. */
|
|
|
|
|
force?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useQueryCache = defineStore('queryCache', () => {
|
|
|
|
|
const entries = new Map<string, Entry>()
|
|
|
|
|
const session = useSessionStore()
|
|
|
|
|
|
|
|
|
|
function request<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
|
|
|
const entry = entries.get(key)
|
|
|
|
|
if (entry?.inflight) return entry.inflight as Promise<T>
|
|
|
|
|
|
|
|
|
|
const p: Promise<T> = fetcher()
|
|
|
|
|
.then((data) => {
|
|
|
|
|
// Invalidated (or the user changed) while this was in flight: the
|
|
|
|
|
// response may predate the change, so it is handed back but not kept.
|
|
|
|
|
if (entries.get(key)?.inflight === p) entries.set(key, { data, has: true })
|
|
|
|
|
return data
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
const current = entries.get(key)
|
|
|
|
|
if (current?.inflight === p) current.inflight = undefined
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
entries.set(key, { ...(entry ?? { has: false }), inflight: p })
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetch<T>(key: string, fetcher: () => Promise<T>, opts: FetchOptions = {}): Promise<T> {
|
|
|
|
|
const entry = entries.get(key)
|
|
|
|
|
if (!opts.force && entry?.has) return entry.data as T
|
|
|
|
|
return request(key, fetcher)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The cached value, without fetching. */
|
|
|
|
|
function peek<T>(key: string): T | undefined {
|
|
|
|
|
const entry = entries.get(key)
|
|
|
|
|
return entry?.has ? (entry.data as T) : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invalidate(...keys: string[]) {
|
|
|
|
|
for (const key of keys) entries.delete(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearUserScoped() {
|
|
|
|
|
for (const key of [...entries.keys()]) {
|
|
|
|
|
if (key.startsWith(USER_PREFIX)) entries.delete(key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
// The token changes when the user changes — and, with «ورود به حساب مشتری»,
|
|
|
|
|
// WHOSE data `me:` keys hold changes without the token changing at all. Both
|
|
|
|
|
// have to drop the cache, or a sales expert entering a customer's account
|
|
|
|
|
// would be shown their own profile, orders, balance and ledger from before
|
|
|
|
|
// the switch.
|
2026-10-11 14:31:32 +03:30
|
|
|
watch(
|
2026-10-12 11:48:59 +03:30
|
|
|
() => [session.token, session.actingAs?.id ?? null],
|
2026-10-11 14:31:32 +03:30
|
|
|
() => clearUserScoped(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return { fetch, peek, invalidate, clearUserScoped }
|
|
|
|
|
})
|