effect-pulumi 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/README.md +44 -176
  2. package/package.json +1 -1
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,7 @@ 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 ^3.0.0`.
44
44
 
45
45
  `@pulumi/pulumi` and `effect` are peer dependencies - this library extends
46
46
  your Pulumi and Effect runtimes, so it must use the same copies you do rather
@@ -52,12 +52,7 @@ npm install @pulumi/pulumi effect
52
52
  ```
53
53
 
54
54
  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
- ```
55
+ program as well as an ESM one - both are shown below.
61
56
 
62
57
  ## Quick start
63
58
 
@@ -86,101 +81,34 @@ const program = Effect.gen(function* () {
86
81
  export const { id, key } = await Effect.runPromise(program);
87
82
  ```
88
83
 
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:
84
+ Pulumi's TypeScript programs default to CommonJS (no `"type": "module"` in
85
+ `package.json`), where top-level `await` isn't available. `fromOutput` and
86
+ `fromOutputs` resolve an Output's real value, which is genuinely
87
+ asynchronous, so `Effect.runSync` isn't an option here either - it throws on
88
+ any effect that suspends on real async work. Export the Promise itself
89
+ instead; Pulumi's engine awaits an exported Promise the same way it would an
90
+ awaited value. The `program` itself is unchanged - only the first and last
91
+ lines differ:
161
92
 
162
93
  ```ts
163
- const eawsx = effectify(awsx);
94
+ const aws = require("@pulumi/aws");
95
+ const { Effect } = require("effect");
96
+ const { effectify, fromOutput, fromOutputs } = require("effect-pulumi");
164
97
 
165
- const program = Effect.gen(function* () {
166
- // ✗ type error - component args are never auto-lifted:
167
- // eawsx.ecs.Cluster("app", { vpcId: fromOutput(vpc.id) })
98
+ // ... same program ...
168
99
 
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
- });
100
+ module.exports = Effect.runPromise(program);
174
101
  ```
175
102
 
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.
103
+ `Effect.runSync` does work for programs that never resolve an Output's value
104
+ - e.g. [`examples/random-pet`](examples/random-pet), which exports raw
105
+ `Output`s from resource properties directly rather than reading through them
106
+ with `fromOutput`.
107
+
108
+ Alternatively, hand `program` to the [Automation API](#automation-api) as an
109
+ inline program and deploy it from the same process.
110
+
111
+ For projects you can deploy as-is, see [`examples/`](examples/).
184
112
 
185
113
  ## Automation API
186
114
 
@@ -191,93 +119,33 @@ re-selecting.
191
119
  ```ts
192
120
  const exit = await Effect.runPromiseExit(
193
121
  deploy({
194
- stackName,
195
- projectName,
196
- program: inlineProgram("dev"),
122
+ stackName: "dev",
123
+ projectName: "my-infra",
124
+ // a PulumiFn - run the Effect, return its result as the stack outputs
125
+ program: async () => Effect.runPromise(program),
197
126
  up: { onOutput: (out) => process.stdout.write(out) },
198
127
  })
199
128
  );
200
129
  // Exit/Cause instead of a thrown, stringified error
201
130
  ```
202
131
 
203
- Every operation forwards the matching Pulumi options type - `UpOptions`,
204
- `PreviewOptions`, `RefreshOptions`, `DestroyOptions`, `RemoveOptions`. Pass
132
+ Stacks are either inline programs (`projectName` + `program`, above) or an
133
+ existing project on disk (`workDir`), and every operation forwards the
134
+ matching Pulumi options type - `UpOptions`, `DestroyOptions`, and so on. Pass
205
135
  `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.
136
+ prints nothing until it finishes.
219
137
 
220
- ## Handling failures
138
+ Preview is opt-in via `preview: true` and comes back on
139
+ `DeployResult.preview`. It is off by default because a preview is a full
140
+ engine run, so previewing and then immediately upping does the work twice -
141
+ and `up` surfaces the same failures anyway.
221
142
 
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.
143
+ Teardown is two steps: `destroyStack` removes the resources but leaves the
144
+ stack registered, while `teardownStack` destroys and then deletes it - the
145
+ latter is what per-run stacks want. Avoid `RemoveOptions.force`, which drops
146
+ a stack while leaving its resources alive and billing with nothing tracking
147
+ them.
226
148
 
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
- ```
149
+ ## License
240
150
 
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.
151
+ MIT - see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-pulumi",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Effect bindings for Pulumi - wrap a provider package once and get Effect-returning resource factories, typed errors, and structured deploy results.",
5
5
  "keywords": [
6
6
  "effect",