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 */
11import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import type * as PlatformError from "effect/PlatformError";
17import * as Ref from "effect/Ref";
18import * as Semaphore from "effect/Semaphore";
19
20interface AccountRedemptionState {
21 readonly lock: Semaphore.Semaphore;
22 readonly pendingKey: Ref.Ref<string | null>;
23}
24
25export 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. */
44export 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
88export 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 */
94export 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);