Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 28 additions & 17 deletions packages/alchemy/src/Auth/Demand.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Cause from "effect/Cause";
/**
* The credential-demand seam for credential-free `alchemy dev`.
*
Expand Down Expand Up @@ -105,24 +106,34 @@ export class CredentialsRequired extends Data.TaggedError("CredentialsRequired")
export const failCredentialsRequired = (fqn: string) =>
Effect.fn(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
const isUnavailable = Schema.is(CredentialsUnavailable);
// Annotated: the catch handler returns this or a re-fail of E, and a
// union of Effects does not unify on its own.
const required = (
unavailable: CredentialsUnavailable,
): Effect.Effect<never, E | CredentialsRequired> =>
Effect.fail(
new CredentialsRequired({
provider: unavailable.provider,
resources: [fqn],
reason: "runs against the real cloud",
message: unavailable.message,
}),
);
const required = (unavailable: CredentialsUnavailable) =>
new CredentialsRequired({
provider: unavailable.provider,
resources: [fqn],
reason: "runs against the real cloud",
message: unavailable.message,
});
return yield* self.pipe(
// oxlint-disable-next-line effecttsgo/catch-conditional-refail-to-catch-if -- catchIf cannot narrow a generic E
Effect.catch((error) => (isUnavailable(error) ? required(error) : Effect.fail(error))),
Effect.catchDefect((defect) =>
isUnavailable(defect) ? required(defect) : Effect.die(defect),
Effect.catchCause((cause) =>
Effect.failCause(
cause.reasons.reduce<Cause.Cause<E | CredentialsRequired>>((combined, reason) => {
if (Cause.isFailReason(reason)) {
return Cause.combine(
combined,
Cause.fail(isUnavailable(reason.error) ? required(reason.error) : reason.error),
);
}
if (Cause.isDieReason(reason)) {
return Cause.combine(
combined,
isUnavailable(reason.defect)
? Cause.fail(required(reason.defect))
: Cause.die(reason.defect),
);
}
return Cause.combine(combined, Cause.interrupt(reason.fiberId));
}, Cause.empty),
),
),
);
});
Expand Down
59 changes: 59 additions & 0 deletions packages/alchemy/test/Auth/Demand.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { expect, it } from "@alchemy.run/test";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Cause from "effect/Cause";
import * as ConfigProvider from "effect/ConfigProvider";
import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
Expand All @@ -11,8 +13,10 @@ import {
type CredentialDemand,
type CredentialsRequired,
demandCredentials,
failCredentialsRequired,
} from "@/Auth/Demand.ts";
import { ProfileStore, ProfileStoreLive } from "@/Auth/Profile.ts";
import { CredentialsUnavailable } from "@/Auth/Resolve.ts";

const PROBE = "DemandProbe";

Expand Down Expand Up @@ -123,6 +127,61 @@ const demand: CredentialDemand = {
resources: [{ fqn: "Website", reason: "remote" }],
};

it.live(
"maps unavailable credentials in composite causes without dropping other reasons",
() =>
Effect.sync(() => {
const otherFailure = new Error("provider operation failed");
const otherDefect = new Error("unexpected provider defect");
const unavailable = new CredentialsUnavailable({
provider: PROBE,
message: "Configure credentials.",
});
const cause = Cause.combine(
Cause.combine(Cause.fail(otherFailure), Cause.fail(unavailable)),
Cause.die(otherDefect),
);
const exit = Effect.runSyncExit(failCredentialsRequired("Website")(Effect.failCause(cause)));

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const reasons = exit.cause.reasons;
expect(reasons).toHaveLength(3);
expect(Cause.isFailReason(reasons[0]) && reasons[0].error).toBe(otherFailure);
expect(Cause.isFailReason(reasons[1]) && reasons[1].error).toMatchObject({
_tag: "CredentialsRequired",
provider: PROBE,
resources: ["Website"],
});
expect(Cause.isDieReason(reasons[2]) && reasons[2].defect).toBe(otherDefect);
}
}),
{ tags: ["unit", "local"] },
);

it.live(
"maps unavailable credential defects to CredentialsRequired",
() =>
Effect.sync(() => {
const unavailable = new CredentialsUnavailable({
provider: PROBE,
message: "Configure credentials.",
});
const exit = Effect.runSyncExit(failCredentialsRequired("Website")(Effect.die(unavailable)));

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0];
expect(Cause.isFailReason(reason) && reason.error).toMatchObject({
_tag: "CredentialsRequired",
provider: PROBE,
resources: ["Website"],
});
}
}),
{ tags: ["unit", "local"] },
);

it.live(
"zero demand touches no credentials at all",
() =>
Expand Down