| 1 | --- |
| 2 | title: "Memoization: builds & one-off commands" |
| 3 | description: Content-hash memoization shared by Command.Build and Command.Exec — what gets hashed, when a command re-runs, and the outdir contract on destroy. |
| 4 | --- |
| 5 | |
| 6 | `Command.Build` and `Command.Exec` content-hash their inputs so a |
| 7 | deploy skips any build or one-off command whose inputs haven't |
| 8 | changed — an unchanged frontend isn't rebuilt, unchanged schema |
| 9 | files don't re-run codegen. Both resources share the same memo |
| 10 | semantics, covered here. For what the Command provider is and how |
| 11 | it's registered, start at the [Command hub](/command). |
| 12 | |
| 13 | ## A memoized build |
| 14 | |
| 15 | `Command.Build` runs a command that must produce an output path and |
| 16 | tracks it in state: |
| 17 | |
| 18 | ```typescript |
| 19 | import * as Command from "alchemy/Command"; |
| 20 | import * as Console from "effect/Console"; |
| 21 | |
| 22 | const build = yield* Command.Build("vite-build", { |
| 23 | command: "npm run build", |
| 24 | cwd: "./frontend", |
| 25 | outdir: "dist", |
| 26 | }); |
| 27 | yield* Console.log(build.outdir); // path to the dist directory, relative to process.cwd() |
| 28 | yield* Console.log(build.hash.output); // hash of the output files (when memo is enabled) |
| 29 | ``` |
| 30 | |
| 31 | Reconcile runs the command, then verifies it actually produced |
| 32 | `outdir` — if the path doesn't exist afterwards, the deploy fails |
| 33 | with a `CommandError` whose reason is `OutputNotFound`. On success |
| 34 | the resource records two hashes: `hash.input` (the files that |
| 35 | produced the build) and `hash.output` (the files inside `outdir`). |
| 36 | |
| 37 | ## What gets hashed |
| 38 | |
| 39 | By default, every non-gitignored file under `cwd`: `include` |
| 40 | defaults to `["**/*"]`, and `exclude` defaults to the `.gitignore` |
| 41 | rules collected from `cwd` up to the repository root, plus |
| 42 | `**/.git/**`. The nearest package-manager lockfile (`bun.lock`, |
| 43 | `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`, found by |
| 44 | walking upward — so a monorepo-root lockfile counts) is hashed too. |
| 45 | Narrow the scope with explicit globs when the default is too broad: |
| 46 | |
| 47 | ```typescript |
| 48 | yield* Command.Exec("codegen", { |
| 49 | command: "npm run codegen", |
| 50 | memo: { include: ["schema/**"] }, |
| 51 | }); |
| 52 | ``` |
| 53 | |
| 54 | The lockfile default only applies when both `include` and `exclude` |
| 55 | are unset — with custom globs like the above, set `lockfile: true` |
| 56 | explicitly if you still want it in the hash. Setting `memo: false` |
| 57 | disables hashing entirely and re-runs the command on every deploy. |
| 58 | |
| 59 | ## When it re-runs |
| 60 | |
| 61 | Diff rehashes the inputs on every deploy and returns `noop` when |
| 62 | nothing changed, so the command never runs. A change to any prop — |
| 63 | `command`, `cwd`, `env` — forces a re-run even though the file |
| 64 | hashes are unchanged (e.g. a recreated database's connection URL |
| 65 | with identical files: the hash tracks input files only, so the |
| 66 | re-run is driven by the prop change, not the hash). For `Build`, |
| 67 | diff rehashes the output too, so a deleted `outdir` triggers a |
| 68 | rebuild even with unchanged inputs — `rm -rf dist` followed by a |
| 69 | deploy rebuilds instead of silently no-oping. |
| 70 | |
| 71 | ## The outdir contract |
| 72 | |
| 73 | `build.outdir` is stored relative to `process.cwd()`, not absolute, |
| 74 | so state is portable across machines — a CI runner's |
| 75 | `/home/runner/work/.../dist` resolves correctly on a local laptop |
| 76 | and vice versa. Consumers resolve it against their own cwd: |
| 77 | |
| 78 | ```typescript |
| 79 | import * as Path from "effect/Path"; |
| 80 | |
| 81 | const path = yield* Path.Path; |
| 82 | const absolute = path.resolve(build.outdir); |
| 83 | ``` |
| 84 | |
| 85 | On destroy, `Build`'s delete removes the outdir recursively — |
| 86 | tearing down the stack cleans up the build artifacts along with the |
| 87 | state. |
| 88 | |
| 89 | ## One-off commands |
| 90 | |
| 91 | `Command.Exec` runs a command purely for its side effects — codegen, |
| 92 | seeding data, setup steps — and succeeds as long as the exit code is |
| 93 | `0`. It memoizes the same way as `Build`, minus the output half: |
| 94 | only `hash.input` is recorded. |
| 95 | |
| 96 | ```typescript |
| 97 | import * as Redacted from "effect/Redacted"; |
| 98 | |
| 99 | yield* Command.Exec("migrate", { |
| 100 | command: "npm run db:migrate", |
| 101 | env: { |
| 102 | DATABASE_URL: Redacted.make("postgres://..."), |
| 103 | }, |
| 104 | }); |
| 105 | ``` |
| 106 | |
| 107 | `Redacted` env values are unwrapped at spawn time, so the secret |
| 108 | reaches the child process without appearing in logs or state. The |
| 109 | full re-run matrix: |
| 110 | |
| 111 | - Unchanged inputs — the run is skipped. |
| 112 | - A changed memoized file, an env-only change, or a command-only |
| 113 | change — each re-runs the command. |
| 114 | - Destroy — delete is a no-op; the command's side effects are never |
| 115 | reversed. |
| 116 | - After a destroy, an unchanged redeploy runs the command again — |
| 117 | destroy forgets the run key. |
| 118 | |
| 119 | ## Where next |
| 120 | |
| 121 | - [Static site](/cloudflare/frontend/static-site) — deploy the build |
| 122 | output; `StaticSite` is a `Command.Build` resource under the hood. |
| 123 | - [Dev servers](/command/dev-servers) — long-lived local processes |
| 124 | with `Command.Dev` during `alchemy dev`. |
| 125 | |
| 126 | Reference: |
| 127 | |
| 128 | - [Build](/providers/command/build) · |
| 129 | [Exec](/providers/command/exec) |