Skip to content

Commit 1eade78

Browse files
committed
feat: include Phase in the Plan and properly type the output of Apply
1 parent 9784eca commit 1eade78

File tree

9 files changed

+129
-122
lines changed

9 files changed

+129
-122
lines changed

@alchemy.run/effect-aws/src/lambda/function.provider.ts

Lines changed: 14 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,7 @@ import { FileSystem } from "@effect/platform";
55
import * as Effect from "effect/Effect";
66
import * as Schedule from "effect/Schedule";
77

8-
import {
9-
App,
10-
DotAlchemy,
11-
type BindingService,
12-
type BindNode,
13-
type ProviderService,
14-
} from "@alchemy.run/effect";
8+
import { App, DotAlchemy, type ProviderService } from "@alchemy.run/effect";
159

1610
import type {
1711
CreateFunctionUrlConfigRequest,
@@ -47,50 +41,26 @@ export const functionProvider = () =>
4741
const attachBindings = Effect.fn(function* ({
4842
roleName,
4943
policyName,
50-
functionArn,
51-
functionName,
44+
// functionArn,
45+
// functionName,
5246
bindings,
5347
}: {
5448
roleName: string;
5549
policyName: string;
5650
functionArn: string;
5751
functionName: string;
58-
bindings: BindNode[];
52+
bindings: Function["binding"][];
5953
}) {
60-
let env: Record<string, string> = {};
61-
const policyStatements: IAM.PolicyStatement[] = [];
62-
63-
for (const binding of bindings) {
64-
if (binding.action === "attach") {
65-
const binder = yield* Function(
66-
binding.capability,
67-
// erase the Lambda(Capability) requirement
68-
// TODO(sam): move bindings into the core engine instead of replicating them here
69-
) as unknown as Effect.Effect<BindingService, never, never>;
70-
const bound = yield* binder.attach(
71-
{
72-
id: binding.capability.resource.id,
73-
attr: binding.attributes,
74-
props: binding.capability.resource.props,
75-
},
76-
binding.capability,
77-
{
78-
env,
79-
policyStatements,
80-
},
81-
);
82-
env = { ...env, ...(bound?.env ?? {}) };
83-
84-
policyStatements.push(
85-
...bound?.policyStatements?.map((stmt: IAM.PolicyStatement) => ({
86-
...stmt,
87-
Sid: stmt.Sid?.replace(/[^A-Za-z0-9]+/gi, ""),
88-
})),
89-
);
90-
} else if (binding.action === "detach") {
91-
// no-op: PutRolePolicy will remove the removed statements
92-
}
93-
}
54+
const env = bindings.reduce(
55+
(acc, binding) => ({ ...acc, ...binding?.env }),
56+
{},
57+
);
58+
const policyStatements = bindings.flatMap((binding) =>
59+
binding?.policyStatements?.map((stmt: IAM.PolicyStatement) => ({
60+
...stmt,
61+
Sid: stmt.Sid?.replace(/[^A-Za-z0-9]+/gi, ""),
62+
})),
63+
);
9464

9565
if (policyStatements.length > 0) {
9666
yield* iam.putRolePolicy({

@alchemy.run/effect-aws/src/lambda/function.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ export type FunctionAttr<Props extends FunctionProps = FunctionProps> = {
2525
};
2626

2727
export interface Function extends Runtime<"AWS.Lambda.Function"> {
28+
props: FunctionProps;
29+
attr: FunctionAttr<Extract<this["props"], FunctionProps>>;
2830
binding: {
2931
env: {
3032
[key: string]: string;
3133
};
3234
policyStatements: IAM.PolicyStatement[];
3335
};
34-
props: FunctionProps;
35-
attr: FunctionAttr<Extract<this["props"], FunctionProps>>;
3636
}
3737
export const Function = Runtime("AWS.Lambda.Function")<Function>();

@alchemy.run/effect-aws/src/sqs/queue.send-message.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,19 @@ export const sendMessage = <Q extends Queue>(
3434

3535
export const sendMessageFromLambdaFunction = () =>
3636
SendMessage.provider.succeed({
37-
// oxlint-disable-next-line require-yield
38-
attach: Effect.fn(function* (queue) {
39-
return {
40-
env: {
41-
// ask what attribute is needed to interact? e.g. is it the Queue ARN or the Queue URL?
42-
[`${queue.id.toUpperCase().replace(/-/g, "_")}_QUEUE_URL`]:
43-
queue.attr.queueUrl,
37+
attach: (queue) => ({
38+
env: {
39+
// ask what attribute is needed to interact? e.g. is it the Queue ARN or the Queue URL?
40+
[`${queue.id.toUpperCase().replace(/-/g, "_")}_QUEUE_URL`]:
41+
queue.attr.queueUrl,
42+
},
43+
policyStatements: [
44+
{
45+
Sid: "SendMessage",
46+
Effect: "Allow",
47+
Action: ["sqs:SendMessage"], // <- ask LLM how to generate this
48+
Resource: [queue.attr.queueArn],
4449
},
45-
policyStatements: [
46-
{
47-
Sid: "AWS.SQS.SendMessage",
48-
Effect: "Allow",
49-
Action: ["sqs:SendMessage"], // <- ask LLM how to generate this
50-
Resource: [queue.attr.queueArn],
51-
},
52-
],
53-
};
50+
],
5451
}),
5552
});

@alchemy.run/effect-example/alchemy.run.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ import { NodeContext } from "@effect/platform-node";
66
import * as Effect from "effect/Effect";
77
import { Api } from "./src/index.ts";
88

9+
const phase = process.argv.includes("--destroy") ? "destroy" : "update";
10+
911
const plan = Alchemy.plan({
10-
// phase: process.argv.includes("--destroy") ? "destroy" : "update",
11-
phase: "update",
12+
phase,
13+
// phase: "update",
14+
// phase: "destroy",
1215
services: [Api],
1316
});
1417

1518
const stack = await plan.pipe(
1619
Alchemy.apply,
17-
Effect.catchTag("PlanRejected", () => Effect.void),
20+
Effect.catchTag("PlanRejected", () => Effect.die("Plan rejected")),
1821
Effect.provide(AlchemyCLI.layer),
1922
Effect.provide(AWS.live),
2023
Effect.provide(Alchemy.State.localFs),

@alchemy.run/effect-example/src/api.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ import * as Effect from "effect/Effect";
55
import * as S from "effect/Schema";
66
import { Message, Messages } from "./messages.ts";
77

8+
const __ = Lambda.serve("Api", {
9+
fetch: Effect.fn(function* (event) {
10+
const msg = yield* S.validate(Message)(event.body).pipe(
11+
Effect.catchAll(Effect.die),
12+
);
13+
yield* SQS.sendMessage(Messages, msg).pipe(
14+
Effect.catchAll(() => Effect.void),
15+
);
16+
return {
17+
body: JSON.stringify(null),
18+
};
19+
}),
20+
})({
21+
main: import.meta.filename,
22+
bindings: $(SQS.SendMessage(Messages)),
23+
});
24+
825
export class Api extends Lambda.serve("Api", {
926
fetch: Effect.fn(function* (event) {
1027
const msg = yield* S.validate(Message)(event.body).pipe(

@alchemy.run/effect/src/apply.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class PlanStatusReporter extends Context.Tag("PlanStatusReporter")<
3232
}
3333
>() {}
3434

35-
export const apply = <const P extends Plan, Err, Req>(
35+
export const apply = <P extends Plan, Err, Req>(
3636
plan: Effect.Effect<P, Err, Req>,
3737
) =>
3838
plan.pipe(
@@ -66,7 +66,7 @@ export const apply = <const P extends Plan, Err, Req>(
6666
const resourceId = isBindNode(node)
6767
? node.binding.capability.resource.id
6868
: node.resource.id;
69-
const resource = plan[resourceId];
69+
const resource = plan.resources[resourceId];
7070
return !resource
7171
? Effect.dieMessage(`Resource ${resourceId} not found`)
7272
: apply(resource);
@@ -165,8 +165,12 @@ export const apply = <const P extends Plan, Err, Req>(
165165
} else if (node.action === "delete") {
166166
yield* Effect.all(
167167
node.downstream.map((dep) =>
168-
dep in plan
169-
? apply(plan[dep] as P[keyof P])
168+
dep in plan.resources
169+
? apply(
170+
plan.resources[
171+
dep
172+
] as P["resources"][keyof P["resources"]],
173+
)
170174
: Effect.void,
171175
),
172176
);
@@ -228,7 +232,7 @@ export const apply = <const P extends Plan, Err, Req>(
228232

229233
const resources: any = Object.fromEntries(
230234
yield* Effect.all(
231-
Object.entries(plan).map(
235+
Object.entries(plan.resources).map(
232236
Effect.fn(function* ([id, node]) {
233237
return [id, yield* apply(node as P[keyof P])];
234238
}),
@@ -245,17 +249,19 @@ export const apply = <const P extends Plan, Err, Req>(
245249
}),
246250
),
247251
) as Effect.Effect<
248-
{
249-
[id in keyof P]: P[id] extends Delete<Resource> | undefined | never
250-
? never
251-
: Simplify<P[id]["resource"]["attr"]>;
252-
} extends infer O
253-
? O extends {
254-
[key: string]: never;
255-
}
256-
? undefined
257-
: O
258-
: never,
252+
"update" extends P["phase"]
253+
?
254+
| {
255+
[id in keyof P["resources"]]: P["resources"][id] extends
256+
| Delete<Resource>
257+
| undefined
258+
| never
259+
? never
260+
: Simplify<P["resources"][id]["resource"]["attr"]>;
261+
}
262+
// union distribution isn't happening, so we gotta add this additional void here just in case
263+
| ("destroy" extends P["phase"] ? void : never)
264+
: void,
259265
Err | PlanRejected,
260266
Req
261267
>;

0 commit comments

Comments
 (0)