Skip to content

Commit fa8b893

Browse files
authored
feat(core): Inputs and Outputs (#19)
1 parent dbfbe40 commit fa8b893

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+4031
-664
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,6 @@ smoke
2929
*.tgz
3030
*.js
3131
*.d.ts
32-
*.map
32+
*.map
33+
34+
.attest

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
2-
"typescript.experimental.useTsgo": false,
2+
"typescript.experimental.useTsgo": true,
33
"typescript.tsdk": "node_modules/typescript/lib",
44
"typescript.enablePromptUseWorkspaceTsdk": true,
5+
"typescript.preferences.importModuleSpecifier": "relative",
6+
"typescript.preferences.importModuleSpecifierEnding": "js",
57
"oxc.fmt.experimental": true,
68
"oxc.enable": true,
79
"editor.defaultFormatter": "oxc.oxc-vscode",

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@ Conduct each engagement with the user as follows:
1515
- Creating a Client for an AWS scope (see [aws/lambda/client.ts](alchemy-effect/src/aws/lambda/client.ts) and [aws/sqs/client.ts](alchemy-effect/src/aws/sqs/client.ts))
1616
- Compiling the AWS.live layer (see [aws/index.ts](alchemy-effect/src/aws/index.ts))
1717
- Using resources (see [example/src/api.ts](example/src/api.ts) and [example/src/consumer.ts](example/src/consumer.ts))
18-
3. When implementing a Resource Provider, make sure to read the [itty-aws](./alchemy-effect/node_modules/itty-aws/dist/services/*/types.d.ts) type defnitions for that service and come up with a plan for which errors to retry, which to consider fatal, and design the overall create, update, delete flow for each of the resource lifecycle handlers. We are using effect, so we gain the benefit of type-safe errors, delcarative retry behavior (declarative flow control).
18+
3. When implementing a Resource Provider, make sure to read the [itty-aws](./alchemy-effect/node_modules/itty-aws/dist/services/*/types.d.ts) type defnitions for that service and come up with a plan for which errors to retry, which to consider fatal, and design the overall create, update, delete flow for each of the resource lifecycle handlers. We are using effect, so we gain the benefit of type-safe errors, delcarative retry behavior (declarative flow control).
19+
20+
Restrictions:
21+
1. Never use `Effect.catchAll`, always use `Effect.catchTag` or `Effect.catchTags`
22+
1. Always use `bun` (never npm, pnpm, yarn, etc.)

alchemy-effect/package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,21 @@
2323
"build": "bun bundle && bun pm pack",
2424
"bundle": "tsdown",
2525
"bundle:watch": "tsdown --watch",
26-
"publish:npm": "cp ../README.md . && bun publish && rm README.md"
26+
"publish:npm": "cp ../README.md . && bun publish && rm README.md",
27+
"test": "vitest run",
28+
"test:benchmark": "tsx --tsconfig tsconfig.test.json test/types.benchmark.ts"
2729
},
2830
"exports": {
2931
".": {
3032
"bun": "./src/index.ts",
3133
"import": "./lib/index.js",
3234
"types": "./src/index.d.ts"
3335
},
36+
"./Output": {
37+
"bun": "./src/output.ts",
38+
"import": "./lib/output.js",
39+
"types": "./lib/output.d.ts"
40+
},
3441
"./aws": {
3542
"bun": "./src/aws/index.ts",
3643
"import": "./lib/aws/index.js",
@@ -128,6 +135,7 @@
128135
"@cloudflare/workers-types": "catalog:",
129136
"@effect/platform": "catalog:",
130137
"@effect/platform-node": "catalog:",
138+
"@effect/typeclass": "catalog:",
131139
"@types/aws-lambda": "catalog:",
132140
"@types/bun": "catalog:",
133141
"@types/node": "catalog:",
@@ -137,7 +145,9 @@
137145
"react": "^19.2.0",
138146
"react-devtools-core": "^7.0.1",
139147
"tsconfig-paths": "^4.2.0",
140-
"tsdown": "^0.15.4"
148+
"tsdown": "^0.15.4",
149+
"tsx": "^4.20.6",
150+
"typescript": "catalog:"
141151
},
142152
"peerDependencies": {
143153
"@effect/platform": "*",

alchemy-effect/src/$.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type Instance, Policy } from "./policy.ts";
2+
import { isResource } from "./resource.ts";
3+
import * as Output from "./output.ts";
4+
5+
export type $ = typeof Policy & typeof Output.interpolate & typeof Output.of;
6+
export const $ = ((...args: any[]) =>
7+
Array.isArray(args[0])
8+
? Output.interpolate(
9+
args[0] as unknown as TemplateStringsArray,
10+
...args.slice(1),
11+
)
12+
: isResource(args[0])
13+
? Output.of(args[0])
14+
: Policy(...args)) as $;

alchemy-effect/src/apply.ts

Lines changed: 104 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@ import type { Simplify } from "effect/Types";
55
import { PlanReviewer, type PlanRejected } from "./approve.ts";
66
import type { AnyBinding, BindingService } from "./binding.ts";
77
import type { ApplyEvent, ApplyStatus } from "./event.ts";
8+
import * as Output from "./output.ts";
89
import {
910
plan,
1011
type BindNode,
1112
type Create,
1213
type CRUD,
1314
type Delete,
15+
type IPlan,
1416
type Plan,
1517
type Update,
18+
type DerivePlan,
19+
type BindingTags,
1620
} from "./plan.ts";
17-
import type { Resource } from "./resource.ts";
18-
import type { Service } from "./service.ts";
21+
import type { Instance } from "./policy.ts";
22+
import type { AnyResource, Resource } from "./resource.ts";
23+
import type { AnyService, Service } from "./service.ts";
1924
import { State } from "./state.ts";
2025

2126
export interface PlanStatusSession {
@@ -30,40 +35,45 @@ export interface ScopedPlanStatusSession extends PlanStatusSession {
3035
export class PlanStatusReporter extends Context.Tag("PlanStatusReporter")<
3136
PlanStatusReporter,
3237
{
33-
start(plan: Plan): Effect.Effect<PlanStatusSession, never>;
38+
start(plan: IPlan): Effect.Effect<PlanStatusSession, never>;
3439
}
3540
>() {}
3641

37-
export const apply: typeof applyPlan &
38-
typeof applyResources &
39-
typeof applyResourcesPhase = (...args: any[]): any =>
40-
Effect.isEffect(args[0])
41-
? applyPlan(args[0] as any)
42-
: args.length === 1 && "phase" in args[0]
43-
? applyResourcesPhase(args[0])
44-
: applyResources(...args);
45-
46-
export const applyResourcesPhase = <
47-
const Phase extends "update" | "destroy",
48-
const Resources extends (Service | Resource)[],
49-
>(props: {
50-
resources: Resources;
51-
phase: Phase;
52-
}) => applyPlan(plan(props));
53-
54-
export const applyResources = <const Resources extends (Service | Resource)[]>(
42+
export type ApplyEffect<
43+
P extends IPlan,
44+
Err = never,
45+
Req = never,
46+
> = Effect.Effect<
47+
{
48+
[k in keyof AppliedPlan<P>]: AppliedPlan<P>[k];
49+
},
50+
Err | PlanRejected,
51+
Req
52+
>;
53+
54+
export type AppliedPlan<P extends IPlan> = {
55+
[id in keyof P["resources"]]: P["resources"][id] extends
56+
| Delete<Resource>
57+
| undefined
58+
| never
59+
? never
60+
: Simplify<P["resources"][id]["resource"]["attr"]>;
61+
};
62+
63+
export const apply = <
64+
const Resources extends (AnyService | AnyResource)[] = never,
65+
>(
5566
...resources: Resources
56-
) =>
57-
applyPlan(
58-
plan({
59-
phase: "update",
60-
resources,
61-
}),
62-
);
63-
64-
export const applyPlan = <P extends Plan, Err, Req>(
67+
): ApplyEffect<
68+
DerivePlan<Instance<Resources[number]>>,
69+
never,
70+
State | BindingTags<Instance<Resources[number]>>
71+
// TODO(sam): don't cast to any
72+
> => applyPlan(plan(...resources)) as any;
73+
74+
export const applyPlan = <P extends IPlan, Err = never, Req = never>(
6575
plan: Effect.Effect<P, Err, Req>,
66-
) =>
76+
): ApplyEffect<P, Err, Req> =>
6777
plan.pipe(
6878
Effect.flatMap((plan) =>
6979
Effect.gen(function* () {
@@ -90,6 +100,18 @@ export const applyPlan = <P extends Plan, Err, Req>(
90100
): Effect.Effect<T, Err, Req> =>
91101
Effect.isEffect(effect) ? effect : Effect.succeed(effect);
92102

103+
const resolveUpstream = Effect.fn(function* (resourceId: string) {
104+
const upstreamNode = plan.resources[resourceId];
105+
const upstreamAttr = upstreamNode
106+
? yield* apply(upstreamNode)
107+
: yield* Effect.dieMessage(`Resource ${resourceId} not found`);
108+
return {
109+
resourceId,
110+
upstreamAttr,
111+
upstreamNode,
112+
};
113+
});
114+
93115
const resolveBindingUpstream = Effect.fn(function* ({
94116
node,
95117
resource,
@@ -104,10 +126,8 @@ export const applyPlan = <P extends Plan, Err, Req>(
104126
const provider = yield* binding.Tag;
105127

106128
const resourceId: string = node.binding.capability.resource.id;
107-
const upstreamNode = plan.resources[resourceId];
108-
const upstreamAttr = resource
109-
? yield* apply(upstreamNode)
110-
: yield* Effect.dieMessage(`Resource ${resourceId} not found`);
129+
const { upstreamAttr, upstreamNode } =
130+
yield* resolveUpstream(resourceId);
111131

112132
return {
113133
resourceId,
@@ -221,23 +241,21 @@ export const applyPlan = <P extends Plan, Err, Req>(
221241
node,
222242
) =>
223243
Effect.gen(function* () {
224-
const checkpoint = <Out, Err>(
225-
effect: Effect.Effect<Out, Err, never>,
226-
) => effect.pipe(Effect.flatMap((output) => saveState({ output })));
227-
228244
const saveState = <Output>({
229245
output,
230246
bindings = node.bindings,
247+
news,
231248
}: {
232249
output: Output;
233250
bindings?: BindNode[];
251+
news: any;
234252
}) =>
235253
state
236254
.set(node.resource.id, {
237255
id: node.resource.id,
238256
type: node.resource.type,
239257
status: node.action === "create" ? "created" : "updated",
240-
props: node.resource.props,
258+
props: news,
241259
output,
242260
bindings,
243261
})
@@ -271,18 +289,33 @@ export const applyPlan = <P extends Plan, Err, Req>(
271289
attr,
272290
phase,
273291
}: {
274-
node: Create<Resource> | Update<Resource>;
292+
node: Create | Update;
275293
attr: any;
276294
phase: "create" | "update";
277295
}) {
296+
const upstream = Object.fromEntries(
297+
yield* Effect.all(
298+
Object.entries(Output.resolveUpstream(node.news)).map(
299+
([id, resource]) =>
300+
resolveUpstream(id).pipe(
301+
Effect.map(({ upstreamAttr }) => [
302+
id,
303+
upstreamAttr,
304+
]),
305+
),
306+
),
307+
),
308+
);
309+
const news = yield* Output.evaluate(node.news, upstream);
310+
278311
yield* report(phase === "create" ? "creating" : "updating");
279312

280313
let bindingOutputs = yield* attachBindings({
281314
resource,
282315
bindings: node.bindings,
283316
target: {
284317
id,
285-
props: node.news,
318+
props: news,
286319
attr,
287320
},
288321
});
@@ -293,7 +326,7 @@ export const applyPlan = <P extends Plan, Err, Req>(
293326
: node.provider.update
294327
)({
295328
id,
296-
news: node.news,
329+
news,
297330
bindings: bindingOutputs,
298331
session: scopedSession,
299332
...(node.action === "update"
@@ -316,12 +349,13 @@ export const applyPlan = <P extends Plan, Err, Req>(
316349
bindingOutputs,
317350
target: {
318351
id,
319-
props: node.news,
352+
props: news,
320353
attr,
321354
},
322355
});
323356

324357
yield* saveState({
358+
news,
325359
output,
326360
bindings: node.bindings.map((binding, i) => ({
327361
...binding,
@@ -364,11 +398,7 @@ export const applyPlan = <P extends Plan, Err, Req>(
364398
yield* Effect.all(
365399
node.downstream.map((dep) =>
366400
dep in plan.resources
367-
? apply(
368-
plan.resources[
369-
dep
370-
] as P["resources"][keyof P["resources"]],
371-
)
401+
? apply(plan.resources[dep] as any)
372402
: Effect.void,
373403
),
374404
);
@@ -399,29 +429,30 @@ export const applyPlan = <P extends Plan, Err, Req>(
399429
});
400430
const create = Effect.gen(function* () {
401431
yield* report("creating");
402-
return yield* (
403-
node.provider
404-
.create({
405-
id,
406-
news: node.news,
407-
// TODO(sam): these need to only include attach actions
408-
bindings: yield* attachBindings({
409-
resource,
410-
bindings: node.bindings,
411-
target: {
412-
id,
413-
props: node.news,
414-
attr: node.attributes,
415-
},
416-
}),
417-
session: scopedSession,
418-
})
419-
// TODO(sam): delete and create will conflict here, we need to extend the state store for replace
420-
.pipe(
421-
checkpoint,
422-
Effect.tap(() => report("created")),
423-
)
424-
);
432+
433+
// TODO(sam): delete and create will conflict here, we need to extend the state store for replace
434+
return yield* node.provider
435+
.create({
436+
id,
437+
news: node.news,
438+
// TODO(sam): these need to only include attach actions
439+
bindings: yield* attachBindings({
440+
resource,
441+
bindings: node.bindings,
442+
target: {
443+
id,
444+
// TODO(sam): resolve the news
445+
props: node.news,
446+
attr: node.attributes,
447+
},
448+
}),
449+
session: scopedSession,
450+
})
451+
.pipe(
452+
Effect.tap((output) =>
453+
saveState({ news: node.news, output }),
454+
),
455+
);
425456
});
426457
if (!node.deleteFirst) {
427458
yield* destroy;
@@ -457,20 +488,4 @@ export const applyPlan = <P extends Plan, Err, Req>(
457488
return resources;
458489
}),
459490
),
460-
) as Effect.Effect<
461-
"update" extends P["phase"]
462-
?
463-
| {
464-
[id in keyof P["resources"]]: P["resources"][id] extends
465-
| Delete<Resource>
466-
| undefined
467-
| never
468-
? never
469-
: Simplify<P["resources"][id]["resource"]["attr"]>;
470-
}
471-
// union distribution isn't happening, so we gotta add this additional void here just in case
472-
| ("destroy" extends P["phase"] ? void : never)
473-
: void,
474-
Err | PlanRejected,
475-
Req
476-
>;
491+
) as ApplyEffect<P>;

0 commit comments

Comments
 (0)