Skip to content
← 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
	}
}