openapi-versions 0.0.0-stage → 0.0.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/dist/chunk-4O4PFYPM.js +288 -0
  4. package/dist/chunk-4O4PFYPM.js.map +1 -0
  5. package/dist/chunk-QZ47EPTW.js +96 -0
  6. package/dist/chunk-QZ47EPTW.js.map +1 -0
  7. package/dist/cli.js +20 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/codegen.d.ts +104 -0
  10. package/dist/codegen.js +29 -0
  11. package/dist/codegen.js.map +1 -0
  12. package/dist/core.d.ts +146 -0
  13. package/dist/core.js +19 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/index.d.ts +39 -0
  16. package/dist/index.js +120 -0
  17. package/dist/index.js.map +1 -0
  18. package/docs/README.md +22 -0
  19. package/docs/adr/0001-separate-package-not-upstream.md +16 -0
  20. package/docs/adr/0002-extensible-enums-via-x-extensible-enum.md +16 -0
  21. package/docs/adr/0003-gating-model.md +16 -0
  22. package/docs/adr/0004-oasdiff-breaking-gate.md +16 -0
  23. package/docs/adr/0005-patch-openapi-typescript-helpers.md +16 -0
  24. package/docs/adr/0006-tooling-mise-pnpm-biome.md +16 -0
  25. package/docs/adr/0007-mit-license.md +16 -0
  26. package/docs/adr/README.md +13 -0
  27. package/docs/adr/template.md +16 -0
  28. package/docs/concepts.md +67 -0
  29. package/docs/ecosystem.md +39 -0
  30. package/docs/getting-started.md +99 -0
  31. package/docs/how-to.md +155 -0
  32. package/docs/reference.md +129 -0
  33. package/docs/troubleshooting.md +21 -0
  34. package/docs/upstream-drafts.md +166 -0
  35. package/openapi-versions.schema.json +57 -0
  36. package/package.json +114 -4
  37. package/patches/openapi-typescript-helpers@0.1.0.patch +51 -0
  38. package/skills/openapi-versions-add-spec/SKILL.md +37 -0
  39. package/skills/openapi-versions-gate-feature/SKILL.md +34 -0
  40. package/skills/openapi-versions-setup/SKILL.md +35 -0
@@ -0,0 +1,166 @@
1
+ # Upstream drafts (NOT posted)
2
+
3
+ > **Status 2026-10-25 — draft 1 is superseded.** [openapi-ts/openapi-typescript#2766](https://github.com/openapi-ts/openapi-typescript/pull/2766) (open, review required) contains the same fix (primitive guard before `T extends object` in `Readable`/`Writable`). Do not post a duplicate; add the repro below as a comment there. Related: [#2415](https://github.com/openapi-ts/openapi-typescript/pull/2415) (merged, unreleased, queued for openapi-typescript 7.14.0 in release PR #2616) makes openapi-typescript itself emit `(literals) | (string & {})` for `additionalProperties: true` string enums, which hits this bug in every openapi-fetch consumer once released. Both are reasons to drop `patches/` once they ship.
4
+
5
+ Two drafts for the openapi-ts repository (`openapi-ts/openapi-typescript`). Nothing here has been sent. Before posting: search existing issues for duplicates, re-run the repro on the latest versions, and fill in the placeholders in `<angle brackets>`.
6
+
7
+ Versions used for the repro: `openapi-fetch@0.17.0`, `openapi-typescript-helpers@0.1.0`, `openapi-typescript@7.13`, `typescript@5.9`.
8
+
9
+ ---
10
+
11
+ ## 1. Bug + PR: `Readable<T>` / `Writable<T>` turn `string & {}` into an object type
12
+
13
+ **Package:** `openapi-typescript-helpers`
14
+
15
+ ### Title
16
+
17
+ `Readable<T>` and `Writable<T>` map over `string & {}`, so open string unions lose their open member
18
+
19
+ ### Description
20
+
21
+ Open string unions such as `"a" | "b" | (string & {})` are a common way to say "these values are known, others may appear" while keeping editor completion. `Readable<T>` and `Writable<T>` do not preserve them: `string & {}` matches the `T extends object` branch, and the mapped type then produces an object with all of `String`'s members instead of a string.
22
+
23
+ `openapi-fetch` wraps every response in `Readable<…>` (and every request body in `Writable<…>`), so the problem shows up in `data` and `error` of every call whose schema contains such a type, for instance a schema generated with a `transform` hook that emits open enums.
24
+
25
+ ### Reproduction
26
+
27
+ `schema.d.ts` (the shape openapi-typescript emits with a transform that makes a string enum open):
28
+
29
+ ```ts
30
+ export interface paths {
31
+ "/users/{id}": {
32
+ parameters: { query?: never; header?: never; path?: never; cookie?: never };
33
+ get: {
34
+ parameters: { query?: never; header?: never; path: { id: string }; cookie?: never };
35
+ requestBody?: never;
36
+ responses: {
37
+ 200: {
38
+ headers: { [name: string]: unknown };
39
+ content: { "application/json": { id: string; status: "active" | "invited" | "archived" | (string & {}) } };
40
+ };
41
+ };
42
+ };
43
+ };
44
+ }
45
+ ```
46
+
47
+ ```ts
48
+ import createClient from "openapi-fetch";
49
+ import type { paths } from "./schema";
50
+
51
+ const client = createClient<paths>({ baseUrl: "http://x" });
52
+ const { data } = await client.GET("/users/{id}", { params: { path: { id: "1" } } });
53
+
54
+ export const asString: string = data!.status; // error
55
+ export function label(s: NonNullable<typeof data>["status"]) {
56
+ switch (s) {
57
+ case "active":
58
+ return "A";
59
+ default:
60
+ return s.toUpperCase();
61
+ } // error
62
+ }
63
+ ```
64
+
65
+ ### Actual
66
+
67
+ ```
68
+ error TS2322: Type '"active" | "invited" | "archived" | { readonly [x: number]: string; toString: {}; charAt: {}; charCodeAt: {}; concat: {}; indexOf: {}; ... }' is not assignable to type 'string'.
69
+ error TS2349: This expression is not callable. Not all constituents of type '(() => string) | {}' are callable.
70
+ ```
71
+
72
+ The open member became `{ readonly [x: number]: string; toString: {}; charAt: {}; … }`.
73
+
74
+ ### Expected
75
+
76
+ `Readable<"a" | (string & {})>` is `"a" | (string & {})`: primitives are returned unchanged, only objects and arrays are mapped.
77
+
78
+ ### Cause
79
+
80
+ In `Readable<T>` (and `Writable<T>`) the last branches are:
81
+
82
+ ```ts
83
+ … : T extends (infer E)[] ? Readable<E>[]
84
+ : T extends object ? { [K in keyof T as …]: Readable<T[K]> }
85
+ : T;
86
+ ```
87
+
88
+ `string & {}` is assignable to `object`'s mapped path, so the mapped type iterates the keys of `String`.
89
+
90
+ ### Proposed fix
91
+
92
+ Return primitives before the object branch, in both helpers:
93
+
94
+ ```diff
95
+ -type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends object ? { … } : T;
96
+ +type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? { … } : T;
97
+ ```
98
+
99
+ and the same line in `Writable<T>`. A drop-in diff against 0.1.0 is in this repo: `patches/openapi-typescript-helpers@0.1.0.patch` (pnpm format).
100
+
101
+ With the patch applied, the repro above compiles; `"zzz"` is accepted where a newer backend sends an unknown value and `switch` still narrows the known cases.
102
+
103
+ ### PR checklist (when posting)
104
+
105
+ - [ ] Add a type test next to the existing helper tests: `Readable<"a" | (string & {})>` equals `"a" | (string & {})`, and the same for `Writable`.
106
+ - [ ] Add a changeset for `openapi-typescript-helpers` (patch).
107
+ - [ ] Confirm other primitives are unaffected (`number`, `boolean`, `null`, template literal types).
108
+ - [ ] Mention that it also fixes `branded` primitives such as `string & { __brand: "x" }` (verify before claiming).
109
+
110
+ ---
111
+
112
+ ## 2. Discussion / proposal: multi-version input ("one client, a range of server versions")
113
+
114
+ **Where:** GitHub Discussion (Ideas) first, not an issue. This is a feature request that may belong outside the core.
115
+
116
+ ### Title
117
+
118
+ Supporting a range of API minor versions from one generated client: merge + since-map + capability gating
119
+
120
+ ### Problem
121
+
122
+ A client often has to work against more than one version of the same API at once: mobile builds stay installed for months, the web app deploys continuously while the backend deploys on its own cycle, staging and on-prem differ from production. Today `openapi-typescript` takes one document and `openapi-fetch` / `openapi-react-query` assume the server matches it. That breaks in both directions: a client ahead of the server calls operations that 404, a client behind the server meets values and fields it has never seen.
123
+
124
+ I did not find an existing tool for this. Hey API's multi-input merges different APIs and prefixes colliding paths; oasdiff detects breaking changes but produces no client.
125
+
126
+ ### What I built (works today, outside the core)
127
+
128
+ A wrapper around the Node API of `openapi-typescript`, no fork, published as `<package>`:
129
+ 1. **Inputs:** one spec per released version of one major, plus a _floor_ (oldest supported minor).
130
+ 2. **Gate:** `oasdiff breaking` on each consecutive pair; the build fails on any break. Inside a major, changes must be additive.
131
+ 3. **Tolerant merge:** paths, operations and properties are the union; `required` is the intersection; enums are the union; the rest is newest-wins.
132
+ 4. **Since maps:** for operations and schema fields introduced after the floor, the first version that has them.
133
+ 5. **Generation:** `openapiTS(mergedDocument, { transform })`. The `transform` hook turns `x-extensible-enum` into open unions (`"a" | "b" | (string & {})`).
134
+ 6. **Runtime (separate, thin):** reads `GET <base>/version` and `X-Api-Version`, and exposes `supports(op)` / `hasField(field)`. A second `openapi-react-query` client is typed with gated operations removed, so calling a newer operation without a capability check is a compile error.
135
+
136
+ Evidence: a prototype with 34 tests runs one client build against seven backend versions plus two majors side by side, and never sends a request to an operation the connected backend lacks. A packed install into a fresh app generates and type-checks.
137
+
138
+ ### What I am asking
139
+
140
+ Which of these would you like, if any?
141
+ - **A. Nothing in core.** The wrapper stays a separate package. Could the docs list it under ecosystem / recipes?
142
+ - **B. Documented recipe** for multi-spec input using the existing `transform` hook and Node API (no code in core).
143
+ - **C. Small core hooks** that would make the wrapper simpler: (i) built-in open enums from an extension keyword (today: custom `transform`), (ii) a way to pass several documents and receive the merged one, (iii) exposing the operation list (`"<method> <path>"` keys) with the schema.
144
+
145
+ Separately, I will open the `Readable` / `Writable` fix as its own PR (see above); it is independent of this proposal.
146
+
147
+ ### Open design questions I would like feedback on
148
+
149
+ - Is `x-extensible-enum` the right keyword, or should open enums use another convention?
150
+ - Should "closed vs open enum" be a first-class concept in openapi-typescript?
151
+ - The merge is only sound if the inputs are non-breaking relative to each other. Is it better to run the oasdiff gate inside the tool or leave it to CI?
152
+ - Fields added after the floor become optional in the merged types. Is there a better representation (for example a branded "since" type)?
153
+
154
+ ### Links
155
+
156
+ - Repo: `<repo url>`
157
+ - Concepts and how-tos: `<docs url>`
158
+ - Example app (control panel that "deploys" backend versions while the app is open): `<url or gif>`
159
+
160
+ ---
161
+
162
+ ## Posting order I would suggest
163
+
164
+ 1. Open the `Readable` / `Writable` PR first: small, self-contained, needs no discussion.
165
+ 2. Post the discussion after the PR, linking it as evidence that the open-enum use case is real.
166
+ 3. Only after maintainers respond, decide between options A, B and C.
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://example.com/schemas/openapi-versions.schema.json",
4
+ "title": "openapi-versions.config.json",
5
+ "description": "Configuration of the version-aware codegen: which OpenAPI specs to merge, per API major, and from which minor (the floor) the app supports.",
6
+ "type": "object",
7
+ "required": ["specsDir", "outDir", "majors"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string",
12
+ "description": "Path or URL of this schema, for editor autocompletion."
13
+ },
14
+ "specsDir": {
15
+ "type": "string",
16
+ "minLength": 1,
17
+ "description": "Directory holding one spec per released version, named <semver>.json (e.g. specs/1.4.0.json). Files whose name is not valid semver are ignored."
18
+ },
19
+ "outDir": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "description": "Directory the generated code is written to: <outDir>/v<major>/{schema.d.ts, meta.ts, merged.openapi.json} and <outDir>/index.ts. Safe to delete and regenerate."
23
+ },
24
+ "majors": {
25
+ "type": "object",
26
+ "description": "API majors this app build supports, keyed by major number. One app release normally lists exactly one.",
27
+ "minProperties": 1,
28
+ "propertyNames": {
29
+ "pattern": "^(0|[1-9][0-9]*)$"
30
+ },
31
+ "additionalProperties": {
32
+ "type": "object",
33
+ "required": ["floor"],
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "floor": {
37
+ "type": "string",
38
+ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$",
39
+ "description": "Oldest minor of this major the app still supports, as a plain semver (e.g. 1.3.0). A spec file named <floor>.json must exist. Operations and fields introduced after the floor are gated at runtime; everything at the floor is assumed on every supported backend."
40
+ }
41
+ }
42
+ }
43
+ },
44
+ "gate": {
45
+ "type": "string",
46
+ "enum": ["auto", "required", "off"],
47
+ "default": "auto",
48
+ "description": "Breaking-change gate (oasdiff) between consecutive specs. auto: run it when oasdiff is installed, warn and skip otherwise. required: fail when oasdiff is missing or too old (use in CI, or pass --require-gate). off: never run it."
49
+ },
50
+ "runtimeImport": {
51
+ "type": "string",
52
+ "minLength": 1,
53
+ "default": "openapi-versions",
54
+ "description": "Module specifier the generated v<major>/client.ts imports the runtime from. Leave unset when the runtime is installed as the `openapi-versions` package; set a relative path when developing the runtime in the same repo."
55
+ }
56
+ }
57
+ }
package/package.json CHANGED
@@ -1,6 +1,116 @@
1
1
  {
2
2
  "name": "openapi-versions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.0",
4
+ "description": "Multi-version OpenAPI client for the openapi-ts ecosystem: merges the minor specs of one API major, generates tolerant types with openapi-typescript, and gates features at runtime.",
5
+ "scripts": {
6
+ "codegen:playground": "rm -rf playground/generated && tsx codegen/cli.ts --config playground/openapi-versions.config.json",
7
+ "codegen:test": "rm -rf test/fixtures/generated && tsx codegen/cli.ts --config test/fixtures/openapi-versions.config.json",
8
+ "codegen": "npm run codegen:playground && npm run codegen:test",
9
+ "check:breaking": "tsx codegen/cli.ts --check-only --config playground/openapi-versions.config.json",
10
+ "typecheck": "npm run codegen:test && npm run codegen:playground && tsc --noEmit",
11
+ "test": "npm run codegen:test && vitest run",
12
+ "playground": "npm run codegen:playground && vite --config playground/app/vite.config.ts",
13
+ "playground:api": "npm run codegen:playground && tsx playground/api/run.ts",
14
+ "build:playground": "npm run codegen:playground && vite build --config playground/app/vite.config.ts",
15
+ "build": "tsup",
16
+ "prepack": "npm run build",
17
+ "lint": "biome check .",
18
+ "format": "biome check --write .",
19
+ "changeset": "changeset",
20
+ "release": "pnpm build && changeset publish"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/NoZiL/openapi-versions.git"
26
+ },
27
+ "homepage": "https://github.com/NoZiL/openapi-versions#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/NoZiL/openapi-versions/issues"
30
+ },
31
+ "devDependencies": {
32
+ "@testing-library/react": "^16.3.3",
33
+ "@types/node": "^26.6.5",
34
+ "@types/react": "^19.3.0",
35
+ "@types/react-dom": "^19.3.0",
36
+ "@types/semver": "^7.8.0",
37
+ "jsdom": "^30.1.3",
38
+ "tsup": "^9.0.0",
39
+ "tsx": "^4.23.15",
40
+ "vite": "^8.3.5",
41
+ "vitest": "^5.0.5",
42
+ "openapi-fetch": "^0.17.0",
43
+ "openapi-react-query": "^0.5.4",
44
+ "openapi-typescript": "^7.13.0",
45
+ "openapi-typescript-helpers": "^0.1.0",
46
+ "@tanstack/react-query": "^5.104.5",
47
+ "react": "^19.3.1",
48
+ "react-dom": "^19.3.1",
49
+ "@biomejs/biome": "^2.6.2",
50
+ "@changesets/cli": "^3.0.3"
51
+ },
52
+ "dependencies": {
53
+ "semver": "^7.8.5",
54
+ "typescript": "^5.9.3"
55
+ },
56
+ "type": "module",
57
+ "bin": {
58
+ "openapi-versions": "dist/cli.js"
59
+ },
60
+ "main": "./dist/index.js",
61
+ "types": "./dist/index.d.ts",
62
+ "exports": {
63
+ ".": {
64
+ "types": "./dist/index.d.ts",
65
+ "import": "./dist/index.js"
66
+ },
67
+ "./core": {
68
+ "types": "./dist/core.d.ts",
69
+ "import": "./dist/core.js"
70
+ },
71
+ "./codegen": {
72
+ "types": "./dist/codegen.d.ts",
73
+ "import": "./dist/codegen.js"
74
+ },
75
+ "./schema.json": "./openapi-versions.schema.json"
76
+ },
77
+ "files": [
78
+ "dist",
79
+ "openapi-versions.schema.json",
80
+ "docs",
81
+ "skills",
82
+ "patches"
83
+ ],
84
+ "keywords": [
85
+ "openapi",
86
+ "openapi-typescript",
87
+ "openapi-fetch",
88
+ "openapi-react-query",
89
+ "api-versioning",
90
+ "semver",
91
+ "codegen"
92
+ ],
93
+ "peerDependencies": {
94
+ "openapi-typescript": "^7.13.0",
95
+ "openapi-fetch": "^0.17.0",
96
+ "openapi-react-query": "^0.5.4",
97
+ "openapi-typescript-helpers": "^0.1.0",
98
+ "@tanstack/react-query": "^5.104.5",
99
+ "react": "^19.3.1"
100
+ },
101
+ "peerDependenciesMeta": {
102
+ "react": {
103
+ "optional": true
104
+ },
105
+ "@tanstack/react-query": {
106
+ "optional": true
107
+ },
108
+ "openapi-react-query": {
109
+ "optional": true
110
+ }
111
+ },
112
+ "packageManager": "pnpm@12.3.4",
113
+ "engines": {
114
+ "node": ">=20"
115
+ }
116
+ }
@@ -0,0 +1,51 @@
1
+ diff --git a/dist/index.d.cts b/dist/index.d.cts
2
+ index 077c7b86d92878fe71b70d379f29027ae7bb2940..90b5181d898c69b0ac95643733407d633f807558 100644
3
+ --- a/dist/index.d.cts
4
+ +++ b/dist/index.d.cts
5
+ @@ -155,10 +155,10 @@ type $Read<T> = {
6
+ type $Write<T> = {
7
+ readonly $write: T;
8
+ };
9
+ -type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends object ? {
10
+ +type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? {
11
+ [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]>;
12
+ } : T;
13
+ -type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends object ? {
14
+ +type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? {
15
+ [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]>;
16
+ } & {
17
+ [K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never;
18
+ diff --git a/dist/index.d.mts b/dist/index.d.mts
19
+ index 077c7b86d92878fe71b70d379f29027ae7bb2940..90b5181d898c69b0ac95643733407d633f807558 100644
20
+ --- a/dist/index.d.mts
21
+ +++ b/dist/index.d.mts
22
+ @@ -155,10 +155,10 @@ type $Read<T> = {
23
+ type $Write<T> = {
24
+ readonly $write: T;
25
+ };
26
+ -type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends object ? {
27
+ +type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? {
28
+ [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]>;
29
+ } : T;
30
+ -type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends object ? {
31
+ +type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? {
32
+ [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]>;
33
+ } & {
34
+ [K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never;
35
+ diff --git a/dist/index.d.ts b/dist/index.d.ts
36
+ index 077c7b86d92878fe71b70d379f29027ae7bb2940..90b5181d898c69b0ac95643733407d633f807558 100644
37
+ --- a/dist/index.d.ts
38
+ +++ b/dist/index.d.ts
39
+ @@ -155,10 +155,10 @@ type $Read<T> = {
40
+ type $Write<T> = {
41
+ readonly $write: T;
42
+ };
43
+ -type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends object ? {
44
+ +type Readable<T> = T extends $Write<any> ? never : T extends $Read<infer U> ? Readable<U> : T extends (infer E)[] ? Readable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? {
45
+ [K in keyof T as NonNullable<T[K]> extends $Write<any> ? never : K]: Readable<T[K]>;
46
+ } : T;
47
+ -type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends object ? {
48
+ +type Writable<T> = T extends $Read<any> ? never : T extends $Write<infer U> ? Writable<U> : T extends (infer E)[] ? Writable<E>[] : T extends string | number | boolean | bigint | symbol ? T : T extends object ? {
49
+ [K in keyof T as NonNullable<T[K]> extends $Read<any> ? never : K]: Writable<T[K]>;
50
+ } & {
51
+ [K in keyof T as NonNullable<T[K]> extends $Read<any> ? K : never]?: never;
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: openapi-versions-add-spec
3
+ description: Add a new backend release spec, raise the floor, fix a breaking-change failure, or move the app to the next API major in the version-aware client. Use when the backend ships a new minor, when `pnpm codegen` fails with `FAIL v1: a -> b` or an oasdiff report, or when a version leaves the support window.
4
+ ---
5
+
6
+ # Add a spec, raise the floor, handle breaks
7
+
8
+ Details and rationale: `docs/how-to.md`.
9
+
10
+ ## A new backend release (minor)
11
+
12
+ 1. Save the release's spec as `specs/<exact semver>.json` (from the backend CI artifact, not a branch tip). It may be unreleased: its features stay hidden until the backend reports that version.
13
+ 2. `pnpm codegen`.
14
+ 3. `git diff src/generated/v1/meta.ts`: new keys in `gatedOps` / `gatedFields` are what the app can now gate. Report them to the user.
15
+ 4. If the spec changed again before release, overwrite the file and re-run.
16
+
17
+ ## Codegen fails with `FAIL v1: X -> Y`
18
+
19
+ The pair is not additive (removed/renamed/retyped, new required request field, or value added to a closed `enum`). In order:
20
+ 1. Tell the user the backend change must be made additive. Quote the oasdiff report.
21
+ 2. If the report is `response-property-enum-value-added` and the enum is meant to grow: the backend declares it `x-extensible-enum`.
22
+ 3. If the break is intended: it needs a new major (below).
23
+ NEVER edit a spec to silence the gate, never delete the offending spec, never bypass `checkChain`.
24
+
25
+ ## Raise the floor
26
+
27
+ 1. Set `majors.<n>.floor` in `openapi-versions.config.json` (a spec file with that exact version must exist).
28
+ 2. `pnpm codegen`, then `pnpm typecheck`.
29
+ 3. Operations now at-or-below the floor are ungated: remove `useSupports`/`<Feature requires>`/`gate()` that the compiler flags or that guard operations no longer in `gatedOps`.
30
+
31
+ ## Next major
32
+
33
+ Backend serves `/api/vN+1` side by side with the frozen `/api/vN` (with `Sunset`). In the app: set `majors` to `{ "<N+1>": { "floor": "<N+1>.0.0" } }`, add the new specs, change imports from `…/v<N>/client` to `…/v<N+1>/client`, point `baseUrl` at `/api/v<N+1>`, run codegen, fix compile errors. This is a new app release; the app never switches majors at runtime.
34
+
35
+ ## Verify
36
+
37
+ `pnpm codegen && pnpm typecheck && npm test`. Report what changed in `meta.ts`.
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: openapi-versions-gate-feature
3
+ description: Use an API operation or field that only exists on newer backend versions, in an app built with the version-aware client. Use when writing UI or data code against operations in `gatedOps`/`gatedFields`, when TypeScript says an operation does not exist on `$api`, or when handling an extensible enum.
4
+ ---
5
+
6
+ # Gate a feature in app code
7
+
8
+ Source of truth for what is gated: `src/generated/v<n>/meta.ts` (`gatedOps`, `gatedFields`: key → version that introduced it). Anything not listed exists on every supported backend and is used normally through `$api`.
9
+
10
+ ## Pick the pattern
11
+
12
+ | Need | Code |
13
+ |---|---|
14
+ | Always-available operation | `const { $api } = useApi(); $api.useQuery("get", "/users/{id}", init)` |
15
+ | Gated **query** | `const { $gated, gate } = useApi(); useQuery(gate($gated.queryOptions("get", "/users/{id}/activity", init)))` |
16
+ | Gated **mutation** | `const m = $gated.useMutation("post", "/users/{id}/archive");` and render the trigger inside `<Feature requires="post /users/{id}/archive" fallback={…}>` |
17
+ | Boolean check | `useSupports("get /users/{id}/activity")`; outside React `api.compat.supports(op)` |
18
+ | Gated **field** | `useHasField("User.avatarUrl")`; the field is typed optional |
19
+ | Loader / outside React | `const api = await createApi({ baseUrl }); api.$api.queryOptions(...)` |
20
+
21
+ Operation keys: lower-case method + space + path template. Field keys: `Schema.property`.
22
+
23
+ ## Rules
24
+
25
+ - A gated operation is NOT on `$api` (compile error is intended). Do not cast around it; use `$gated`.
26
+ - `gate()` results cannot go to `useSuspenseQuery` (may be skipped). Use `useQuery`.
27
+ - Never read or compare the backend version in a component. Ask `supports` / `hasField`.
28
+ - Provide a sensible UI when the feature is off (`fallback`, or render nothing). No error state: an unsupported operation is never called.
29
+ - Extensible enums are open unions: always a `default` branch in `switch`, showing the raw value. Do not narrow with `as`.
30
+ - Newer backends may send fields the app does not know: ignore them, never validate strictly against the generated shape.
31
+
32
+ ## Check
33
+
34
+ `pnpm typecheck`. For behaviour, test with `createMockBackend(version)` from `playground/api/mock-backend.ts` against a version below, at, and above the feature (see `test/react.test.tsx`): assert the control is absent below, present at/above, and that `backend.calls` contains no request to the gated path below.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: openapi-versions-setup
3
+ description: Set up the version-aware API client (multi-minor OpenAPI codegen on top of openapi-typescript) in an app, or verify an existing setup. Use when the user wants one client to work against a range of backend minor versions, mentions openapi-versions.config.json, specs/<semver>.json, floor, gating, or X-Api-Version.
4
+ ---
5
+
6
+ # Set up the version-aware client
7
+
8
+ Read `docs/concepts.md` once if the vocabulary (major, minor, floor, gated) is unclear. Reference: `docs/reference.md`.
9
+
10
+ ## Checklist
11
+
12
+ 1. **oasdiff**: `command -v oasdiff` (else `brew install oasdiff` or set `OASDIFF_BIN`). Without it codegen warns and skips the gate; set `"gate": "required"` (or pass `--require-gate`) in CI.
13
+ 2. **Specs**: `specs/<semver>.json`, one per released version of ONE major, from the backend release artifacts. Ask the user where to get them if absent; never invent specs.
14
+ 3. **Config** `openapi-versions.config.json`:
15
+ ```json
16
+ { "$schema": "./openapi-versions.schema.json", "specsDir": "specs", "outDir": "src/generated", "majors": { "1": { "floor": "1.3.0" } } }
17
+ ```
18
+ The floor is the oldest minor the app supports and must have its own spec file. Ask the user for the floor if unknown (default suggestion: the oldest minor running in any environment: prod, staging, on-prem).
19
+ 4. **Generate**: `npx openapi-versions` (or an npm script). Expect `ok v1: a -> b` lines then `gen v1: …`.
20
+ 5. **Install**: `npm i openapi-versions openapi-fetch openapi-react-query @tanstack/react-query` and `npm i -D openapi-typescript "typescript@<7"`; apply the `openapi-typescript-helpers` patch from `patches/` (patch-package).
21
+ Import everything from the generated `<outDir>/v<n>/client.ts` (`ApiVersionProvider`, `useApi`, `Feature`, `createApi`, `Paths`); the major appears only in that import path.
22
+ 6. **Provider**: wrap the app in `QueryClientProvider` then `<ApiVersionProvider baseUrl="https://host/api/v<n>">`. The base URL must include the major prefix; `/version` is read relative to it.
23
+ 7. **Verify**: `pnpm typecheck && npm test`; for a visual check `pnpm playground`.
24
+
25
+ ## Backend prerequisites (confirm, do not assume)
26
+
27
+ `GET <prefix>/version` → `{"version":"<real semver>"}`; `X-Api-Version` on every response; additive changes only within a major; growable enums marked `x-extensible-enum`.
28
+
29
+ ## Do not
30
+
31
+ - Edit `src/generated/**`.
32
+ - Add a version comparison in a component.
33
+ - Point one app build at two majors.
34
+
35
+ If codegen fails, read `docs/troubleshooting.md` for the exact message before changing anything.