| 1 | /** |
| 2 | * Redeeming a reset credit is an account-level action: instances that share |
| 3 | * the directory holding a provider's login share the credit, so their |
| 4 | * redemptions must serialise on that directory, not the instance. This |
| 5 | * service keeps one lock and one pending idempotency key per account key so |
| 6 | * overlapping confirmations from any instance queue rather than spending two |
| 7 | * credits, and a retry after a timeout re-sends the same attempt. |
| 8 | * |
| 9 | * @module provider/resetCreditCoordinator |
| 10 | */ |
| 11 | import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; |
| 12 | import * as Context from "effect/Context"; |
| 13 | import * as Crypto from "effect/Crypto"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import type * as PlatformError from "effect/PlatformError"; |
| 17 | import * as Ref from "effect/Ref"; |
| 18 | import * as Semaphore from "effect/Semaphore"; |
| 19 | |
| 20 | interface AccountRedemptionState { |
| 21 | readonly lock: Semaphore.Semaphore; |
| 22 | readonly pendingKey: Ref.Ref<string | null>; |
| 23 | } |
| 24 | |
| 25 | export class ResetCreditCoordinator extends Context.Service< |
| 26 | ResetCreditCoordinator, |
| 27 | { |
| 28 | /** |
| 29 | * Run `consume` under the account's lock with a stable idempotency key. |
| 30 | * The key is cleared when the provider reports an outcome, or when |
| 31 | * `isSettled` says a failure was a final answer (such as a cooldown). |
| 32 | * Any other failure (timeout included) keeps it so the next attempt is |
| 33 | * the same attempt. |
| 34 | */ |
| 35 | readonly redeem: <E, R>( |
| 36 | accountKey: string, |
| 37 | consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>, |
| 38 | isSettled?: (error: E) => boolean, |
| 39 | ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>; |
| 40 | } |
| 41 | >()("t3/provider/resetCreditCoordinator") {} |
| 42 | |
| 43 | /** @public Service construction is part of the canonical Effect module API. */ |
| 44 | export const make = Effect.gen(function* () { |
| 45 | const crypto = yield* Crypto.Crypto; |
| 46 | const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map()); |
| 47 | |
| 48 | // Get-or-create through one Ref.modify so two first redemptions for the |
| 49 | // same account cannot each install their own lock. |
| 50 | const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { |
| 51 | const existing = (yield* Ref.get(statesRef)).get(accountKey); |
| 52 | if (existing) return existing; |
| 53 | const candidate = { |
| 54 | lock: yield* Semaphore.make(1), |
| 55 | pendingKey: yield* Ref.make<string | null>(null), |
| 56 | }; |
| 57 | return yield* Ref.modify(statesRef, (states) => { |
| 58 | const current = states.get(accountKey); |
| 59 | if (current) return [current, states] as const; |
| 60 | const next = new Map(states); |
| 61 | next.set(accountKey, candidate); |
| 62 | return [candidate, next] as const; |
| 63 | }); |
| 64 | }); |
| 65 | |
| 66 | const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => |
| 67 | Effect.gen(function* () { |
| 68 | const state = yield* stateFor(accountKey); |
| 69 | return yield* state.lock.withPermits(1)( |
| 70 | Effect.gen(function* () { |
| 71 | const existing = yield* Ref.get(state.pendingKey); |
| 72 | const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); |
| 73 | yield* Ref.set(state.pendingKey, idempotencyKey); |
| 74 | const outcome = yield* consume(idempotencyKey).pipe( |
| 75 | Effect.tapError((error) => |
| 76 | isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, |
| 77 | ), |
| 78 | ); |
| 79 | yield* Ref.set(state.pendingKey, null); |
| 80 | return outcome; |
| 81 | }), |
| 82 | ); |
| 83 | }); |
| 84 | |
| 85 | return { redeem } satisfies ResetCreditCoordinator["Service"]; |
| 86 | }); |
| 87 | |
| 88 | export const layer = Layer.effect(ResetCreditCoordinator, make); |
| 89 | |
| 90 | /** |
| 91 | * Self-contained for tests: a counter-backed Crypto so keys are deterministic |
| 92 | * and distinct without the platform layer. |
| 93 | */ |
| 94 | export const layerTest = Layer.effect( |
| 95 | ResetCreditCoordinator, |
| 96 | Effect.gen(function* () { |
| 97 | let counter = 0; |
| 98 | return yield* make.pipe( |
| 99 | Effect.provideService( |
| 100 | Crypto.Crypto, |
| 101 | Crypto.make({ |
| 102 | randomBytes: (size) => { |
| 103 | counter += 1; |
| 104 | return new Uint8Array(size).fill(counter); |
| 105 | }, |
| 106 | digest: (_algorithm, data) => Effect.succeed(data), |
| 107 | }), |
| 108 | ), |
| 109 | ); |
| 110 | }), |
| 111 | ); |