|
| 1 | +# ADR 001: .apply Command Design — Minimalist Function Injection |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Accepted |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Counterfact's REPL lets developers interact with the running mock server from the terminal. A common need is to transition the server into a specific state (e.g. "all pets sold", "service unavailable") in a reproducible, shareable way. Today, operators must manually call REPL commands one by one; there is no mechanism to save and replay a named scenario. |
| 10 | + |
| 11 | +The `.apply` command is proposed to address this: given a path argument, it loads and executes a user-authored script that mutates REPL context and routes, then reports what changed. |
| 12 | + |
| 13 | +Three designs were proposed as working documents in `.github/issue-proposals/`: |
| 14 | + |
| 15 | +- `apply-approach-1-function-injection.md` — plain TypeScript named function exports, no framework coupling |
| 16 | +- `apply-approach-2-scenario-class-lifecycle.md` — a `Scenario` class interface with `setup()` / `teardown()` lifecycle hooks and a `dependencies` declaration |
| 17 | +- `apply-approach-3-proxy-based-tracking.md` — transparent reactive proxies that intercept all mutations and produce an automatic structured diff |
| 18 | + |
| 19 | +### Key constraints |
| 20 | + |
| 21 | +- Counterfact users are TypeScript developers who prefer writing ordinary code over learning framework-specific APIs. |
| 22 | +- The initial implementation must be straightforward to ship, test, and extend without committing to a heavy abstraction layer. |
| 23 | +- The REPL already provides `context` and `routes` as live objects; any solution must integrate cleanly with those. |
| 24 | +- TypeScript support is first-class via the existing transpiler. |
| 25 | + |
| 26 | +## Decision |
| 27 | + |
| 28 | +**Solution 1 (Minimalist Function Injection) is selected.** |
| 29 | + |
| 30 | +An apply script is a TypeScript file with one or more named function exports. When `.apply <path>` is run, Counterfact splits the argument on `/`, uses the last segment as the function name and the rest as the file path (relative to `<basePath>/repl/`), dynamically imports the module, and calls the named function with a live `ApplyContext` (`$`) object: |
| 31 | + |
| 32 | +```ts |
| 33 | +// repl/sold-pets.ts |
| 34 | +import type { ApplyContext } from "counterfact"; |
| 35 | + |
| 36 | +export function soldPets($: ApplyContext) { |
| 37 | + $.context.petService.reset(); |
| 38 | + $.context.petService.addPet({ id: 1, status: "sold" }); |
| 39 | + |
| 40 | + $.routes.getSoldPets = $.route("/pet/findByStatus").method("get").query({ status: "sold" }); |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +`ApplyContext` exposes `{ context, loadContext, routes, route }`. After the function returns, Counterfact diffs the `routes` object and prints a summary of what was added or removed. |
| 45 | + |
| 46 | +Solution 1 was chosen because it introduces the smallest possible API surface, imposes no structural requirements on script authors, and integrates naturally with TypeScript `import` for composability. It is the right foundation to build on before adding lifecycle or tracking features. |
| 47 | + |
| 48 | +## Options |
| 49 | + |
| 50 | +### Solution 1: Minimalist Function Injection (selected) |
| 51 | + |
| 52 | +Scripts export named functions that receive `$: ApplyContext`. Counterfact resolves the file/function from the path argument and calls the function directly. Route changes are diffed and reported; context changes are not automatically tracked. |
| 53 | + |
| 54 | +**Why chosen:** Maximum simplicity. No new abstractions, no required boilerplate. Easy to implement, test, and understand. Composability via normal `import`. |
| 55 | + |
| 56 | +### Solution 2: Scenario Class with Lifecycle Hooks |
| 57 | + |
| 58 | +Scripts export a named class that implements a `Scenario` interface with `setup()` and optional `teardown()` methods. Counterfact instantiates the class, calls `setup()`, and tracks applied instances in a map for later `.unapply`. A static `dependencies` array enables ordered composition. |
| 59 | + |
| 60 | +**Why not chosen:** Class syntax and lifecycle coupling add complexity that is not justified until the need for teardown and dependency ordering is proven in practice. These concerns can be layered on top of Solution 1 once the basic command exists. |
| 61 | + |
| 62 | +### Solution 3: Reactive Proxy-Based Change Tracking |
| 63 | + |
| 64 | +Identical surface syntax to Solution 1, but Counterfact wraps `context` and `routes` in transparent `Proxy` objects before calling the function. All mutations are intercepted, logged, and printed as a structured diff automatically. |
| 65 | + |
| 66 | +**Why not chosen:** The proxy layer adds significant runtime complexity and a class of subtle edge cases (prototype method calls, deeply nested mutations, proxy-obscured stack traces). The automatic diff is appealing but is a refinement that can be added after Solution 1 is stable, without changing the script-author API. |
| 67 | + |
| 68 | +## Consequences |
| 69 | + |
| 70 | +### What this enables |
| 71 | + |
| 72 | +- Developers can save named scenarios as TypeScript files and replay them from the REPL. |
| 73 | +- Scripts are ordinary TypeScript modules; they can import each other, use type-checking, and leverage the existing toolchain. |
| 74 | +- The feature ships quickly without a large API commitment. |
| 75 | + |
| 76 | +### Trade-offs accepted |
| 77 | + |
| 78 | +- Context changes are not automatically tracked; script authors must document or annotate context mutations manually. |
| 79 | +- There is no built-in teardown mechanism; reverting a scenario requires writing and calling a separate function. |
| 80 | +- Dependency ordering between scenarios is the script author's responsibility via normal `import`. |
| 81 | + |
| 82 | +### Risks and downsides |
| 83 | + |
| 84 | +- Without lifecycle hooks, accumulated state across many `.apply` calls may be difficult to reason about. |
| 85 | +- If teardown proves to be a common need, adding it later will require extending the API in a backward-compatible way. |
| 86 | +- Proxy-based auto-diffing (Solution 3) remains attractive for DX; deferring it means script authors will need to be disciplined about documenting context changes in the short term. |
| 87 | + |
| 88 | +### Follow-up work |
| 89 | + |
| 90 | +- Evaluate whether `teardown` support (from Solution 2) is needed and, if so, define a clean extension point. |
| 91 | +- Explore adding proxy-based context diffing (from Solution 3) as an opt-in enhancement once the core command is stable. |
| 92 | +- Define `ApplyContext` as a public exported type in `counterfact-types/`. |
| 93 | + |
| 94 | +## Advice |
| 95 | + |
| 96 | +- **Apply this decision** whenever a new scenario management capability is considered for the REPL. Start with a named function in a `.ts` file; reach for classes or proxy wrappers only when a concrete need for lifecycle or auto-tracking is demonstrated. (Copilot/Claude) |
| 97 | +- **Revisit this decision** if the lack of teardown creates significant friction for users who need to reset state cleanly, or if the absence of automatic context diffing makes scripts hard to audit. (Copilot/Claude) |
| 98 | +- **Prefer Solution 2 or 3** when: (a) scenarios need deterministic cleanup, (b) dependency ordering between scenarios must be enforced automatically, or (c) context mutation tracking is required for auditing or debugging. (Copilot/Claude) |
| 99 | +- **Rule of thumb:** keep scripts as plain TypeScript. If you find yourself writing setup/teardown boilerplate repeatedly, that is the signal to revisit lifecycle support. If you find yourself commenting every context change for reviewers, that is the signal to revisit proxy-based diffing. (Copilot/Claude) |
| 100 | +- **A natural extension point is the return value of the function** (currently `void`). It could be an optional string used to summarize the changes made by the script. It could also return an object containing a `teardown()` function, providing a lightweight path to lifecycle support without requiring a full class interface. (@pmcelhaney) |
0 commit comments