effect-pulumi 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ Mixing Effect and Pulumi by hand means wrapping every constructor and every
12
12
  data-source call yourself:
13
13
 
14
14
  ```ts
15
+ // InfraError here is a tagged error you have to define yourself
15
16
  const bucket = yield* Effect.try({
16
17
  try: () => new aws.s3.Bucket("assets", { forceDestroy: true }),
17
18
  catch: (cause) => new InfraError({ cause }),
@@ -39,8 +40,9 @@ npm install effect-pulumi
39
40
  ```
40
41
 
41
42
  Requires Node.js ≥ 22 (the floor `@pulumi/pulumi` itself sets). The peer
42
- ranges are `@pulumi/pulumi ^3.0.0` and `effect ^3.0.0`, currently verified
43
- against `@pulumi/pulumi` 3.255 and `effect` 3.22.
43
+ ranges are `@pulumi/pulumi ^3.0.0` and `effect ^4.0.0`. Projects still on
44
+ Effect 3 should stay on `effect-pulumi@0.1.2`, the last release that supports
45
+ it.
44
46
 
45
47
  `@pulumi/pulumi` and `effect` are peer dependencies - this library extends
46
48
  your Pulumi and Effect runtimes, so it must use the same copies you do rather
@@ -52,12 +54,7 @@ npm install @pulumi/pulumi effect
52
54
  ```
53
55
 
54
56
  Ships as dual ESM + CommonJS, so it works from a conventional CJS Pulumi
55
- program as well as an ESM one:
56
-
57
- ```ts
58
- import { effectify } from "effect-pulumi"; // ESM
59
- const { effectify } = require("effect-pulumi"); // CJS
60
- ```
57
+ program as well as an ESM one - both are shown below.
61
58
 
62
59
  ## Quick start
63
60
 
@@ -86,101 +83,34 @@ const program = Effect.gen(function* () {
86
83
  export const { id, key } = await Effect.runPromise(program);
87
84
  ```
88
85
 
89
- Alternatively, hand `program` to the [Automation API](#automation-api) as an
90
- inline program and deploy it from the same process.
91
-
92
- The full version of the program above lives in
93
- [`test/s3-bucket-program.ts`](test/s3-bucket-program.ts). It sits in `test/`
94
- rather than `examples/` because it is a fixture rather than a project you can
95
- run - its only consumer today is the mocked suite in `npm test`, exercising a
96
- real `@pulumi/aws` package under Pulumi's mocks, no cloud account involved.
97
-
98
- For examples you can actually deploy, see [`examples/`](examples/) - those use
99
- `@pulumi/random`, whose resources take no inputs from one another, so they
100
- don't show `fromOutput` or an Effect in an args slot. For that, see
101
- [`test/random-password-file-program.ts`](test/random-password-file-program.ts):
102
- a `RandomPassword`'s Output flows into a `local.File`'s args, and it's
103
- deployed for real by `npm run test:live` - no cloud credentials needed, since
104
- `@pulumi/local` only touches the local filesystem.
105
-
106
- ## What `effectify` does
107
-
108
- `effectify(mod)` returns a lazy, memoized proxy over a provider package:
109
-
110
- | Export kind | Result |
111
- | --- | --- |
112
- | `CustomResource` subclass | `(name, args, opts?) => Effect<R, PulumiError>`, and any **top-level** args field may additionally be an `Effect` (several resolve concurrently) |
113
- | `ComponentResource` subclass | `(name, args, opts?) => Effect<R, PulumiError>`, args passed through verbatim - never auto-lifted |
114
- | Namespace object (`aws.s3`) | Recursively proxied, lazily |
115
- | Invoke function (`aws.s3.getBucket`) | Returns `Effect<R, PulumiError>` instead of `Promise<R>` |
116
- | `*Output` invoke variant (`getBucketOutput`) | Passed through untouched (returns an `Output`, as upstream) |
117
- | Enums, plain values, sync functions | Passed through untouched |
118
-
119
- Semantics worth knowing:
120
-
121
- - **Outputs still work exactly as in vanilla Pulumi.** `effectify` only
122
- *additionally* accepts `Effect`s; passing a bare `Output<T>` or `Input<T>`
123
- needs no unwrapping or rewrapping.
124
- - **Statics survive the wrapping.** `eaws.s3.Bucket.get(name, id)` (adopt an
125
- existing resource), `isInstance`, and any other codegen'd static forward to
126
- the original class - `instanceof` works too - so the wrapped package can be
127
- the only import a program needs.
128
- - **Component args are never lifted.** Component args aren't guaranteed to be
129
- `Input<T>`-shaped the way codegen'd `CustomResource` args are - a component
130
- may do synchronous work on a bare primitive in its constructor - so passing
131
- an `Effect` where a component expects a primitive is a type error. See
132
- [Component resources](#component-resources) for the patterns this implies.
133
- - **Invokes start when you call them.** Whether a function is async is only
134
- knowable by calling it, so `eaws.getAmi(args)` fires the invoke immediately
135
- and hands back an Effect that resolves the already-in-flight call - the
136
- failure still lands in the typed error channel, but `Effect.retry` re-awaits
137
- the same call rather than re-invoking. To re-invoke per attempt, defer the
138
- call site: `Effect.suspend(() => eaws.getAmi(args))`.
139
-
140
- Resource registration stays synchronous under the hood; `Effect.try` runs its
141
- thunk immediately. This removes wrapper boilerplate, not Pulumi's execution
142
- model.
143
-
144
- ## Component resources
145
-
146
- Component resources are fully supported as *consumers*: `effectify` detects
147
- any class extending `pulumi.ComponentResource` - your own, or those in a
148
- component-based package like `@pulumi/awsx` - and wraps its constructor into
149
- an Effect factory, exactly like a custom resource. Construction errors land
150
- in the typed error channel, and the component sequences with `yield*` like
151
- everything else.
152
-
153
- What differs is the args object. Custom resource args can carry `Effect`
154
- fields because codegen guarantees they are all `Input<T>`-shaped, so
155
- substituting a resolved value is always legal. Component args are
156
- hand-authored: a component may take a bare `replicas: number` and do
157
- synchronous arithmetic on it inside its constructor, and an `Effect` silently
158
- swapped in there would break it. So for components, `effectify` refuses at
159
- the type level instead of guessing - resolve your Effects first, then
160
- construct with plain values:
86
+ Pulumi's TypeScript programs default to CommonJS (no `"type": "module"` in
87
+ `package.json`), where top-level `await` isn't available. `fromOutput` and
88
+ `fromOutputs` resolve an Output's real value, which is genuinely
89
+ asynchronous, so `Effect.runSync` isn't an option here either - it throws on
90
+ any effect that suspends on real async work. Export the Promise itself
91
+ instead; Pulumi's engine awaits an exported Promise the same way it would an
92
+ awaited value. The `program` itself is unchanged - only the first and last
93
+ lines differ:
161
94
 
162
95
  ```ts
163
- const eawsx = effectify(awsx);
96
+ const aws = require("@pulumi/aws");
97
+ const { Effect } = require("effect");
98
+ const { effectify, fromOutput, fromOutputs } = require("effect-pulumi");
164
99
 
165
- const program = Effect.gen(function* () {
166
- // ✗ type error - component args are never auto-lifted:
167
- // eawsx.ecs.Cluster("app", { vpcId: fromOutput(vpc.id) })
100
+ // ... same program ...
168
101
 
169
- // ✓ resolve first, then pass a plain value (or just pass the Output -
170
- // Input<T>-typed component args accept those as in vanilla Pulumi):
171
- const vpcId = yield* fromOutput(vpc.id);
172
- const cluster = yield* eawsx.ecs.Cluster("app", { vpcId });
173
- });
102
+ module.exports = Effect.runPromise(program);
174
103
  ```
175
104
 
176
- *Authoring* a component is different: a `ComponentResource` constructor is
177
- synchronous, so you cannot `yield*` inside it. Write a component's internals
178
- in plain Pulumi - its children are ordinary constructor calls - and use
179
- `effectify` at the program level, where composition actually happens. The
180
- wrapped and unwrapped worlds interoperate freely: a component built from raw
181
- Pulumi children can itself be constructed through an effectified package,
182
- and its `Output` properties flow into `fromOutput`/`fromOutputs` like any
183
- other resource's.
105
+ `Effect.runSync` does work for programs that never resolve an Output's value
106
+ - e.g. [`examples/random-pet`](examples/random-pet), which exports raw
107
+ `Output`s from resource properties directly rather than reading through them
108
+ with `fromOutput`.
109
+
110
+ Alternatively, hand `program` to the [Automation API](#automation-api) as an
111
+ inline program and deploy it from the same process.
112
+
113
+ For projects you can deploy as-is, see [`examples/`](examples/).
184
114
 
185
115
  ## Automation API
186
116
 
@@ -191,93 +121,33 @@ re-selecting.
191
121
  ```ts
192
122
  const exit = await Effect.runPromiseExit(
193
123
  deploy({
194
- stackName,
195
- projectName,
196
- program: inlineProgram("dev"),
124
+ stackName: "dev",
125
+ projectName: "my-infra",
126
+ // a PulumiFn - run the Effect, return its result as the stack outputs
127
+ program: async () => Effect.runPromise(program),
197
128
  up: { onOutput: (out) => process.stdout.write(out) },
198
129
  })
199
130
  );
200
131
  // Exit/Cause instead of a thrown, stringified error
201
132
  ```
202
133
 
203
- Every operation forwards the matching Pulumi options type - `UpOptions`,
204
- `PreviewOptions`, `RefreshOptions`, `DestroyOptions`, `RemoveOptions`. Pass
134
+ Stacks are either inline programs (`projectName` + `program`, above) or an
135
+ existing project on disk (`workDir`), and every operation forwards the
136
+ matching Pulumi options type - `UpOptions`, `DestroyOptions`, and so on. Pass
205
137
  `onOutput` to stream the CLI's progress; without it a multi-minute deploy
206
- prints nothing until it finishes. Stacks can be inline programs
207
- (`projectName` + `program`) or an existing project on disk (`workDir`).
208
-
209
- Previewing is opt-in via `preview: true` (or a `PreviewOptions` object), and
210
- its result comes back on `DeployResult.preview`. It is off by default because
211
- a preview is a full engine run against the provider, so previewing and then
212
- immediately upping does the work twice - and `up` surfaces the same failures.
213
-
214
- Teardown is two steps. `destroyStack` removes the resources but leaves the
215
- stack registered with the backend; `teardownStack` destroys and then deletes
216
- it, which is what you want for per-run stacks. `RemoveOptions.force` deletes a
217
- stack while leaving its resources alive and billing - it is reachable, but it
218
- orphans them.
138
+ prints nothing until it finishes.
219
139
 
220
- ## Handling failures
140
+ Preview is opt-in via `preview: true` and comes back on
141
+ `DeployResult.preview`. It is off by default because a preview is a full
142
+ engine run, so previewing and then immediately upping does the work twice -
143
+ and `up` surfaces the same failures anyway.
221
144
 
222
- Failures are values with types, not stringified stack traces. `PulumiError`
223
- covers resource construction and Output resolution; `AutomationError` adds
224
- the lifecycle `stage` it came from. Both derive `.message` from the
225
- underlying cause, so they read well even outside Effect.
145
+ Teardown is two steps: `destroyStack` removes the resources but leaves the
146
+ stack registered, while `teardownStack` destroys and then deletes it - the
147
+ latter is what per-run stacks want. Avoid `RemoveOptions.force`, which drops
148
+ a stack while leaving its resources alive and billing with nothing tracking
149
+ them.
226
150
 
227
- ```ts
228
- import { Effect } from "effect";
229
- import { deploy } from "effect-pulumi";
230
-
231
- const guarded = deploy({ stackName, projectName, program }).pipe(
232
- // Transient engine failures during `up` are worth another attempt; a
233
- // failure creating the stack or setting config is not.
234
- Effect.retry({ times: 2, while: (error) => error.stage === "up" }),
235
- Effect.catchTag("AutomationError", (error) =>
236
- Effect.fail(new Error(`deploy failed at ${error.stage}: ${error.message}`))
237
- )
238
- );
239
- ```
151
+ ## License
240
152
 
241
- The same works inside a program: `Effect.catchTag("PulumiError", …)` around a
242
- resource, or `Effect.exit` / `runPromiseExit` at the edge to inspect the full
243
- `Cause`.
244
-
245
- ## API
246
-
247
- | Export | What it does |
248
- | --- | --- |
249
- | `effectify(mod)` | Wrap a provider package (or any namespace) once; see the table above |
250
- | `fromOutput(output)` | `Output<T>` → `Effect<T, PulumiError>`; resolves to the unknown sentinel during `preview` instead of throwing |
251
- | `fromOutputs(record)` | Record of Outputs → `Effect` of the resolved record |
252
- | `deploy(opts)` | Select/create stack → apply config → optional preview → `up`; returns `{ stack, result, preview? }` |
253
- | `createOrSelectStack(opts)` | Inline (`projectName` + `program`) or local (`workDir`) stack |
254
- | `setStackConfig(stack, config)` | Apply a whole config map in one `setAllConfig` round-trip |
255
- | `previewStack(stack, opts?)` | `pulumi preview`, returning the `PreviewResult` |
256
- | `upStack(stack, opts?)` | `pulumi up`, returning the `UpResult` |
257
- | `refreshStack(stack, opts?)` | Re-sync state from the actual cloud resources |
258
- | `stackOutputs(stack)` | Read current outputs without running an update |
259
- | `destroyStack(stack, opts?)` | Destroy resources; the stack stays registered |
260
- | `removeStack(stack, opts?)` | Delete the stack from the backend |
261
- | `teardownStack(stack, opts?)` | Destroy then remove - skips the remove if the destroy failed |
262
- | `PulumiError` | Construction / Output-resolution failure; carries `cause` |
263
- | `AutomationError` | Lifecycle failure; carries `stage` and `cause` |
264
-
265
- ## Scripts
266
-
267
- | Command | What it does |
268
- | --- | --- |
269
- | `npm run build` | Build dual ESM + CJS (`tsup`) with declarations to `dist/` |
270
- | `npm run typecheck` | Type-check everything, including tests and examples |
271
- | `npm run lint` | Lint with `oxlint` (`npm run lint:fix` applies safe fixes) |
272
- | `npm run format` | Format with `oxfmt` (`npm run format:check` asserts instead) |
273
- | `npm run check` | `typecheck` + `lint` + `format:check`, the pre-commit sweep |
274
- | `npm test` | Unit + mocked-provider tests. No credentials needed |
275
- | `npm run test:package` | Builds, packs a tarball and consumes it from ESM and CJS projects |
276
- | `npm run test:live` | Deploys `test/s3-bucket-program.ts` against real AWS, then destroys it |
277
-
278
- `npm test` never runs the live harness: it's excluded in `vitest.config.ts`
279
- and additionally gated on `EFFECT_PULUMI_RUN_LIVE_TESTS=1`. The live harness
280
- picks up credentials from the environment the normal way each provider expects
281
- (e.g. `AWS_PROFILE` / `AWS_ACCESS_KEY_ID`), and uses
282
- `Effect.acquireRelease` + `Effect.scoped` so the stack is destroyed even when
283
- an assertion fails.
153
+ MIT - see [LICENSE](LICENSE).