Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/issue-proposals/config-add-version-field-to-spec-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: "config: add `version` field to SpecConfig and YAML config"
parentIssue: 1937
labels:
- enhancement
- config
assignees: []
milestone:
---

Add an optional `version` field to `SpecConfig` (and the YAML config format) so users can declare multiple versioned specs under the same group. This is the first foundational step for multi-version support.

## Context

The current `SpecConfig` interface has `source`, `prefix`, and `group`. The multi-version feature needs a `version` field so that two specs with the same `group` but different `version` values can coexist. For example:

```yaml
spec:
- source: ./specs/my-api-v1.yaml
group: my-api
version: v1
- source: ./specs/my-api-v2.yaml
group: my-api
version: v2
destination: out
```

Without this field, specifying two specs in the same group is treated as a duplicate-group error (which is correct today, but needs to be relaxed when versions are present).

## Proposed change

1. Add an optional `version: string` field (default `""`) to the `SpecConfig` interface in `src/app.ts`.
2. Update `normalizeSpecs` / `normalizeSpecOption` (in `src/cli/run.ts`) to pass `version` through from the YAML config or CLI when provided.
3. Relax the duplicate-group validation in `validateSpecGroups` (in `src/app.ts`): two specs may share the same `group` as long as they each carry a distinct non-empty `version`. Mixed configurations (same-group entries with and without `version`) should produce a clear validation error.
4. Update the `counterfact.yaml` JSON Schema (if one exists) to allow `version` on each spec entry.
5. Update the TypeScript types for the CLI's `SpecOption` / `SpecEntry` objects to include the optional `version` field.

## Acceptance criteria

- [ ] `SpecConfig` has an optional `version: string` field that defaults to `""`
- [ ] A YAML config with two specs sharing the same `group` but different non-empty `version` values is accepted without error
- [ ] A YAML config with two specs sharing the same `group` and no `version` (or one with and one without) still produces a clear validation error
- [ ] `normalizeSpecOption` passes `version` through for both single-object and array inputs
- [ ] Unit tests cover the new valid and invalid group+version combinations in `validateSpecGroups`
- [ ] Single-spec usage without a `version` field continues to work unchanged
46 changes: 46 additions & 0 deletions .github/issue-proposals/docs-multiple-version-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: "docs: document multi-version API support"
parentIssue: 1937
labels:
- documentation
assignees: []
milestone:
---

Add user-facing documentation that explains how to configure and use multi-version API support: YAML config syntax, generated code layout, the `Versioned` type, route-handler authoring patterns, and REPL usage.

## Context

Multi-version support introduces several new concepts that need clear documentation for users:

- How to declare multiple versions of the same API group in the config
- The URL layout produced by the server (`/<group>/<version>/...`)
- The generated code layout (`types/<version>/...`, shared `types/paths/...`, `types/versions.ts`)
- How to write a route handler that serves multiple versions using the `Versioned` type
- How the REPL addresses versioned context, routes, and scenarios

Without documentation, users will not know the feature exists or how to adopt it.

## Proposed change

1. **`docs/features/multiple-versions.md`** – New dedicated feature page covering:
- YAML config syntax with `group` and `version` fields (with a realistic example that is not specific to any particular API)
- How the server derives URL prefixes from `group` + `version`
- Generated code layout diagram (shared `types/paths/`, per-version `types/<version>/`, `types/versions.ts`)
- How to write a route handler using `Versioned<…>` and `minVersion()`
- How to use `.scenario <group> <version> <path>` in the REPL

2. **`docs/usage.md`** or **`docs/getting-started.md`** – Add a brief "Multiple versions" section that links to the feature page.

3. **`docs/reference.md`** – Document the `version` field on `SpecConfig`, the `Versioned` type, and the `types/versions.ts` output file.

4. **`src/counterfact-types/` JSDoc** – Ensure `Versioned`, `Versions`, and `VersionsGTE` have accurate JSDoc comments that appear in IDE tooltips.

## Acceptance criteria

- [ ] `docs/features/multiple-versions.md` exists and covers config syntax, URL layout, code layout, handler authoring, and REPL usage
- [ ] The example in the documentation uses a generic API, not a specific real-world API, to avoid implying the feature is limited to a particular use case
- [ ] `docs/usage.md` or `docs/getting-started.md` links to the new feature page
- [ ] `docs/reference.md` documents the `version` field and the `Versioned` type
- [ ] `Versioned`, `Versions`, and `VersionsGTE` have JSDoc comments
- [ ] A code example shows a route handler using `minVersion()` to branch on API version
65 changes: 65 additions & 0 deletions .github/issue-proposals/generator-emit-versions-ts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
title: "generator: emit versions.ts with Versions, VersionsGTE, and Versioned types"
parentIssue: 1937
labels:
- enhancement
- typescript-generator
assignees: []
milestone:
---

When multi-version specs are configured, the code generator should emit a `types/versions.ts` file containing the `Versions` union, the `VersionsGTE` map, and the `Versioned` utility type that route handlers use to narrow their `$` argument to a specific API version.

## Context

The `Versioned` type allows a single route-handler function to serve multiple API versions while the TypeScript type system enforces that each version's parameters and responses are used correctly. It is the public contract between the generated types and the user-authored route handlers.

The file should be generated (not hand-written) because the `Versions` union and `VersionsGTE` map must be derived from the `version` strings declared in the user's spec config.

## Proposed shape

```ts
// types/versions.ts (generated, do not edit)

export type Versions = "v1" | "v2";

/**
* Maps each version to the set of versions that are greater than or equal to it.
* Used by `Versioned.minVersion()` to narrow which versions a handler must support.
*/
export type VersionsGTE = {
v1: "v1" | "v2";
v2: "v2";
};

type VersionMap = Partial<Record<Versions, object>>;

export type Versioned<
T extends VersionMap,
V extends keyof T & Versions = keyof T & Versions,
> = T[V] & {
version: V;
minVersion<M extends keyof T & Versions>(
min: M,
): this is Versioned<T, Extract<V, VersionsGTE[M]>>;
};
```

The version ordering used to build `VersionsGTE` should follow the order in which versions appear in the spec config (first entry = oldest). The `Versioned` type body itself is a fixed template; only `Versions` and `VersionsGTE` vary per configuration.

## Proposed change

1. Add a new code-generation step (or extend the repository's post-processing phase) that reads all unique non-empty `version` strings from the spec configs in declaration order.
2. Emit `types/versions.ts` with the `Versions` union, the `VersionsGTE` map, and the fixed `Versioned` utility type.
3. When no spec carries a `version` field, skip emitting `types/versions.ts` entirely to avoid changing single-spec output.
4. Export `Versioned` from `src/counterfact-types/index.ts` so route-handler authors can import it directly alongside other shared types.

## Acceptance criteria

- [ ] `types/versions.ts` is generated when at least one spec has a non-empty `version`
- [ ] `Versions` is a union of all distinct version strings in config-declaration order
- [ ] `VersionsGTE[V]` includes `V` and all later-declared versions (i.e. versions >= V)
- [ ] The `Versioned` utility type compiles without errors and correctly narrows `$` in a test route handler
- [ ] `Versioned` is exported from `src/counterfact-types/index.ts`
- [ ] No `types/versions.ts` is emitted for single-spec (no version) configurations
- [ ] Unit tests cover `Versions` and `VersionsGTE` generation for 1-, 2-, and 3-version configs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: "generator: update OperationTypeCoder to emit version-mapped handler types"
parentIssue: 1937
labels:
- enhancement
- typescript-generator
assignees: []
milestone:
---

`OperationTypeCoder` currently emits a single flat handler type per operation. For multi-version APIs it must instead emit a *version map* that collects each version's strongly-typed `$` argument under a keyed object, so that the shared route handler can be narrowed to a specific version at call time.

## Context

When two or more versioned specs share the same operation path (e.g. `GET /pets`), the generated `types/paths/pets.types.ts` file cannot contain two conflicting `HTTP_GET` type definitions. Instead, `OperationTypeCoder` should emit a single merged type that covers all versions:

```ts
// types/paths/pets.types.ts (generated, do not edit)

import type { MaybePromise, COUNTERFACT_RESPONSE } from "../../counterfact-types/index.js";
import type { Versioned } from "../../types/versions.js";
import type { HTTP_GET_$ as HTTP_GET_$_v1 } from "../../types/v1/paths/pets.types.js";
import type { HTTP_GET_$ as HTTP_GET_$_v2 } from "../../types/v2/paths/pets.types.js";

type HTTP_GET_$_Versions = {
v1: HTTP_GET_$_v1;
v2: HTTP_GET_$_v2;
};

export type HTTP_GET = (
$: Versioned<HTTP_GET_$_Versions>,
) => MaybePromise<COUNTERFACT_RESPONSE>;
```

Each version's `$` argument type lives in its own subdirectory file (produced by the per-version coder invocations). `OperationTypeCoder` is responsible only for assembling the union wrapper at the shared path.

## Proposed change

1. When `version` is non-empty, `OperationTypeCoder` should write a **per-version $-argument type** (the current flat type body) to `types/<version>/paths/<path>.types.ts` instead of the shared path.
2. The shared `types/paths/<path>.types.ts` script should use `Script.declareVersion()` to accumulate version entries and ultimately emit the merged `HTTP_<METHOD>_$_Versions` map and the `HTTP_<METHOD>` wrapper that uses `Versioned<…>`.
3. `modulePath()` should return the shared `types/paths/<path>.types.ts` path (unchanged), while the per-version file path is handled by a helper or a dedicated `VersionedOperationTypeCoder`.
4. Imports of per-version types in the shared file are generated relative to the file's location in the output tree.
5. When only one version is configured (or `version` is empty), the existing flat-type output is preserved for full backwards compatibility.

## Acceptance criteria

- [ ] With two versioned specs sharing a path, the shared `types/paths/<path>.types.ts` exports an `HTTP_<METHOD>` type whose `$` argument is `Versioned<{ v1: …, v2: … }>`
- [ ] Each version's `$`-argument type is emitted to `types/<version>/paths/<path>.types.ts` and imported by the shared file
- [ ] With a single spec (no version), the generated output is identical to today
- [ ] `OperationTypeCoder` unit tests are updated/extended to cover the versioned output shape
- [ ] The generated TypeScript compiles without errors (`tsc --noEmit`) against a sample versioned spec pair
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: "generator: update remaining TypeCoders to emit output under version subdirectories"
parentIssue: 1937
labels:
- enhancement
- typescript-generator
assignees: []
milestone:
---

`SchemaTypeCoder` and `ResponseTypeCoder` already emit their output under `types/<version>/...` when a non-empty `version` is set. The remaining `TypeCoder` subclasses that produce version-specific type files must be updated to follow the same convention.

## Context

The multi-version code generation strategy is:

> Every `TypeCoder` except `OperationTypeCoder` writes its output into a `types/<version>/` subdirectory when a `version` is present, so that the types for each version are fully isolated. `OperationTypeCoder` is the exception: it lives at the shared path and imports/merges types from all version subdirectories (see companion issue).

`SchemaTypeCoder` and `ResponseTypeCoder` already implement this:

```
types/v1/components/MySchema.ts
types/v1/responses/MyResponse.ts
types/v2/components/MySchema.ts
types/v2/responses/MyResponse.ts
```

The following coders still need to be updated:

- `ParametersTypeCoder` – emits `types/paths/<path>.parameters.ts`
- `ResponsesTypeCoder` – emits types for the combined response object
- `ParameterExportTypeCoder` – emits individual exported parameter types

## Proposed change

For each coder listed above, update `modulePath()` to insert the `version` segment between `types/` and the remainder of the path when `this.version` is non-empty, mirroring the pattern already used in `SchemaTypeCoder` and `ResponseTypeCoder`:

```ts
// before
return `types/paths/${pathString}.parameters.ts`;

// after
return pathJoin("types", this.version, "paths", `${pathString}.parameters.ts`);
// → "types/v1/paths/pets.parameters.ts" when version = "v1"
// → "types/paths/pets.parameters.ts" when version = ""
```

No other behaviour should change; only the output file location is affected.

## Acceptance criteria

- [ ] `ParametersTypeCoder.modulePath()` returns `types/<version>/paths/...` when `version` is non-empty and `types/paths/...` when empty
- [ ] `ResponsesTypeCoder.modulePath()` (if it declares one) follows the same convention
- [ ] `ParameterExportTypeCoder.modulePath()` follows the same convention
- [ ] Existing unit tests for `modulePath()` on each affected coder continue to pass
- [ ] New unit tests assert the versioned path for a non-empty `version` on each affected coder
- [ ] Single-spec (no version) generation produces identical output to today
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
title: "repl: version-aware context, routes, and .scenario for multi-version APIs"
parentIssue: 1937
labels:
- enhancement
- repl
assignees: []
milestone:
---

Extend the REPL so that users interacting with a multi-version API server can address context, routes, and scenarios by both group and version, not just by group.

## Context

When the same group has multiple versions (e.g. `my-api/v1` and `my-api/v2`), each version is backed by its own `ApiRunner` with its own `contextRegistry` and `scenarioRegistry`. The REPL currently keys its helpers by group name alone. With multiple runners per group, the group key is no longer sufficient to uniquely identify a runner.

## Proposed change

### Context and routes helpers

In multi-version mode, extend the grouped helper objects with a second level keyed by version:

```
context.myApi.v1 // context for my-api v1
context.myApi.v2 // context for my-api v2
routes.myApi.v1 // route helpers for my-api v1
```

When a group has only one version (or no version), the existing flat `context.myApi` / `routes.myApi` shape is preserved.

### `.scenario` command

Extend `.scenario` to accept an optional version qualifier:

- Single-runner (no version): `.scenario <path>` (unchanged)
- Multi-runner, no version: `.scenario <group> <path>` (existing multi-runner syntax, unchanged)
- Multi-runner, with versions: `.scenario <group> <version> <path>`

Unknown group or version combinations should produce a descriptive error that lists available groups and versions.

### Tab completion

Update the `.scenario` completer to suggest version keys after a valid group key has been typed, in the same two-stage pattern used for group keys today.

## Acceptance criteria

- [ ] In multi-version mode, `context.<group>.<version>` and `routes.<group>.<version>` are available in the REPL
- [ ] In multi-version mode, `.scenario <group> <version> <path>` executes scenarios against the correct runner
- [ ] Tab completion after `.scenario <group> ` suggests available version keys for that group
- [ ] Tab completion after `.scenario <group> <version> ` suggests scenario paths for that group/version
- [ ] Unknown group or version produces a clear error listing available options
- [ ] Single-runner and multi-runner-without-version behaviors are unchanged
- [ ] Unit tests cover context/routes wiring and `.scenario` dispatch for all modes
54 changes: 54 additions & 0 deletions .github/issue-proposals/scenarios-version-aware-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: "scenarios: version-aware scenario loading and startup scenario for multi-version APIs"
parentIssue: 1937
labels:
- enhancement
- scenarios
assignees: []
milestone:
---

Update the scenario system so that each versioned API runner loads scenarios from a version-scoped directory, and the startup scenario receives version information so it can initialise per-version state.

## Context

Scenarios are currently loaded from `<basePath>/<group>/scenarios/`. When a group has multiple versions (`v1`, `v2`, …), scenarios that are specific to one version should live under `<basePath>/<group>/<version>/scenarios/` while shared scenarios can remain at `<basePath>/<group>/scenarios/`.

The startup scenario (`scenarios/index.ts` → `startup()`) runs once per runner and is given a `$` object with `context`, `loadContext`, and `route` helpers. In multi-version mode, the startup scenario may also need to know which version it is initialising.

## Proposed change

### Scenario directory resolution

When a runner's `SpecConfig` has a non-empty `version`, resolve scenario scripts from:

1. `<basePath>/<group>/<version>/scenarios/` (version-specific, searched first)
2. `<basePath>/<group>/scenarios/` (shared fallback)

When `version` is empty, use only `<basePath>/<group>/scenarios/` as today.

### Startup scenario `$` argument

Pass `$.version` (the version string, or `""` for unversioned runners) into the startup scenario's `$` argument so authors can branch initialisation logic:

```ts
// scenarios/index.ts
export const startup = ($: { context: …; version: string }) => {
if ($.version === "v2") {
$.context.featureFlags.newPagination = true;
}
};
```

### `.scenario` REPL command

No changes are needed here beyond those tracked in the REPL version-awareness issue; scenario loading uses the same directory resolution rules described above.

## Acceptance criteria

- [ ] When `version` is set, scenario files in `<group>/<version>/scenarios/` are loaded and take precedence over same-named files in `<group>/scenarios/`
- [ ] Files in `<group>/scenarios/` remain available as a shared fallback in multi-version mode
- [ ] The startup scenario's `$` argument includes a `version` property containing the runner's version string
- [ ] The startup scenario's `$` argument has `version = ""` for unversioned runners (backwards compatible)
- [ ] Single-spec / single-version scenario loading is unchanged
- [ ] Unit tests cover version-scoped directory resolution and the fallback behavior
Loading
Loading