mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 17:07:46 +08:00
303 lines
9.9 KiB
TypeScript
303 lines
9.9 KiB
TypeScript
import type {
|
|
UserBatchActionResponse,
|
|
UserBatchBalanceActionRequest,
|
|
} from '@/api/users'
|
|
|
|
export type UserBatchWalletAdjustmentRequest = Omit<
|
|
UserBatchBalanceActionRequest,
|
|
'idempotency_key'
|
|
>
|
|
|
|
export interface PendingUserBatchWalletRequest {
|
|
idempotency_key: string
|
|
request: UserBatchBalanceActionRequest
|
|
}
|
|
|
|
interface StringStorage {
|
|
getItem(key: string): string | null
|
|
setItem(key: string, value: string): void
|
|
removeItem(key: string): void
|
|
}
|
|
|
|
interface CoordinatorOptions {
|
|
storage?: StringStorage | null
|
|
createKey?: () => string
|
|
fallback?: Map<string, string>
|
|
lockManager?: Pick<LockManager, 'request'> | null
|
|
scope?: () => string | null
|
|
}
|
|
|
|
const STORAGE_KEY = 'admin.users.batch.wallet-adjustment.pending.v1'
|
|
const inMemoryFallback = new Map<string, string>()
|
|
|
|
export class UnresolvedWalletRequestMismatchError extends Error {
|
|
constructor(readonly pending: PendingUserBatchWalletRequest) {
|
|
super('A different wallet batch request is still unresolved')
|
|
this.name = 'UnresolvedWalletRequestMismatchError'
|
|
}
|
|
}
|
|
|
|
export class WalletIdempotencyUnavailableError extends Error {
|
|
constructor() {
|
|
super('crypto.randomUUID is unavailable')
|
|
this.name = 'WalletIdempotencyUnavailableError'
|
|
}
|
|
}
|
|
|
|
export class WalletIdempotencyPersistenceUnavailableError extends Error {
|
|
constructor() {
|
|
super('Persistent browser storage is unavailable')
|
|
this.name = 'WalletIdempotencyPersistenceUnavailableError'
|
|
}
|
|
}
|
|
|
|
export class WalletIdempotencyCoordinationUnavailableError extends Error {
|
|
constructor() {
|
|
super('Cross-tab wallet request coordination is unavailable')
|
|
this.name = 'WalletIdempotencyCoordinationUnavailableError'
|
|
}
|
|
}
|
|
|
|
export class WalletIdempotencyRequestInProgressError extends Error {
|
|
constructor() {
|
|
super('A wallet batch request is already in progress in another tab')
|
|
this.name = 'WalletIdempotencyRequestInProgressError'
|
|
}
|
|
}
|
|
|
|
export class WalletIdempotencyScopeUnavailableError extends Error {
|
|
constructor() {
|
|
super('The authenticated administrator identity is unavailable')
|
|
this.name = 'WalletIdempotencyScopeUnavailableError'
|
|
}
|
|
}
|
|
|
|
export class WalletIdempotencyScopeChangedError extends Error {
|
|
constructor() {
|
|
super('The authenticated administrator changed before the request was sent')
|
|
this.name = 'WalletIdempotencyScopeChangedError'
|
|
}
|
|
}
|
|
|
|
export class InvalidPendingWalletRequestError extends Error {
|
|
constructor() {
|
|
super('The stored wallet batch request is invalid')
|
|
this.name = 'InvalidPendingWalletRequestError'
|
|
}
|
|
}
|
|
|
|
function browserPersistentStorage(): StringStorage | null {
|
|
try {
|
|
return globalThis.localStorage ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function browserLockManager(): Pick<LockManager, 'request'> | null {
|
|
try {
|
|
return globalThis.navigator?.locks ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function secureRandomUUID(): string {
|
|
try {
|
|
const cryptoApi = globalThis.crypto
|
|
if (typeof cryptoApi?.randomUUID === 'function') {
|
|
return cryptoApi.randomUUID()
|
|
}
|
|
} catch {
|
|
// Treat unavailable secure randomness as a hard failure.
|
|
}
|
|
throw new WalletIdempotencyUnavailableError()
|
|
}
|
|
|
|
function isPendingRequest(value: unknown): value is PendingUserBatchWalletRequest {
|
|
if (typeof value !== 'object' || value === null) return false
|
|
const record = value as Partial<PendingUserBatchWalletRequest>
|
|
const request = record.request
|
|
return typeof record.idempotency_key === 'string'
|
|
&& record.idempotency_key.length > 0
|
|
&& typeof request === 'object'
|
|
&& request !== null
|
|
&& request.action === 'adjust_wallet_balance'
|
|
&& request.idempotency_key === record.idempotency_key
|
|
&& typeof request.selection === 'object'
|
|
&& request.selection !== null
|
|
&& typeof request.payload === 'object'
|
|
&& request.payload !== null
|
|
&& (request.payload.operation === 'add' || request.payload.operation === 'deduct')
|
|
&& Number.isFinite(request.payload.amount)
|
|
&& request.payload.amount > 0
|
|
}
|
|
|
|
function parsePendingRequest(serialized: string): PendingUserBatchWalletRequest {
|
|
try {
|
|
const value: unknown = JSON.parse(serialized)
|
|
if (isPendingRequest(value)) return value
|
|
} catch {
|
|
// Invalid persisted state must not allow a fresh adjustment to be sent.
|
|
}
|
|
throw new InvalidPendingWalletRequestError()
|
|
}
|
|
|
|
function stableSerialize(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((item) => item === undefined ? 'null' : stableSerialize(item)).join(',')}]`
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
const fields = Object.entries(value as Record<string, unknown>)
|
|
.filter(([, item]) => item !== undefined)
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
return `{${fields.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(',')}}`
|
|
}
|
|
return JSON.stringify(value) ?? 'null'
|
|
}
|
|
|
|
export function matchesPendingWalletRequest(
|
|
pending: PendingUserBatchWalletRequest,
|
|
request: UserBatchWalletAdjustmentRequest,
|
|
): boolean {
|
|
const { idempotency_key: _key, ...pendingPayload } = pending.request
|
|
return stableSerialize(pendingPayload) === stableSerialize(request)
|
|
}
|
|
|
|
export function createUserBatchWalletRetryCoordinator(options: CoordinatorOptions = {}) {
|
|
const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage()
|
|
const fallback = options.fallback ?? inMemoryFallback
|
|
const createKey = options.createKey ?? secureRandomUUID
|
|
const lockManager = 'lockManager' in options
|
|
? options.lockManager ?? null
|
|
: browserLockManager()
|
|
const getScope = options.scope ?? (() => 'default')
|
|
|
|
function getStorageKey(): string {
|
|
const scope = getScope()
|
|
if (!scope) throw new WalletIdempotencyScopeUnavailableError()
|
|
return `${STORAGE_KEY}:${encodeURIComponent(scope)}`
|
|
}
|
|
|
|
function readPending(storageKey: string): PendingUserBatchWalletRequest | null {
|
|
let serialized: string | null = null
|
|
let storageReadFailed = false
|
|
try {
|
|
serialized = storage?.getItem(storageKey) ?? null
|
|
} catch {
|
|
storageReadFailed = true
|
|
serialized = null
|
|
}
|
|
serialized ??= fallback.get(storageKey) ?? null
|
|
if (serialized === null && (!storage || storageReadFailed)) {
|
|
throw new WalletIdempotencyPersistenceUnavailableError()
|
|
}
|
|
return serialized === null ? null : parsePendingRequest(serialized)
|
|
}
|
|
|
|
function persist(storageKey: string, pending: PendingUserBatchWalletRequest): void {
|
|
const serialized = JSON.stringify(pending)
|
|
if (!storage) throw new WalletIdempotencyPersistenceUnavailableError()
|
|
try {
|
|
storage.setItem(storageKey, serialized)
|
|
if (storage.getItem(storageKey) !== serialized) {
|
|
throw new Error('Stored wallet batch request could not be verified')
|
|
}
|
|
} catch {
|
|
throw new WalletIdempotencyPersistenceUnavailableError()
|
|
}
|
|
fallback.set(storageKey, serialized)
|
|
}
|
|
|
|
function clear(storageKey: string): void {
|
|
fallback.delete(storageKey)
|
|
try {
|
|
storage?.removeItem(storageKey)
|
|
} catch {
|
|
// A stale persisted request is safe to replay and will fail closed on mismatch.
|
|
}
|
|
}
|
|
|
|
async function withExclusiveLock<T>(storageKey: string, task: () => Promise<T>): Promise<T> {
|
|
if (!lockManager) throw new WalletIdempotencyCoordinationUnavailableError()
|
|
|
|
let taskStarted = false
|
|
try {
|
|
return await lockManager.request(
|
|
storageKey,
|
|
{ mode: 'exclusive', ifAvailable: true },
|
|
async (lock) => {
|
|
if (lock === null) throw new WalletIdempotencyRequestInProgressError()
|
|
taskStarted = true
|
|
return task()
|
|
},
|
|
)
|
|
} catch (error) {
|
|
if (taskStarted || error instanceof WalletIdempotencyRequestInProgressError) throw error
|
|
throw new WalletIdempotencyCoordinationUnavailableError()
|
|
}
|
|
}
|
|
|
|
function getOrCreate(
|
|
storageKey: string,
|
|
request: UserBatchWalletAdjustmentRequest,
|
|
): UserBatchBalanceActionRequest {
|
|
const pending = readPending(storageKey)
|
|
if (pending) {
|
|
if (!matchesPendingWalletRequest(pending, request)) {
|
|
throw new UnresolvedWalletRequestMismatchError(pending)
|
|
}
|
|
persist(storageKey, pending)
|
|
return pending.request
|
|
}
|
|
|
|
const idempotencyKey = createKey()
|
|
if (!idempotencyKey) throw new WalletIdempotencyUnavailableError()
|
|
const keyedRequest: UserBatchBalanceActionRequest = {
|
|
...request,
|
|
idempotency_key: idempotencyKey,
|
|
}
|
|
persist(storageKey, { idempotency_key: idempotencyKey, request: keyedRequest })
|
|
return keyedRequest
|
|
}
|
|
|
|
async function sendAndResolve(
|
|
request: UserBatchBalanceActionRequest,
|
|
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
|
|
storageKey: string,
|
|
): Promise<UserBatchActionResponse> {
|
|
const response = await send(request)
|
|
if (!response.interrupted) clear(storageKey)
|
|
return response
|
|
}
|
|
|
|
return {
|
|
getPending() {
|
|
return readPending(getStorageKey())
|
|
},
|
|
async execute(
|
|
request: UserBatchWalletAdjustmentRequest,
|
|
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
|
|
) {
|
|
const storageKey = getStorageKey()
|
|
return withExclusiveLock(storageKey, async () => {
|
|
const keyedRequest = getOrCreate(storageKey, request)
|
|
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
|
|
return sendAndResolve(keyedRequest, send, storageKey)
|
|
})
|
|
},
|
|
async retry(
|
|
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
|
|
): Promise<UserBatchActionResponse | null> {
|
|
const storageKey = getStorageKey()
|
|
return withExclusiveLock(storageKey, async () => {
|
|
const pending = readPending(storageKey)
|
|
if (!pending) return null
|
|
persist(storageKey, pending)
|
|
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
|
|
return sendAndResolve(pending.request, send, storageKey)
|
|
})
|
|
},
|
|
}
|
|
}
|