Skip to content

Commit 9a3193c

Browse files
committed
feat: adopt currying pattern across codebase to deal with NoInfer and Extract limitations
1 parent 47aef06 commit 9a3193c

File tree

12 files changed

+143
-171
lines changed

12 files changed

+143
-171
lines changed

@alchemy.run/effect-aws/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"@clack/prompts": "^0.11.0",
6161
"@rspack/core": "^1.5.7",
6262
"@smithy/node-config-provider": "^4.0.0",
63-
"@types/aws-lambda": "^8.10.152",
63+
"@types/aws-lambda": "catalog:",
6464
"aws4fetch": "^1.0.20",
6565
"effect": "^3.17.14",
6666
"fast-xml-parser": "^5.2.5",
Lines changed: 45 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Policy, type Capability } from "@alchemy.run/effect";
1+
import { $, Policy } from "@alchemy.run/effect";
22
import type {
33
Context as LambdaContext,
44
SQSBatchResponse,
@@ -9,58 +9,48 @@ import * as S from "effect/Schema";
99
import * as SQS from "../sqs/index.ts";
1010
import * as Lambda from "./function.ts";
1111

12-
export interface ConsumeProps<Q extends SQS.Queue, Req>
13-
extends Lambda.FunctionProps {
14-
queue: Q;
15-
bindings: Policy<Extract<Req, Capability>>;
16-
}
17-
18-
export function consume<Q extends SQS.Queue, ID extends string, Req>(
19-
id: ID,
20-
props: ConsumeProps<Q, Req>,
21-
handle: (
22-
event: SQS.QueueEvent<Q["props"]["schema"]["Type"]>,
23-
context: LambdaContext,
24-
) => Effect.Effect<SQSBatchResponse | void, never, Req>,
25-
) {
26-
const { queue, bindings } = props;
27-
const schema = queue.props.schema;
28-
return Lambda.Function(
29-
id,
30-
{
31-
...props,
32-
bindings: bindings.and(SQS.Consume(queue)),
12+
export const consume =
13+
<Q extends SQS.Queue, ID extends string, Req>(
14+
id: ID,
15+
{ queue, handle }: {
16+
queue: Q;
17+
handle: (
18+
event: SQS.QueueEvent<Q["props"]["schema"]["Type"]>,
19+
context: LambdaContext,
20+
) => Effect.Effect<SQSBatchResponse | void, never, Req>;
3321
},
34-
Effect.fn(function* (event: SQSEvent, context: LambdaContext) {
35-
yield* Policy.declare<SQS.Consume<Q>>();
36-
const records = yield* Effect.all(
37-
event.Records.map(
38-
Effect.fn(function* (record) {
39-
return {
40-
...record,
41-
body: yield* S.validate(schema)(record.body).pipe(
42-
Effect.catchAll(() => Effect.void),
43-
),
44-
};
45-
}),
46-
),
47-
);
48-
const response = yield* handle(
49-
{
50-
Records: records.filter((record) => record.body !== undefined),
51-
},
52-
context,
53-
);
54-
return {
55-
batchItemFailures: [
56-
...(response?.batchItemFailures ?? []),
57-
...records
58-
.filter((record) => record.body === undefined)
59-
.map((failed) => ({
60-
itemIdentifier: failed.messageId,
61-
})),
62-
],
63-
} satisfies SQSBatchResponse;
64-
}),
65-
);
66-
}
22+
) =>
23+
<const Props extends Lambda.FunctionProps<Req>>(props: Props) =>
24+
Lambda.Function(id, {
25+
handle: Effect.fn(function* (event: SQSEvent, context: LambdaContext) {
26+
yield* Policy.declare<SQS.Consume<Q>>();
27+
const records = yield* Effect.all(
28+
event.Records.map(
29+
Effect.fn(function* (record) {
30+
return {
31+
...record,
32+
body: yield* S.validate(queue.props.schema)(record.body).pipe(
33+
Effect.catchAll(() => Effect.void),
34+
),
35+
};
36+
}),
37+
),
38+
);
39+
const response = yield* handle(
40+
{
41+
Records: records.filter((record) => record.body !== undefined),
42+
},
43+
context,
44+
);
45+
return {
46+
batchItemFailures: [
47+
...(response?.batchItemFailures ?? []),
48+
...records
49+
.filter((record) => record.body === undefined)
50+
.map((failed) => ({
51+
itemIdentifier: failed.messageId,
52+
})),
53+
],
54+
} satisfies SQSBatchResponse;
55+
}),
56+
})({ ...props, bindings: $(SQS.Consume(queue)) });

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

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import * as IAM from "../iam.ts";
1313
export const FunctionType = "AWS.Lambda.Function";
1414
export type FunctionType = typeof FunctionType;
1515

16-
export type FunctionProps = {
16+
export type FunctionProps<Req = any> = {
1717
main: string;
1818
handler?: string;
1919
memory?: number;
2020
runtime?: "nodejs20x" | "nodejs22x";
2121
architecture?: "x86_64" | "arm64";
2222
url?: boolean;
23+
bindings: Policy<Extract<Req, Capability>>;
2324
};
2425

2526
export type FunctionAttr<Props extends FunctionProps = FunctionProps> = {
@@ -49,24 +50,19 @@ export interface FunctionRuntime<
4950
}
5051
export const FunctionRuntime = Runtime(FunctionType)<FunctionRuntime>();
5152

52-
export const Function = <
53-
const ID extends string,
54-
const Props extends FunctionProps,
55-
In,
56-
Out,
57-
Req,
58-
>(
59-
id: ID,
60-
props: Props & {
61-
bindings: Policy<Extract<Req, Capability>>;
62-
},
63-
handle: (input: In, context: LambdaContext) => Effect<Out, never, Req>,
64-
) =>
65-
Alchemy.bind(
66-
FunctionRuntime,
67-
Alchemy.Service(id, handle, props.bindings),
68-
props,
69-
);
53+
export const Function =
54+
<const ID extends string, In, Out, Req>(
55+
id: ID,
56+
{ handle }: {
57+
handle: (input: In, context: LambdaContext) => Effect<Out, never, Req>;
58+
},
59+
) =>
60+
<const Props extends FunctionProps<Req>>(props: Props) =>
61+
Alchemy.bind(
62+
FunctionRuntime,
63+
Alchemy.Service(id, handle, props.bindings),
64+
props,
65+
);
7066

7167
export type FunctionProvider = Provider<
7268
FunctionRuntime<unknown, unknown, FunctionProps>
Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { Capability, Policy } from "@alchemy.run/effect";
21
import type {
32
Context as LambdaContext,
43
LambdaFunctionURLEvent,
@@ -7,25 +6,15 @@ import type {
76
import * as Effect from "effect/Effect";
87
import * as Lambda from "./function.ts";
98

10-
export const serve = <
11-
const ID extends string,
12-
const Props extends Lambda.FunctionProps,
13-
Req,
14-
>(
15-
id: ID,
16-
props: Props & {
17-
bindings: Policy<Extract<Req, Capability>>;
18-
},
19-
fetch: (
20-
event: LambdaFunctionURLEvent,
21-
context: LambdaContext,
22-
) => Effect.Effect<LambdaFunctionURLResult, never, Req>,
23-
) =>
24-
Lambda.Function(
25-
id,
26-
{
27-
...props,
28-
url: true,
9+
export const serve =
10+
<const ID extends string, Req>(
11+
id: ID,
12+
{ fetch }: {
13+
fetch: (
14+
event: LambdaFunctionURLEvent,
15+
context: LambdaContext,
16+
) => Effect.Effect<LambdaFunctionURLResult, never, Req>;
2917
},
30-
fetch,
31-
);
18+
) =>
19+
<const Props extends Lambda.FunctionProps<Req>>(props: Props) =>
20+
Lambda.Function(id, { handle: fetch })({ ...props, url: true });

@alchemy.run/effect-example/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
"@alchemy.run/effect": "workspace:*",
2323
"@effect/platform-node": "catalog:",
2424
"@effect/platform": "catalog:",
25-
"effect": "catalog:"
25+
"effect": "catalog:",
26+
"@types/aws-lambda": "catalog:"
27+
},
28+
"devDependencies": {
29+
"@types/bun": "^1.3.1",
30+
"i": "^0.3.7"
2631
}
2732
}

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

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

8-
// Biz Logic (isolated) easy to test, portable, decoupled from physical infrastructure
9-
export class Api extends Lambda.serve(
10-
"Api",
11-
{
12-
main: import.meta.filename,
13-
bindings: $(SQS.SendMessage(Messages)),
14-
// TODO(sam): wish it could be this, but inference seems to fail without .fn wrapper
15-
// *fetch(req) { .. }
16-
},
17-
Effect.fn(function* (event, _ctx) {
18-
// _ctx.getRemainingTimeInMillis()
8+
export class Api extends Lambda.serve("Api", {
9+
fetch: Effect.fn(function* (event) {
1910
const msg = yield* S.validate(Message)(event.body).pipe(
2011
Effect.catchAll(Effect.die),
2112
);
@@ -26,7 +17,10 @@ export class Api extends Lambda.serve(
2617
body: JSON.stringify(null),
2718
};
2819
}),
29-
) {}
20+
})({
21+
main: import.meta.filename,
22+
bindings: $(SQS.SendMessage(Messages)),
23+
}) {}
3024

3125
// coupled to physical infrastructure (actual SQS client)
3226
export default Api.pipe(Effect.provide(SQS.clientFromEnv()), Lambda.toHandler);

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

Lines changed: 31 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,19 @@ const MonitorSimple = <
2828
schema: Message,
2929
}) {}
3030

31-
return Lambda.Function(
32-
id,
33-
{
34-
main: import.meta.filename,
35-
bindings: props.bindings.and(SQS.SendMessage(Messages)),
36-
},
37-
Effect.fn(function* (event, context) {
31+
return Lambda.Function(id, {
32+
handle: Effect.fn(function* (event, context) {
3833
yield* SQS.sendMessage(Messages, {
3934
id: 1,
4035
value: "1",
4136
}).pipe(Effect.catchAll(() => Effect.void));
4237

4338
return yield* onAlarm(event);
4439
}),
45-
);
40+
})({
41+
main: import.meta.filename,
42+
bindings: props.bindings.and(SQS.SendMessage(Messages)),
43+
});
4644
};
4745

4846
export interface MonitorComplexProps<ReqAlarm, ReqResolved>
@@ -71,39 +69,31 @@ const MonitorComplex = <const ID extends string, ReqAlarm, ReqResolved>(
7169
schema: Message,
7270
}) {}
7371

74-
return {
75-
// The only way we can allow two functions (onAlarm and onResolved) to be passed in
76-
// and properly infer the policy is to curry, or else the Policy<T> types always distribute
77-
// e.g. Policy<ReqAlarm> | Policy<ReqResolved> instead of Policy<ReqAlarm | ReqResolved>
78-
// TODO(sam): this is ugly AF
79-
make: ({
72+
return ({
73+
main,
74+
bindings,
75+
}: {
76+
main: string;
77+
bindings: Policy<Extract<ReqAlarm | ReqResolved, Capability>>;
78+
}) =>
79+
Lambda.consume(id, {
80+
queue: Messages,
81+
handle: Effect.fn(function* (batch) {
82+
yield* SQS.sendMessage(Messages, {
83+
id: 1,
84+
value: "1",
85+
}).pipe(Effect.catchAll(() => Effect.void));
86+
if (props.onAlarm) {
87+
yield* props.onAlarm(batch);
88+
}
89+
if (props.onResolved) {
90+
yield* props.onResolved(batch);
91+
}
92+
}),
93+
})({
8094
main,
81-
bindings,
82-
}: {
83-
main: string;
84-
bindings: Policy<Extract<ReqAlarm | ReqResolved, Capability>>;
85-
}) =>
86-
Lambda.consume(
87-
id,
88-
{
89-
main,
90-
queue: Messages,
91-
bindings: bindings.and(SQS.SendMessage(Messages)),
92-
},
93-
Effect.fn(function* (batch) {
94-
yield* SQS.sendMessage(Messages, {
95-
id: 1,
96-
value: "1",
97-
}).pipe(Effect.catchAll(() => Effect.void));
98-
if (props.onAlarm) {
99-
yield* props.onAlarm(batch);
100-
}
101-
if (props.onResolved) {
102-
yield* props.onResolved(batch);
103-
}
104-
}),
105-
),
106-
};
95+
bindings: bindings.and(SQS.SendMessage(Messages)),
96+
});
10797
};
10898

10999
// src/my-api.ts
@@ -140,7 +130,7 @@ export class MyMonitor extends MonitorComplex("MyMonitor", {
140130
);
141131
}
142132
}),
143-
}).make({
133+
})({
144134
main: import.meta.filename,
145135
bindings: $(SQS.SendMessage(Outer)),
146136
}) {}

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,9 @@ import * as Effect from "effect/Effect";
55
import { Messages } from "./messages.ts";
66

77
// business logic
8-
export class Consumer extends Lambda.consume(
9-
"Consumer",
10-
{
11-
main: import.meta.filename,
12-
queue: Messages,
13-
bindings: $(SQS.SendMessage(Messages)),
14-
},
15-
Effect.fn(function* (batch) {
8+
export class Consumer extends Lambda.consume("Consumer", {
9+
queue: Messages,
10+
handle: Effect.fn(function* (batch) {
1611
for (const record of batch.Records) {
1712
console.log(record);
1813
yield* SQS.sendMessage(Messages, {
@@ -21,7 +16,11 @@ export class Consumer extends Lambda.consume(
2116
}).pipe(Effect.catchAll(() => Effect.void));
2217
}
2318
}),
24-
) {}
19+
})({
20+
main: import.meta.filename,
21+
bindings: $(SQS.SendMessage(Messages)),
22+
memory: 128,
23+
}) {}
2524

2625
// runtime handler
2726
export default Consumer.pipe(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as SQS from "@alchemy.run/effect-aws/sqs";
22
import * as S from "effect/Schema";
33

4-
export class Message extends S.Struct({
4+
export class Message extends S.Class<Message>("Message")({
55
id: S.Int,
66
value: S.String,
77
}) {}

0 commit comments

Comments
 (0)