← Public packages
@kentcdodds/x
X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.
src/idempotency.ts
106 lines · 3.2 KB · TypeScript/** Successful send fingerprints are reused for this long (7 days). */
export const IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
/** Concurrent workers with the same key share this short inflight window. */
export const IDEMPOTENCY_INFLIGHT_MS = 2 * 60 * 1000
const MAX_KEY_LENGTH = 256
export type IdempotencyFingerprint =
| {
status: 'success'
result: unknown
completedAt: string
}
| {
status: 'inflight'
startedAt: string
}
export type IdempotencyStorage = {
get: (key: string) => Promise<unknown>
set: (key: string, value: unknown) => Promise<unknown>
delete: (key: string) => Promise<unknown>
}
export function normalizeIdempotencyKey(raw: string | undefined | null): string | null {
if (typeof raw !== 'string') return null
const key = raw.trim()
if (!key) return null
if (key.length > MAX_KEY_LENGTH) {
throw new Error(`idempotencyKey must be at most ${MAX_KEY_LENGTH} characters.`)
}
return key
}
export function idempotencyStorageKey(action: string, account: string, key: string): string {
return `idempotency:${action}:${account}:${key}`
}
export function readFreshFingerprint(
value: unknown,
nowMs: number = Date.now(),
): { kind: 'success'; result: unknown } | { kind: 'inflight' } | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as IdempotencyFingerprint
if (record.status === 'success' && typeof record.completedAt === 'string') {
const completed = Date.parse(record.completedAt)
if (!Number.isNaN(completed) && nowMs - completed < IDEMPOTENCY_RETENTION_MS) {
return { kind: 'success', result: record.result }
}
return null
}
if (record.status === 'inflight' && typeof record.startedAt === 'string') {
const started = Date.parse(record.startedAt)
if (!Number.isNaN(started) && nowMs - started < IDEMPOTENCY_INFLIGHT_MS) {
return { kind: 'inflight' }
}
return null
}
return null
}
/**
* Run a side-effecting send at most once per `(account, idempotencyKey)` within
* the retention window. Omitting the key leaves behavior unchanged.
*/
export async function withSendIdempotency<T>(options: {
action: string
account: string
idempotencyKey?: string
run: () => Promise<T>
storage?: IdempotencyStorage
}): Promise<T> {
const key = normalizeIdempotencyKey(options.idempotencyKey)
if (!key) return await options.run()
const storage =
options.storage ??
(await import('kody:runtime')).packageStorage()
const storageKey = idempotencyStorageKey(options.action, options.account, key)
const existing = readFreshFingerprint(await storage.get(storageKey))
if (existing?.kind === 'success') return existing.result as T
if (existing?.kind === 'inflight') {
throw new Error(
`Send already in progress for idempotencyKey "${key}"; retry shortly to receive the prior successful result.`,
)
}
await storage.set(storageKey, {
status: 'inflight',
startedAt: new Date().toISOString(),
} satisfies IdempotencyFingerprint)
try {
const result = await options.run()
await storage.set(storageKey, {
status: 'success',
result,
completedAt: new Date().toISOString(),
} satisfies IdempotencyFingerprint)
return result
} catch (error) {
await storage.delete(storageKey)
throw error
}
}