feat(ui): shared stale-while-revalidate layer — useCachedResource + resources store + rpc-client abort/dedup/retry controls
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m28s
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m28s
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.
- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
with sticky-ready (never regress ready→loading), keep-last-value on
error, per-key in-flight dedup, sessionStorage snapshot hydrate,
debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
hydrate, TTL-gated background revalidate, revalidate-on-focus,
abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e24e0a6473
commit
67454974b2
@ -4,6 +4,16 @@ export interface RPCOptions {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
timeout?: number
|
||||
/** Abort the call (and any pending retries) from the outside — pass a
|
||||
* component-scoped controller's signal so fan-outs stop on unmount. */
|
||||
signal?: AbortSignal
|
||||
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
|
||||
* own timeout/fallback UX — retry×3 on a slow peer is how one unreachable
|
||||
* node turns a 30s timeout into a 90s spinner. */
|
||||
maxRetries?: number
|
||||
/** Collapse concurrent identical calls (same method + params) into one
|
||||
* request. Opt-in: only safe for reads. */
|
||||
dedup?: boolean
|
||||
}
|
||||
|
||||
export interface RPCResponse<T> {
|
||||
@ -74,18 +84,35 @@ function getCsrfToken(): string | null {
|
||||
class RPCClient {
|
||||
private static _sessionExpiredRedirecting = false
|
||||
private baseUrl: string
|
||||
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
|
||||
private inflight = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(baseUrl: string = '/rpc/v1') {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 15000 } = options
|
||||
const maxRetries = 3
|
||||
if (options.dedup) {
|
||||
const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
|
||||
const existing = this.inflight.get(key)
|
||||
if (existing) return existing as Promise<T>
|
||||
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
|
||||
this.inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
return this.callInner<T>(options)
|
||||
}
|
||||
|
||||
private async callInner<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 15000, signal: external } = options
|
||||
const maxRetries = Math.max(1, options.maxRetries ?? 3)
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
const onExternalAbort = () => controller.abort()
|
||||
external?.addEventListener('abort', onExternalAbort, { once: true })
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@ -105,6 +132,7 @@ class RPCClient {
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
external?.removeEventListener('abort', onExternalAbort)
|
||||
|
||||
if (!response.ok) {
|
||||
// Session expired — debounced redirect to login
|
||||
@ -167,8 +195,11 @@ class RPCClient {
|
||||
return data.result as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
external?.removeEventListener('abort', onExternalAbort)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// Caller-initiated abort is final — never retried.
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
const timeoutErr = new Error('Request timeout')
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = 600 * (attempt + 1)
|
||||
|
||||
107
neode-ui/src/composables/useCachedResource.ts
Normal file
107
neode-ui/src/composables/useCachedResource.ts
Normal file
@ -0,0 +1,107 @@
|
||||
// Stale-while-revalidate resource hook over the shared resources store.
|
||||
//
|
||||
// Usage:
|
||||
// const files = useCachedResource<CloudFile[]>({
|
||||
// key: 'cloud.my-files',
|
||||
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
|
||||
// ttlMs: 30_000,
|
||||
// })
|
||||
// // template: files.data renders instantly on revisit (cache), while
|
||||
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
|
||||
//
|
||||
// Behavior:
|
||||
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
|
||||
// snapshot (survives reload) → fetch.
|
||||
// - Sticky-ready: never regresses ready → loading; refreshes are
|
||||
// 'refreshing' so content stays on screen.
|
||||
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
|
||||
// background refresh runs only if the TTL has lapsed (or never fetched).
|
||||
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
|
||||
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
|
||||
// stale (debounced by TTL, so focus-flapping is free).
|
||||
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
|
||||
// the last subscribed component unmounts.
|
||||
|
||||
import { computed, getCurrentScope, onScopeDispose } from 'vue'
|
||||
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
||||
|
||||
export interface CachedResourceOptions<T> {
|
||||
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
|
||||
key: string
|
||||
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
|
||||
fetcher: (signal: AbortSignal) => Promise<T>
|
||||
/** Data older than this triggers a background revalidate (default 30s). */
|
||||
ttlMs?: number
|
||||
/** Snapshot to sessionStorage so reloads paint instantly (default true).
|
||||
* Disable for large payloads. */
|
||||
persist?: boolean
|
||||
/** Revalidate (if stale) when the window regains focus (default true). */
|
||||
revalidateOnFocus?: boolean
|
||||
/** Fetch on first use (default true). Set false for lazy resources. */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export interface CachedResource<T> {
|
||||
entry: ResourceEntry<T>
|
||||
/** Convenience computed views over the entry. */
|
||||
data: ReturnType<typeof computed<T | null>>
|
||||
loadState: ReturnType<typeof computed<ResourceLoadState>>
|
||||
error: ReturnType<typeof computed<string | null>>
|
||||
/** True when data exists but is older than the TTL (drive an age badge). */
|
||||
isStale: ReturnType<typeof computed<boolean>>
|
||||
ageMs: ReturnType<typeof computed<number | null>>
|
||||
/** Force a refresh now (deduped with any in-flight one). */
|
||||
refresh: () => Promise<void>
|
||||
/** Mark stale + debounce-refresh all mounted users of this key. */
|
||||
invalidate: () => void
|
||||
/** Optimistically update cached data; returns rollback for RPC failure. */
|
||||
optimistic: (update: (current: T | null) => T) => () => void
|
||||
}
|
||||
|
||||
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
|
||||
const store = useResourcesStore()
|
||||
const ttlMs = opts.ttlMs ?? 30_000
|
||||
const persist = opts.persist ?? true
|
||||
const entry = store.entry<T>(opts.key, persist)
|
||||
|
||||
const aborter = new AbortController()
|
||||
const fetcher = () => opts.fetcher(aborter.signal)
|
||||
const refresh = () => store.refresh(opts.key, fetcher, { persist })
|
||||
|
||||
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
|
||||
const refreshIfStale = () => {
|
||||
if (stale()) void refresh()
|
||||
}
|
||||
|
||||
// Register as a live revalidator so invalidate(key) reaches us.
|
||||
const unsubscribe = store.subscribe(opts.key, () => void refresh())
|
||||
|
||||
const onFocus = () => refreshIfStale()
|
||||
if (opts.revalidateOnFocus ?? true) {
|
||||
window.addEventListener('focus', onFocus)
|
||||
}
|
||||
|
||||
// Tied to the owning effect scope (component setup or manual scope);
|
||||
// outside any scope (tests, module init) there's nothing to dispose.
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
unsubscribe()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
aborter.abort()
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.immediate ?? true) refreshIfStale()
|
||||
|
||||
return {
|
||||
entry,
|
||||
data: computed(() => entry.data),
|
||||
loadState: computed(() => entry.loadState),
|
||||
error: computed(() => entry.error),
|
||||
isStale: computed(() => entry.data !== null && stale()),
|
||||
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
|
||||
refresh,
|
||||
invalidate: () => store.invalidate(opts.key),
|
||||
optimistic: (update) => store.optimistic<T>(opts.key, update),
|
||||
}
|
||||
}
|
||||
131
neode-ui/src/stores/__tests__/resources.test.ts
Normal file
131
neode-ui/src/stores/__tests__/resources.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useResourcesStore } from '../resources'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
|
||||
describe('resources store — stale-while-revalidate semantics', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('first fetch goes idle → loading → ready with data', async () => {
|
||||
const store = useResourcesStore()
|
||||
const e = store.entry<string>('k1')
|
||||
expect(e.loadState).toBe('idle')
|
||||
const p = store.refresh('k1', async () => 'hello')
|
||||
expect(e.loadState).toBe('loading')
|
||||
await p
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toBe('hello')
|
||||
expect(e.fetchedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('sticky-ready: refresh never regresses ready → loading', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k2', async () => 1)
|
||||
const e = store.entry<number>('k2')
|
||||
const p = store.refresh('k2', async () => 2)
|
||||
expect(e.loadState).toBe('refreshing')
|
||||
await p
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps last-known data on refresh error (ready + error set)', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k3', async () => 'good')
|
||||
const e = store.entry<string>('k3')
|
||||
await store.refresh('k3', async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
expect(e.data).toBe('good')
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.error).toBe('boom')
|
||||
})
|
||||
|
||||
it('errors with no prior data land in error state', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k4', async () => {
|
||||
throw new Error('down')
|
||||
})
|
||||
const e = store.entry('k4')
|
||||
expect(e.loadState).toBe('error')
|
||||
expect(e.data).toBeNull()
|
||||
})
|
||||
|
||||
it('dedups concurrent refreshes for the same key', async () => {
|
||||
const store = useResourcesStore()
|
||||
const fetcher = vi.fn(async () => 'once')
|
||||
const p1 = store.refresh('k5', fetcher)
|
||||
const p2 = store.refresh('k5', fetcher)
|
||||
await Promise.all([p1, p2])
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates a new entry from the sessionStorage snapshot', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k6', async () => ({ n: 42 }))
|
||||
// Fresh pinia = fresh memory cache, same sessionStorage.
|
||||
setActivePinia(createPinia())
|
||||
const store2 = useResourcesStore()
|
||||
const e = store2.entry<{ n: number }>('k6')
|
||||
expect(e.loadState).toBe('ready')
|
||||
expect(e.data).toEqual({ n: 42 })
|
||||
})
|
||||
|
||||
it('optimistic update applies immediately and rollback restores', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k7', async () => ['a'])
|
||||
const e = store.entry<string[]>('k7')
|
||||
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
|
||||
expect(e.data).toEqual(['a', 'b'])
|
||||
rollback()
|
||||
expect(e.data).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('invalidate marks stale and debounce-runs subscribers', async () => {
|
||||
const store = useResourcesStore()
|
||||
await store.refresh('k8', async () => 1)
|
||||
const revalidate = vi.fn()
|
||||
store.subscribe('k8', revalidate)
|
||||
store.invalidate('k8')
|
||||
expect(store.entry('k8').fetchedAt).toBeNull()
|
||||
expect(revalidate).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(900)
|
||||
expect(revalidate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCachedResource composable', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('fetches immediately when stale and exposes reactive views', async () => {
|
||||
const fetcher = vi.fn(async () => 'data')
|
||||
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
|
||||
await r.refresh()
|
||||
expect(fetcher).toHaveBeenCalled()
|
||||
expect(r.data.value).toBe('data')
|
||||
expect(r.loadState.value).toBe('ready')
|
||||
expect(r.isStale.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch within TTL (instant render from cache)', async () => {
|
||||
const fetcher = vi.fn(async () => 'v1')
|
||||
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
|
||||
await r1.refresh()
|
||||
// Second component using the same key inside the TTL: no new fetch.
|
||||
const fetcher2 = vi.fn(async () => 'v2')
|
||||
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
|
||||
expect(r2.data.value).toBe('v1')
|
||||
expect(fetcher2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
166
neode-ui/src/stores/resources.ts
Normal file
166
neode-ui/src/stores/resources.ts
Normal file
@ -0,0 +1,166 @@
|
||||
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
|
||||
//
|
||||
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
|
||||
// keys its router-view by route.path, so each visit unmounted and refetched
|
||||
// everything. This store is the single place resource state lives instead:
|
||||
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
|
||||
// snapshot), and `useCachedResource` renders them instantly while
|
||||
// revalidating in the background.
|
||||
//
|
||||
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
|
||||
// hand-rolled versions):
|
||||
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
|
||||
// refreshes show as 'refreshing' so the UI keeps the data visible.
|
||||
// - keep-last-known-value on error: a failed revalidate leaves data in place
|
||||
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
|
||||
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
|
||||
|
||||
export interface ResourceEntry<T = unknown> {
|
||||
data: T | null
|
||||
loadState: ResourceLoadState
|
||||
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
|
||||
fetchedAt: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const SNAPSHOT_PREFIX = 'resource:'
|
||||
|
||||
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
|
||||
} catch {
|
||||
/* corrupt/absent snapshot — fall through to a fresh fetch */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
|
||||
} catch {
|
||||
/* quota exceeded or unserializable — memory cache still works */
|
||||
}
|
||||
}
|
||||
|
||||
export const useResourcesStore = defineStore('resources', () => {
|
||||
const entries = reactive(new Map<string, ResourceEntry>())
|
||||
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const revalidators = new Map<string, Set<() => void>>()
|
||||
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** Get (or create) the reactive entry for a key, hydrating from the
|
||||
* sessionStorage snapshot on first sight so revisits after a reload paint
|
||||
* before any RPC completes. Pass `persist: false` to skip snapshots. */
|
||||
function entry<T>(key: string, persist = true): ResourceEntry<T> {
|
||||
let e = entries.get(key)
|
||||
if (!e) {
|
||||
const snap = persist ? readSnapshot<T>(key) : null
|
||||
e = reactive<ResourceEntry>({
|
||||
data: snap ? snap.data : null,
|
||||
loadState: snap ? 'ready' : 'idle',
|
||||
fetchedAt: snap ? snap.fetchedAt : null,
|
||||
error: null,
|
||||
})
|
||||
entries.set(key, e)
|
||||
}
|
||||
return e as ResourceEntry<T>
|
||||
}
|
||||
|
||||
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
|
||||
* Concurrent calls for the same key share one in-flight fetch. */
|
||||
function refresh<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
opts: { persist?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const existing = inflight.get(key)
|
||||
if (existing) return existing
|
||||
const e = entry<T>(key, opts.persist ?? true)
|
||||
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
|
||||
const p = (async () => {
|
||||
try {
|
||||
const data = await fetcher()
|
||||
e.data = data
|
||||
e.error = null
|
||||
e.fetchedAt = Date.now()
|
||||
e.loadState = 'ready'
|
||||
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
|
||||
} catch (err) {
|
||||
e.error = err instanceof Error ? err.message : String(err)
|
||||
// Keep last-known data visible; only 'error' when we have nothing.
|
||||
e.loadState = e.data !== null ? 'ready' : 'error'
|
||||
} finally {
|
||||
inflight.delete(key)
|
||||
}
|
||||
})()
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Mark a key stale and (debounced) re-run every mounted subscriber's
|
||||
* fetcher. Call after a mutation or on a relevant WS push. */
|
||||
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
|
||||
const e = entries.get(key)
|
||||
if (e) e.fetchedAt = null
|
||||
const subs = revalidators.get(key)
|
||||
if (!subs || subs.size === 0) return
|
||||
const t = invalidateTimers.get(key)
|
||||
if (t) clearTimeout(t)
|
||||
invalidateTimers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
invalidateTimers.delete(key)
|
||||
for (const fn of subs) fn()
|
||||
}, opts.debounceMs ?? 800),
|
||||
)
|
||||
}
|
||||
|
||||
/** Register a live revalidator for a key (used by useCachedResource);
|
||||
* returns an unsubscribe fn. */
|
||||
function subscribe(key: string, revalidate: () => void): () => void {
|
||||
let subs = revalidators.get(key)
|
||||
if (!subs) {
|
||||
subs = new Set()
|
||||
revalidators.set(key, subs)
|
||||
}
|
||||
subs.add(revalidate)
|
||||
return () => {
|
||||
subs.delete(revalidate)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistically apply `update` to the cached value; returns a rollback.
|
||||
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
|
||||
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
|
||||
const e = entry<T>(key)
|
||||
const before = e.data
|
||||
const beforeState = e.loadState
|
||||
e.data = update(before)
|
||||
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
|
||||
return () => {
|
||||
e.data = before
|
||||
e.loadState = beforeState
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a key entirely (memory + snapshot). */
|
||||
function evict(key: string): void {
|
||||
entries.delete(key)
|
||||
try {
|
||||
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user