fix(admin): make bulk wallet batches idempotent

This commit is contained in:
RWDai
2026-09-23 19:43:18 +08:00
parent 75471ae4a4
commit bcd121d447
19 changed files with 2396 additions and 95 deletions
@@ -88,6 +88,39 @@
</p>
</div>
<div
v-if="pendingWalletBatch"
role="alert"
class="space-y-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
>
<p>
{{ legacyT('存在未决的钱包批量调整') }}:{{ pendingWalletOperationLabel }} {{ pendingWalletBatch.request.payload.amount }} USD。{{ legacyT('结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。') }}
</p>
<p
v-if="walletRequestMismatch"
class="text-xs"
>
{{ legacyT('当前表单与原请求不同;新钱包调整已禁用,请先重试原请求。') }}
</p>
<Button
type="button"
size="sm"
variant="outline"
:disabled="executing"
@click="retryPendingWalletBatch"
>
<RotateCcw class="mr-1.5 h-4 w-4" />
{{ legacyT('重试原钱包批次') }}
</Button>
</div>
<p
v-else-if="pendingWalletReadError"
role="alert"
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
>
{{ legacyT('无法读取未决的钱包批量请求。为避免重复扣款,钱包余额调整已禁用;请先核对余额操作结果。') }}
</p>
<UserBatchResultSummary
:result="lastResult"
:label="lastResultLabel"
@@ -121,8 +154,9 @@ import {
Input,
Label,
} from '@/components/ui'
import { Minus, Plus } from 'lucide-vue-next'
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
import { useUsersStore } from '@/stores/users'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { useI18n } from '@/i18n'
@@ -135,6 +169,18 @@ import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
import type { UserBatchQuotaMode } from './user-management-types'
import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
import {
createUserBatchWalletRetryCoordinator,
matchesPendingWalletRequest,
WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyScopeChangedError,
WalletIdempotencyScopeUnavailableError,
WalletIdempotencyUnavailableError,
} from '../utils/userBatchWalletIdempotency'
import type {
PendingUserBatchWalletRequest,
UserBatchWalletAdjustmentRequest,
} from '../utils/userBatchWalletIdempotency'
import type {
UserBatchAccessControlPayload,
UserBatchAction,
@@ -164,6 +210,10 @@ const emit = defineEmits<{
}>()
const usersStore = useUsersStore()
const authStore = useAuthStore()
const walletRetryCoordinator = createUserBatchWalletRetryCoordinator({
scope: () => authStore.user?.id ?? null,
})
const { success, warning, error } = useToast()
const { legacyT, locale } = useI18n()
@@ -178,6 +228,8 @@ const previewItems = ref<UserBatchSelectionItem[]>([])
const resolvedTotal = ref<number | null>(null)
const executing = ref(false)
const lastResult = ref<UserBatchActionResponse | null>(null)
const pendingWalletBatch = ref<PendingUserBatchWalletRequest | null>(null)
const pendingWalletReadError = ref(false)
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
@@ -185,11 +237,23 @@ const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
balanceOperation.value,
balanceAmount.value,
))
const walletAdjustmentRequest = computed<UserBatchWalletAdjustmentRequest | null>(() => (
balancePayload.value === null
? null
: { selection: buildSelection(), action: 'adjust_wallet_balance', payload: balancePayload.value }
))
const walletRequestMismatch = computed(() => (
pendingWalletBatch.value !== null
&& selectedAction.value === 'adjust_wallet_balance'
&& (walletAdjustmentRequest.value === null
|| !matchesPendingWalletRequest(pendingWalletBatch.value, walletAdjustmentRequest.value))
))
const canExecute = computed(() => (
hasAnyTarget.value
&& !previewLoading.value
&& !executing.value
&& (selectedAction.value !== 'adjust_wallet_balance' || balancePayload.value !== null)
&& (selectedAction.value !== 'adjust_wallet_balance'
|| (balancePayload.value !== null && !pendingWalletReadError.value && !walletRequestMismatch.value))
))
const selectedActionLabel = computed(() => (
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
@@ -208,6 +272,11 @@ const targetRoleWarning = computed(() => {
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
})
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
const pendingWalletOperationLabel = computed(() => (
pendingWalletBatch.value?.request.payload.operation === 'deduct'
? legacyT('扣减')
: legacyT('增加')
))
const lastResultLabel = computed(() => {
if (!lastResult.value) return ''
if (lastResult.value.interrupted) {
@@ -230,8 +299,15 @@ watch(
(open) => {
if (!open) return
resetLocalState()
refreshPendingWalletBatch()
void resolvePreview()
},
{ immediate: true },
)
watch(
() => authStore.user?.id,
() => refreshPendingWalletBatch(),
)
watch(
@@ -255,6 +331,16 @@ function resetLocalState(): void {
lastResult.value = null
}
function refreshPendingWalletBatch(): void {
try {
pendingWalletBatch.value = walletRetryCoordinator.getPending()
pendingWalletReadError.value = false
} catch {
pendingWalletBatch.value = null
pendingWalletReadError.value = true
}
}
function buildSelection(): UserBatchSelection {
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
if (props.selectAllFiltered) {
@@ -301,7 +387,8 @@ function buildRolePayload(): UserBatchRolePayload {
async function executeBatchAction(): Promise<void> {
if (!canExecute.value) return
const selection = buildSelection()
let request: UserBatchActionRequest
let request: Exclude<UserBatchActionRequest, { action: 'adjust_wallet_balance' }> | null = null
let walletRequest: UserBatchWalletAdjustmentRequest | null = null
if (selectedAction.value === 'update_access_control') {
const payload = buildAccessControlPayload()
if (payload === null) {
@@ -310,11 +397,11 @@ async function executeBatchAction(): Promise<void> {
}
request = { selection, action: 'update_access_control', payload }
} else if (selectedAction.value === 'adjust_wallet_balance') {
if (balancePayload.value === null) {
if (walletAdjustmentRequest.value === null) {
warning(legacyT('请输入大于 0 的有限金额'))
return
}
request = { selection, action: 'adjust_wallet_balance', payload: balancePayload.value }
walletRequest = walletAdjustmentRequest.value
} else if (selectedAction.value === 'update_role') {
request = { selection, action: 'update_role', payload: buildRolePayload() }
} else {
@@ -323,6 +410,15 @@ async function executeBatchAction(): Promise<void> {
executing.value = true
try {
if (walletRequest) {
const result = await walletRetryCoordinator.execute(
walletRequest,
(keyedRequest) => usersStore.batchAction(keyedRequest),
)
handleWalletBatchResult(result)
return
}
if (request === null) return
const result = await usersStore.batchAction(request)
lastResult.value = result
if (result.interrupted) {
@@ -338,9 +434,63 @@ async function executeBatchAction(): Promise<void> {
}
emit('completed', result)
} catch (err) {
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
if (walletRequest) {
refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (
err instanceof WalletIdempotencyUnavailableError
|| err instanceof WalletIdempotencyScopeUnavailableError
|| err instanceof WalletIdempotencyScopeChangedError
) {
warning(legacyT('无法确认管理员身份或安全生成钱包批量请求标识,请求未发送。'))
} else {
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
}
} else {
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
}
} finally {
executing.value = false
}
}
async function retryPendingWalletBatch(): Promise<void> {
if (executing.value) return
executing.value = true
try {
const result = await walletRetryCoordinator.retry(
(request) => usersStore.batchAction(request),
)
if (result) handleWalletBatchResult(result)
else refreshPendingWalletBatch()
} catch (err) {
refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (
err instanceof WalletIdempotencyScopeUnavailableError
|| err instanceof WalletIdempotencyScopeChangedError
) {
warning(legacyT('无法确认管理员身份,请求未发送。'))
} else {
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
}
} finally {
executing.value = false
}
}
function handleWalletBatchResult(result: UserBatchActionResponse): void {
lastResult.value = result
refreshPendingWalletBatch()
if (result.interrupted) {
warning(`${lastResultLabel.value};${legacyT('结果可能部分完成,请仅重试原请求,不要开始新的余额调整。')}`)
} else {
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
if (result.failed > 0) warning(message)
else success(message)
}
emit('completed', result)
}
</script>
@@ -0,0 +1,312 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
import {
createUserBatchWalletRetryCoordinator,
UnresolvedWalletRequestMismatchError,
WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyUnavailableError,
} from '../userBatchWalletIdempotency'
function createStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
const walletRequest = {
selection: { user_ids: ['user-1', 'user-2'], group_ids: ['group-1'] },
action: 'adjust_wallet_balance' as const,
payload: { operation: 'deduct' as const, amount: 17.25 },
}
const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default'
let testFallback: Map<string, string>
function response(interrupted = false): UserBatchActionResponse {
return { total: 2, success: 1, failed: 0, failures: [], interrupted }
}
describe('user batch wallet idempotency', () => {
beforeEach(() => {
sessionStorage.clear()
localStorage.clear()
testFallback = new Map()
})
it('serializes the key with the exact top-level wallet request before sending', async () => {
const storage = createStorage()
const coordinator = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-1',
})
let storedDuringSend: string | null = null
let sentRequest: UserBatchBalanceActionRequest | undefined
await coordinator.execute(walletRequest, async (request) => {
sentRequest = request
storedDuringSend = storage.getItem(defaultStorageKey)
return response(true)
})
expect(sentRequest).toEqual({ ...walletRequest, idempotency_key: 'wallet-key-1' })
expect(JSON.parse(storedDuringSend ?? 'null')).toEqual({
idempotency_key: 'wallet-key-1',
request: sentRequest,
})
})
it('retains a transport failure and reopens with the exact request for retry', async () => {
const storage = createStorage()
const first = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-2',
})
const sendFailure = new Error('connection lost')
await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure)
const reopened = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => 'must-not-be-used',
})
const pending = reopened.getPending()
expect(pending).toEqual({
idempotency_key: 'wallet-key-2',
request: { ...walletRequest, idempotency_key: 'wallet-key-2' },
})
const send = vi.fn(async () => response())
await expect(reopened.retry(send)).resolves.toEqual(response())
expect(send).toHaveBeenCalledWith(pending?.request)
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
it('keeps unresolved requests in persistent browser storage across coordinators', async () => {
const first = createUserBatchWalletRetryCoordinator({
createKey: () => 'wallet-key-persistent',
scope: () => 'admin-1',
})
await expect(first.execute(walletRequest, async () => {
throw new Error('connection lost')
})).rejects.toThrow('connection lost')
const reopened = createUserBatchWalletRetryCoordinator({ scope: () => 'admin-1' })
const pending = reopened.getPending()
expect(pending?.request).toEqual({
...walletRequest,
idempotency_key: 'wallet-key-persistent',
})
const send = vi.fn(async () => response())
await reopened.retry(send)
expect(send).toHaveBeenCalledWith(pending?.request)
expect(localStorage.getItem(
'admin.users.batch.wallet-adjustment.pending.v1:admin-1',
)).toBeNull()
})
it('keeps unresolved requests isolated by authenticated administrator', async () => {
const storage = createStorage()
const adminA = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
scope: () => 'admin-a',
createKey: () => 'wallet-key-admin-a',
})
await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') }))
.rejects.toThrow('connection lost')
const adminB = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
scope: () => 'admin-b',
createKey: () => 'wallet-key-admin-b',
})
expect(adminB.getPending()).toBeNull()
await adminB.execute(walletRequest, async () => response(true))
expect(adminA.getPending()?.idempotency_key).toBe('wallet-key-admin-a')
expect(adminB.getPending()?.idempotency_key).toBe('wallet-key-admin-b')
})
it('does not send if the authenticated administrator changes before dispatch', async () => {
const storage = createStorage()
let scopeReads = 0
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
scope: () => (++scopeReads === 1 ? 'admin-a' : 'admin-b'),
createKey: () => 'wallet-key-scope-change',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toThrow(
'authenticated administrator changed',
)
expect(send).not.toHaveBeenCalled()
expect(storage.getItem('admin.users.batch.wallet-adjustment.pending.v1:admin-a')).not.toBeNull()
})
it('retains interrupted requests and reuses their key until a terminal response', async () => {
const storage = createStorage()
const first = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-3',
})
await first.execute(walletRequest, async () => response(true))
const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback })
const pending = reopened.getPending()
const send = vi.fn(async () => response(true))
await reopened.retry(send)
expect(send).toHaveBeenCalledWith(pending?.request)
expect(reopened.getPending()).toEqual(pending)
await reopened.retry(async (request) => {
expect(request).toEqual(pending?.request)
return response()
})
expect(reopened.getPending()).toBeNull()
})
it('matches the same serialized request when optional filter fields are omitted', async () => {
const storage = createStorage()
const requestWithUndefinedField = {
...walletRequest,
selection: { filters: { search: 'active', is_active: undefined } },
}
const first = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-filter',
})
await first.execute(requestWithUndefinedField, async () => response(true))
const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback })
const send = vi.fn(async () => response())
await reopened.execute({
...walletRequest,
selection: { filters: { search: 'active' } },
}, send)
expect(send).toHaveBeenCalledWith({
...walletRequest,
selection: { filters: { search: 'active' } },
idempotency_key: 'wallet-key-filter',
})
})
it('blocks changed payloads while unresolved and gives a later adjustment a new key', async () => {
const storage = createStorage()
let nextKey = 0
const coordinator = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => `wallet-key-${++nextKey}`,
})
await expect(coordinator.execute(walletRequest, async () => { throw new Error('connection lost') }))
.rejects.toThrow('connection lost')
const changedRequest = {
...walletRequest,
payload: { operation: 'add' as const, amount: 20 },
}
const send = vi.fn(async () => response())
await expect(coordinator.execute(changedRequest, send)).rejects.toBeInstanceOf(
UnresolvedWalletRequestMismatchError,
)
expect(send).not.toHaveBeenCalled()
expect(coordinator.getPending()?.idempotency_key).toBe('wallet-key-1')
await coordinator.retry(async () => response())
let newRequest
await coordinator.execute(changedRequest, async (request) => {
newRequest = request
return response()
})
expect(newRequest).toEqual({ ...changedRequest, idempotency_key: 'wallet-key-2' })
})
it('fails closed when persistent storage cannot save the request', async () => {
const unavailableStorage = {
getItem: () => null,
setItem: () => { throw new Error('storage unavailable') },
removeItem: () => undefined,
}
const send = vi.fn(async () => response(true))
const coordinator = createUserBatchWalletRetryCoordinator({
storage: unavailableStorage,
fallback: testFallback,
createKey: () => 'wallet-key-fallback',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyPersistenceUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(testFallback.size).toBe(0)
})
it('does not send when persistent storage readback does not match', async () => {
let wasWritten = false
const mismatchedStorage = {
getItem: () => wasWritten ? 'different request' : null,
setItem: () => { wasWritten = true },
removeItem: () => undefined,
}
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
storage: mismatchedStorage,
fallback: testFallback,
createKey: () => 'wallet-key-readback',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyPersistenceUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(testFallback.size).toBe(0)
})
it('does not create a new request when persistent storage cannot be read', async () => {
const unavailableStorage = {
getItem: () => { throw new Error('storage unavailable') },
setItem: () => undefined,
removeItem: () => undefined,
}
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
storage: unavailableStorage,
fallback: testFallback,
createKey: () => 'must-not-be-used',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyPersistenceUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(testFallback.size).toBe(0)
})
it('fails closed when secure UUID generation is unavailable', async () => {
const storage = createStorage()
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
createKey: () => { throw new WalletIdempotencyUnavailableError() },
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
})
@@ -0,0 +1,252 @@
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>
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 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 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 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.
}
}
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()
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()
const pending = readPending(storageKey)
if (!pending) return null
persist(storageKey, pending)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(pending.request, send, storageKey)
},
}
}