feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-11 14:31:32 +03:30
co-authored by Cursor
parent 561addc676
commit 574fa1b4aa
53 changed files with 1241 additions and 401 deletions
+100
View File
@@ -0,0 +1,100 @@
// 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)
//
// Entries never expire on a timer: data changes when the user asks for it or
// when a write here makes it wrong, and those writes call `invalidate`.
//
// 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
// session token changes, so the next user never sees the previous one's data.
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)
}
}
watch(
() => session.token,
() => clearUserScoped(),
)
return { fetch, peek, invalidate, clearUserScoped }
})