openapi-versions 0.0.0-stage → 0.1.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 (41) 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/0008-prereleases-via-snapshots.md +16 -0
  27. package/docs/adr/README.md +14 -0
  28. package/docs/adr/template.md +16 -0
  29. package/docs/concepts.md +67 -0
  30. package/docs/ecosystem.md +39 -0
  31. package/docs/getting-started.md +99 -0
  32. package/docs/how-to.md +155 -0
  33. package/docs/reference.md +129 -0
  34. package/docs/troubleshooting.md +21 -0
  35. package/docs/upstream-drafts.md +166 -0
  36. package/openapi-versions.schema.json +57 -0
  37. package/package.json +111 -3
  38. package/patches/openapi-typescript-helpers@0.1.0.patch +51 -0
  39. package/skills/openapi-versions-add-spec/SKILL.md +37 -0
  40. package/skills/openapi-versions-gate-feature/SKILL.md +34 -0
  41. package/skills/openapi-versions-setup/SKILL.md +35 -0
@@ -0,0 +1,14 @@
1
+ # Architecture decision records
2
+
3
+ One file per decision, `NNNN-title.md`, [MADR](https://adr.github.io/madr/) style. Accepted ADRs are immutable: to change a decision, add a new ADR that supersedes it and set the old one's status to `superseded by NNNN`. Copy `template.md`.
4
+
5
+ | ADR | Decision | Status |
6
+ | --- | --- | --- |
7
+ | [0001](0001-separate-package-not-upstream.md) | Ship as a separate package, upstream only small fixes | accepted |
8
+ | [0002](0002-extensible-enums-via-x-extensible-enum.md) | Open enums are declared with `x-extensible-enum` | accepted |
9
+ | [0003](0003-gating-model.md) | Newer operations go through `$gated` + `gate()` or `<Feature>` | accepted |
10
+ | [0004](0004-oasdiff-breaking-gate.md) | oasdiff gates every consecutive spec pair | accepted |
11
+ | [0005](0005-patch-openapi-typescript-helpers.md) | Patch `openapi-typescript-helpers` until upstream ships the fix | accepted |
12
+ | [0006](0006-tooling-mise-pnpm-biome.md) | mise, pnpm, Biome, Vitest, Changesets, Renovate; no turbo yet | accepted |
13
+ | [0007](0007-mit-license.md) | MIT license | accepted |
14
+ | [0008](0008-prereleases-via-snapshots.md) | Prereleases are on-demand snapshots from `main` under the `next` tag | accepted |
@@ -0,0 +1,16 @@
1
+ # NNNN. Title
2
+
3
+ - Status: proposed | accepted | superseded by NNNN
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ What forces the decision.
9
+
10
+ ## Decision
11
+
12
+ What we do.
13
+
14
+ ## Consequences
15
+
16
+ What gets easier, what gets harder, what to revisit and when.
@@ -0,0 +1,67 @@
1
+ # Concepts
2
+
3
+ ## Why this exists
4
+
5
+ Web, mobile and backend ship on different clocks. The API releases every two weeks, the web app several times a day, and mobile builds stay installed for months. At any moment several client and API versions talk to each other. A client generated from one spec silently assumes the backend matches it. This tool makes the assumption explicit and safe.
6
+
7
+ ## Vocabulary
8
+
9
+ | Term | Meaning |
10
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
11
+ | **Major** | The `1` in `1.6.0`. Breaking changes allowed. Served under its own prefix: `/api/v1`, `/api/v2`. |
12
+ | **Minor** | The `6` in `1.6.0`. Additive changes only. |
13
+ | **Spec** | One OpenAPI JSON document per released version, stored as `specs/<semver>.json`. |
14
+ | **Floor** | The oldest minor of a major that the app still supports. Everything present at the floor is assumed on every supported backend. |
15
+ | **Newest spec** | The highest spec in `specsDir`. It may be a release the backend has not deployed yet. |
16
+ | **Gated operation / field** | Introduced after the floor. Available only when the connected backend reports a version at or above the one that introduced it. |
17
+ | **Backend ahead** | The backend is newer than the newest spec. Works: unknown fields are ignored, unknown enum values are tolerated. |
18
+
19
+ ## The three rules
20
+
21
+ 1. **Within a major, changes are additive.** New endpoints, new optional request fields, new response fields, new values of _extensible_ enums. Removing, renaming, retyping, or adding a required request field needs a new major.
22
+ 2. **Each app release supports one major**, over a range of its minors (floor to newest spec). Moving to the next major is a new app release.
23
+ 3. **Each major has its own prefix and its own `GET <prefix>/version`**, plus an `X-Api-Version` header on every response, as real semver. The app's base URL includes the major, so it never needs to know other majors exist.
24
+
25
+ ## What the client does for each backend
26
+
27
+ | Connected backend | Behaviour |
28
+ | ----------------------------------- | ------------------------------------------------------------------------------------- |
29
+ | Same major, older than newest spec | Works. Operations added later are hidden or disabled. |
30
+ | Same major, newer than newest spec | Works. Everything known is on. Unknown fields ignored, unknown enum values tolerated. |
31
+ | Same major, below the floor | Blocked: `backend-too-old`. |
32
+ | Same major, `Sunset` header present | Works. `useSunset()` returns the date for a banner. |
33
+ | Another major | Blocked: `app-too-old`. |
34
+ | Version changes mid-session | The next response's `X-Api-Version` triggers re-evaluation and a refetch. |
35
+ | `/version` not valid semver | Blocked: `invalid-version`. |
36
+
37
+ ## How the pieces fit
38
+
39
+ ```
40
+ specs/1.3.0.json … 1.6.0.json + openapi-versions.config.json
41
+ │ codegen (build time, in CI)
42
+ │ 1. keep specs >= floor
43
+ │ 2. oasdiff breaking on each consecutive pair → fail on any break
44
+ │ 3. tolerant merge into one document
45
+ │ 4. since maps: which version introduced each op / field
46
+ │ 5. openapi-typescript on the merged doc, extensible enums as open unions
47
+ ▼
48
+ src/generated/v1/{schema.d.ts, meta.ts, merged.openapi.json}
49
+ │ runtime
50
+ ▼
51
+ resolveCompat(version) → supports(op) / hasField(field) ← the single decision point
52
+ │
53
+ ├─ $api typed WITHOUT gated operations (cannot call newer ops: compile error)
54
+ └─ $gated typed WITH everything; queries go through gate(), mutations inside <Feature>
55
+ ```
56
+
57
+ No component compares version numbers. Everything asks `supports()`.
58
+
59
+ ## Tolerant reader
60
+
61
+ Depend on what you need, ignore the rest. Concretely: merged types make fields added after the floor optional, extensible enums are open (`"a" | "b" | (string & {})`) so a `switch` needs a `default`, and unknown response fields are never an error.
62
+
63
+ ## Trade-offs to accept
64
+
65
+ - It relies on backend discipline. The oasdiff gate belongs in the **backend** CI as well, otherwise a breaking change shipped in a minor makes the merged types describe a shape no server has.
66
+ - Fields added after the floor are typed optional, so the compiler no longer reminds you newer servers always send them. Use `hasField()` for explicit checks.
67
+ - Open enums are open in request bodies too: a typo in a sent value compiles.
@@ -0,0 +1,39 @@
1
+ # Fit with the openapi-ts ecosystem
2
+
3
+ `openapi-versions` is deliberately a thin layer on the openapi-ts packages. It adds one capability they do not have: **one client, a range of server versions**.
4
+
5
+ | openapi-ts piece | Role here | How it is used |
6
+ | ---------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `openapi-typescript` | Generates `schema.d.ts` | Called through its Node API (`openapiTS`, `astToString`) on the _merged_ spec. The only extension point used is the documented `transform` hook (for `x-extensible-enum`). Not forked. |
8
+ | `openapi-fetch` | The HTTP client | One `createClient<paths>` instance, plus one middleware (`onResponse`) for `X-Api-Version` and `Sunset`. |
9
+ | `openapi-react-query` | Hooks | `createClient(fetchClient)` twice: `$api` (types without gated operations) and `$gated` (everything). Query keys are its own, so loaders and `prefetchQuery` work unchanged. |
10
+ | `openapi-typescript-helpers` | Shared types | `StripOps` uses `HttpMethod`. A one-line fix to `Readable<T>` is needed for open enums (below). |
11
+ | `swr-openapi` | SWR hooks | Not supported yet. |
12
+
13
+ Conventions followed: peer dependencies on the user's `openapi-typescript` / `openapi-fetch` / `openapi-react-query` (no bundled copies), ESM, types for every entry point, `$`-prefixed query client as in the openapi-react-query docs, one generated `schema.d.ts` per major.
14
+
15
+ ## Gaps this fills
16
+
17
+ | Tool | What is missing for multi-version clients |
18
+ | ----------------------------------- | --------------------------------------------------------------------------------------------- |
19
+ | openapi-typescript | One spec in, one `paths` out. No merging of minors, no since-versions, closed enums |
20
+ | openapi-fetch / openapi-react-query | Typed against one spec. No capability check at runtime |
21
+ | Hey API `openapi-ts` | Merges _different_ APIs, prefixing colliding paths; not a union of versions of the _same_ API |
22
+ | oasdiff | Detects breaks, produces no client |
23
+
24
+ ## What could be upstreamed
25
+
26
+ 1. **`Readable<T>` / `Writable<T>` primitives fix** in `openapi-typescript-helpers`. The local patch (`patches/`) makes both helpers return primitives as-is, so `string & {}` open unions survive. Without it, `openapi-fetch` response types lose the known values of open enums. Smallest, most valuable contribution; independent of this package.
27
+ 2. **`x-extensible-enum` (or an option) as built-in open enums** in openapi-typescript, replacing the `transform` hook in `codegen/open-enums.ts`.
28
+ 3. **Multi-spec input** (merge + since map) as a documented recipe or plugin in the openapi-typescript docs, with this package as the reference implementation.
29
+
30
+ ## Naming and docs placement
31
+
32
+ - Package name: `openapi-versions` (unclaimed on npm when checked; reserve it before publishing).
33
+ - Fits next to `openapi-fetch` and `openapi-react-query` in the docs sidebar as "Versioning": the pages in `docs/` map to _Introduction_ (concepts), _Setup_ (getting-started), _Recipes_ (how-to), _API_ (reference). The openapi-ts docs are VitePress/Markdown, so these files port as they are.
34
+ - Before proposing it upstream, confirm with the maintainers whether they prefer it as a separate package (as here) or an openapi-typescript feature.
35
+
36
+ ## Known constraints
37
+
38
+ - openapi-typescript 7 requires TypeScript 5 or 6: TypeScript 7 (native port, `latest` on npm) has no `ts.factory`. Upstream issue #2841; open PRs #2867 (bundle a compiler) and #2872 (no runtime compiler dependency).
39
+ - The package is not published yet; `pnpm build` then `npm pack` produces the tarball. A packed install in a fresh app was verified: codegen, generated `client.ts`, and a typecheck of the gating guarantees all pass.
@@ -0,0 +1,99 @@
1
+ # Getting started
2
+
3
+ ## Prerequisites
4
+
5
+ - Node 20+, **TypeScript 5 or 6** (verified on 5.9 and 6.0; openapi-typescript 7 does not run on TypeScript 7, the native compiler, tracked in openapi-ts/openapi-typescript#2841).
6
+ - [`oasdiff`](https://github.com/oasdiff/oasdiff) on the `PATH` (or `OASDIFF_BIN=/path/to/oasdiff`), version 1.34 or newer. `brew install oasdiff` on macOS. Optional locally (codegen warns and skips the gate without it); set `"gate": "required"` or pass `--require-gate` in CI.
7
+ - A backend that: serves each major under its own prefix, answers `GET <prefix>/version` with `{ "version": "1.6.0" }`, sends `X-Api-Version` on every response, and publishes one OpenAPI JSON spec per release.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i openapi-versions
13
+ npm i openapi-fetch openapi-react-query @tanstack/react-query react # peers: the openapi-ts runtime you already use
14
+ npm i -D openapi-typescript "typescript@<7"
15
+ ```
16
+
17
+ `openapi-versions` is a layer on the openapi-ts packages, not a replacement: it generates types with `openapi-typescript`, calls with `openapi-fetch`, hooks with `openapi-react-query`. React and TanStack Query are optional; without them use `openapi-versions/core`.
18
+
19
+ Open enums need a one-line fix to `openapi-typescript-helpers` (see [troubleshooting](troubleshooting.md)); apply `patches/` with `patch-package` in your app until it lands upstream.
20
+
21
+ ## 1. Collect the specs
22
+
23
+ Put one spec per released version in `specs/`, named `<semver>.json`. Take each from the backend CI artifact of the release commit, not from a branch tip.
24
+
25
+ ```
26
+ specs/1.3.0.json
27
+ specs/1.4.0.json
28
+ specs/1.5.0.json
29
+ specs/1.6.0.json # may be a release the backend has not deployed yet
30
+ ```
31
+
32
+ Mark enums that may grow with `x-extensible-enum` in the spec (see [how-to](how-to.md#declare-an-enum-that-can-grow)).
33
+
34
+ ## 2. Write the config
35
+
36
+ `openapi-versions.config.json`:
37
+
38
+ ```json
39
+ {
40
+ "$schema": "./openapi-versions.schema.json",
41
+ "specsDir": "specs",
42
+ "outDir": "src/generated",
43
+ "majors": { "1": { "floor": "1.3.0" } }
44
+ }
45
+ ```
46
+
47
+ The floor must be a real spec file (`specs/1.3.0.json`). See [reference](reference.md#config).
48
+
49
+ ## 3. Generate
50
+
51
+ ```bash
52
+ npx openapi-versions # or an npm script
53
+ ```
54
+
55
+ You get `src/generated/v1/{schema.d.ts, meta.ts, merged.openapi.json}` and `src/generated/index.ts`. Do not edit them. Commit them or regenerate in CI, as you prefer, but be consistent.
56
+
57
+ ## 4. Import from the generated client
58
+
59
+ Codegen writes `<outDir>/v<major>/client.ts`: the runtime already bound to this major's types. The import path is the only place the major appears in app code:
60
+
61
+ ```ts
62
+ import { ApiVersionProvider, useApi, Feature, createApi, type Paths } from "./api/v1/client";
63
+ ```
64
+
65
+ `runtimeImport` in the config sets what `client.ts` imports the runtime from (default `openapi-versions`).
66
+
67
+ ## 5. Use it in React
68
+
69
+ ```tsx
70
+ <QueryClientProvider client={queryClient}>
71
+ <ApiVersionProvider baseUrl="https://host/api/v1">
72
+ <App />
73
+ </ApiVersionProvider>
74
+ </QueryClientProvider>;
75
+ ```
76
+
77
+ ```tsx
78
+ const { $api, $gated, gate } = useApi();
79
+
80
+ // available on every supported backend: plain openapi-react-query
81
+ const user = $api.useQuery("get", "/users/{id}", { params: { path: { id } } });
82
+
83
+ // added after the floor: gate() turns it into skipToken when the backend lacks it
84
+ const activity = useQuery(gate($gated.queryOptions("get", "/users/{id}/activity", { params: { path: { id } } })));
85
+
86
+ // gated mutation: render the trigger inside <Feature>
87
+ const archive = $gated.useMutation("post", "/users/{id}/archive");
88
+ <Feature requires="post /users/{id}/archive">
89
+ <button onClick={() => archive.mutate({ params: { path: { id } } })}>Archive</button>
90
+ </Feature>;
91
+ ```
92
+
93
+ ## 6. Try it without a backend
94
+
95
+ `playground/api/mock-backend.ts` is a fetch-compatible fake backend at any version. `pnpm playground` starts a small app with a control panel that "deploys" versions while the app is open. `pnpm playground:api` prints a table of one build against seven backends.
96
+
97
+ ## 7. Wire CI
98
+
99
+ See [how-to: CI](how-to.md#wire-ci).
package/docs/how-to.md ADDED
@@ -0,0 +1,155 @@
1
+ # How-to
2
+
3
+ Each recipe lists the exact steps. Run `pnpm codegen`, `pnpm typecheck` and `pnpm test` after changes.
4
+ - [Add the spec of a new backend release](#add-the-spec-of-a-new-backend-release)
5
+ - [Use a feature before the backend ships it](#use-a-feature-before-the-backend-ships-it)
6
+ - [Gate a query, a mutation, a field](#gate-a-query-a-mutation-a-field)
7
+ - [Declare an enum that can grow](#declare-an-enum-that-can-grow)
8
+ - [Raise the floor](#raise-the-floor)
9
+ - [Handle a breaking-change failure](#handle-a-breaking-change-failure)
10
+ - [Move the app to the next major](#move-the-app-to-the-next-major)
11
+ - [Use the API in a router loader or outside React](#use-the-api-in-a-router-loader-or-outside-react)
12
+ - [Mobile (React Native)](#mobile-react-native)
13
+ - [Show an "update before" banner](#show-an-update-before-banner)
14
+ - [Test against several backend versions](#test-against-several-backend-versions)
15
+ - [Wire CI](#wire-ci)
16
+
17
+ ## Add the spec of a new backend release
18
+
19
+ 1. Download the spec from the backend release artifact. Save it as `specs/<version>.json` (e.g. `specs/1.7.0.json`). The file name must be the exact semver.
20
+ 2. `pnpm codegen`. The oasdiff gate checks `1.6.0 → 1.7.0`; new operations land in the gated maps automatically.
21
+ 3. `git diff src/generated/v1/meta.ts` shows what is newly gated.
22
+ 4. Build the feature (see next recipe).
23
+
24
+ If the spec changes again before the release, overwrite the file and re-run codegen. The gate catches anything that stopped being additive.
25
+
26
+ ## Use a feature before the backend ships it
27
+
28
+ This is the point of the tool. Add the upcoming spec (as above), build the feature behind `gate()` / `<Feature>`, and release. Users on the old backend never see it and no request is sent. The moment the backend deploys the new version, the next response carries `X-Api-Version`, the provider re-evaluates and the feature appears without a reload.
29
+
30
+ ## Gate a query, a mutation, a field
31
+
32
+ Operation introduced after the floor, **query**:
33
+
34
+ ```tsx
35
+ const { $gated, gate } = useApi();
36
+ const activity = useQuery(gate($gated.queryOptions("get", "/users/{id}/activity", init)));
37
+ // activity.data is undefined while the backend lacks the operation (skipToken: never fetched)
38
+ ```
39
+
40
+ `gate()` output cannot be passed to `useSuspenseQuery` (compile error, by design: it may be skipped).
41
+
42
+ **Mutation**: render the trigger only when supported.
43
+
44
+ ```tsx
45
+ const archive = $gated.useMutation("post", "/users/{id}/archive");
46
+ <Feature requires="post /users/{id}/archive" fallback={null}>
47
+ …
48
+ </Feature>;
49
+ ```
50
+
51
+ **Anywhere**: `useSupports("get /users/{id}/activity")` returns a boolean; outside React use `api.compat.supports(op)`.
52
+
53
+ **Field** added after the floor: its type is optional. Check explicitly when it matters.
54
+
55
+ ```tsx
56
+ const showAvatar = useHasField("User.avatarUrl");
57
+ ```
58
+
59
+ Operation keys are `"<method> <path>"` in lower-case method, e.g. `"post /users/{id}/archive"`; field keys are `"<Schema>.<property>"`. Both are typed (`GatedOp`, `GatedField`), so a typo is a compile error. Calling a gated operation through `$api` is also a compile error: it exists only on `$gated`.
60
+
61
+ ## Declare an enum that can grow
62
+
63
+ Plain `enum` is closed: adding a value is a **breaking** change and oasdiff blocks it. For an enum that will grow within a major, the backend declares it with `x-extensible-enum`:
64
+
65
+ ```json
66
+ "status": { "type": "string", "x-extensible-enum": ["active", "invited", "archived"] }
67
+ ```
68
+
69
+ Codegen turns it into an open union, `"active" | "invited" | "archived" | (string & {})`. Autocomplete lists the known values, unknown ones compile, and a `switch` needs a `default` branch:
70
+
71
+ ```ts
72
+ switch (user.status) {
73
+ case "active": …
74
+ default: return user.status; // value from a newer backend: show raw, do not crash
75
+ }
76
+ ```
77
+
78
+ Converting an existing closed `enum` to `x-extensible-enum` is itself an API-contract decision: do it at a major boundary, or by adding the extension to every spec of the range.
79
+
80
+ ## Raise the floor
81
+
82
+ When a backend minor leaves the support window:
83
+ 1. Edit `majors.<n>.floor` in `openapi-versions.config.json` (the new floor needs a spec file).
84
+ 2. Optionally delete older specs.
85
+ 3. `pnpm codegen`. Operations that were gated only against versions below the new floor become ordinary operations.
86
+ 4. `pnpm typecheck`. `useSupports(...)` / `<Feature requires=…>` for operations that are no longer gated now error or become dead; remove them.
87
+
88
+ ## Handle a breaking-change failure
89
+
90
+ `FAIL v1: 1.6.0 -> 1.7.0` with an oasdiff report means a spec removed, renamed or retyped something, or added a required request field, or added a value to a closed enum. Options, in order of preference:
91
+ 1. Fix the **backend** so the change is additive (add a new field, keep the old one).
92
+ 2. If it was a growable enum, declare it `x-extensible-enum`.
93
+ 3. If the break is intended, it belongs in a new major: [Move to the next major](#move-the-app-to-the-next-major).
94
+
95
+ Do not hand-edit specs to silence the gate: the merged types would then describe a server that does not exist.
96
+
97
+ ## Move the app to the next major
98
+
99
+ The backend serves both majors side by side for a bounded window; the app switches only by a new release.
100
+ 1. Backend ships `/api/v2` and keeps `/api/v1` running, frozen (patches only). Existing apps are unaffected.
101
+ 2. Backend adds `Sunset` to v1 responses. 1.x apps show the banner ([recipe](#show-an-update-before-banner)).
102
+ 3. In the app repo: put the 2.x specs in `specsDir`, set `majors` to `{ "2": { "floor": "2.0.0" } }`, run codegen, change imports from `…/v1/client` to `…/v2/client`, point `baseUrl` at `/api/v2`, fix compile errors.
103
+ 4. When the sunset date passes, the backend removes `/api/v1`. Remaining 1.x builds get the "update the app" screen.
104
+
105
+ ## Use the API in a router loader or outside React
106
+
107
+ ```ts
108
+ import { createApi } from "./api/v1/client";
109
+ const api = await createApi({ baseUrl: "https://host/api/v1" }); // reads /version, throws if unsupported
110
+ await queryClient.ensureQueryData(api.$api.queryOptions("get", "/users/{id}", init));
111
+ ```
112
+
113
+ Without React (scripts, workers, React Native without hooks) import the core from `openapi-versions/core`; the generated client already re-exports `createApi`.
114
+
115
+ Query keys are openapi-react-query's own (`[method, path, params]`), so the same options work in components, loaders and `prefetchQuery`.
116
+
117
+ ## Mobile (React Native)
118
+
119
+ `src/core` is framework-free and the React layer works as is. Only the re-check trigger differs: the provider already refetches `/version` on window focus; on React Native wire app resume with TanStack Query's `focusManager` and `AppState`:
120
+
121
+ ```ts
122
+ import { AppState } from "react-native";
123
+ import { focusManager } from "@tanstack/react-query";
124
+ AppState.addEventListener("change", (s) => focusManager.setFocused(s === "active"));
125
+ ```
126
+
127
+ ## Show an "update before" banner
128
+
129
+ ```tsx
130
+ const sunset = useSunset(); // Date | undefined, set once a response carries a Sunset header
131
+ return sunset && <Banner>Update before {sunset.toDateString()}</Banner>;
132
+ ```
133
+
134
+ ## Test against several backend versions
135
+
136
+ `createMockBackend(version, { sunset? })` from `playground/api/mock-backend.ts` (a test helper in this repo; copy it) returns `{ fetch, calls, deploy(next) }`. Pass `fetch` to `ApiVersionProvider` or `createApi`, assert on `calls` that no gated request was sent, and call `deploy("1.6.0")` then invalidate queries to simulate a deploy mid-session. `sideBySide({ "/api/v1": …, "/api/v2": … })` simulates two majors. See `test/react.test.tsx` and `test/majors.test.tsx`. Pin the compile-time guarantees with `@ts-expect-error` as in `test/types.check.ts`.
137
+
138
+ ## Wire CI
139
+
140
+ Backend CI (the important one): run oasdiff on the previous released spec and the new one and block the merge.
141
+
142
+ ```bash
143
+ oasdiff breaking specs/1.6.0.json new-spec.json --fail-on ERR
144
+ ```
145
+
146
+ Client CI:
147
+
148
+ ```bash
149
+ pnpm install --frozen-lockfile
150
+ pnpm codegen # fails on a breaking pair, bad config, or missing floor spec
151
+ git diff --exit-code src/generated # if generated code is committed
152
+ pnpm typecheck && pnpm test
153
+ ```
154
+
155
+ `pnpm check:breaking` runs only the gate and writes nothing.
@@ -0,0 +1,129 @@
1
+ # Reference
2
+
3
+ ## Config
4
+
5
+ File: `openapi-versions.config.json`. JSON Schema: [`openapi-versions.schema.json`](../openapi-versions.schema.json); add `"$schema": "./openapi-versions.schema.json"` for editor autocompletion. The CLI also validates it and reports what to fix.
6
+
7
+ | Key | Type | Meaning |
8
+ | ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
9
+ | `specsDir` | string | Folder of `<semver>.json` specs. Other file names are ignored. |
10
+ | `outDir` | string | Generated output folder. Safe to delete and regenerate. |
11
+ | `majors` | object | Keyed by major number (`"1"`). Normally one entry per app release. |
12
+ | `majors.<n>.floor` | plain semver | Oldest supported minor of major `n`. Must be in major `n` and must have a spec file. |
13
+ | `gate` | `"auto"` \| `"required"` \| `"off"`, optional | Breaking-change gate. `auto` (default): run oasdiff if installed, otherwise warn and skip. `required`: fail when oasdiff is missing or older than 1.34.0. `off`: never run it. |
14
+ | `runtimeImport` | string, optional | Module the generated `client.ts` imports the runtime from. Default `openapi-versions`. A relative path when developing the runtime in the same repo. |
15
+
16
+ ## CLI
17
+
18
+ ```
19
+ openapi-versions [--config <path>] [--check-only] [--require-gate]
20
+ ```
21
+
22
+ | Flag | Effect |
23
+ | ---------------- | --------------------------------------------------------------------------------------------------------------- |
24
+ | `--config` | Config path. Default `openapi-versions.config.json`. |
25
+ | `--check-only` | Run only the oasdiff gate; write nothing. Fails if oasdiff is missing. |
26
+ | `--require-gate` | Fail when oasdiff is missing or too old, instead of skipping the gate. Same as `"gate": "required"`. Use in CI. |
27
+
28
+ Exit code 1 on: invalid config, missing floor spec, any breaking pair, and (with `--require-gate`, `"gate": "required"` or `--check-only`) a missing or too-old oasdiff (minimum 1.34.0). Environment: `OASDIFF_BIN` overrides the oasdiff binary.
29
+
30
+ ## Package entry points
31
+
32
+ | Import | Content |
33
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
34
+ | `openapi-versions` | Runtime: `createVersionAware(meta)` (core + React), plus the core exports |
35
+ | `openapi-versions/core` | Framework-free runtime: `resolveCompat`, `createApi`, `createApiClient`, `fetchBackendVersion`, `createVersionAwareCore(meta)` |
36
+ | `openapi-versions/codegen` | Programmatic codegen API |
37
+ | `openapi-versions/schema.json` | JSON Schema of the config |
38
+ | `npx openapi-versions` | The CLI |
39
+
40
+ Apps rarely call `createVersionAware` themselves: the generated `client.ts` does, with the app's types. Peers: `openapi-typescript`, `openapi-fetch`, and optionally `openapi-react-query`, `@tanstack/react-query`, `react`.
41
+
42
+ In this repo: `pnpm codegen`, `pnpm check:breaking`, `pnpm build` (bundles with tsup into `dist/`).
43
+
44
+ ## Programmatic API
45
+
46
+ ```ts
47
+ import { build, loadConfig, mergeAll, computeSince, checkChain, openEnums, BreakingChangeError } from "openapi-versions/codegen";
48
+
49
+ const report = await build(loadConfig("openapi-versions.config.json"), { checkOnly: false, log: console.log });
50
+ ```
51
+
52
+ | Export | Purpose |
53
+ | ------------------------------------------ | ------------------------------------------------------------------------------------ |
54
+ | `build(config, { checkOnly?, log? })` | Whole pipeline. Throws `BreakingChangeError` (with `.report`) if a pair is breaking. |
55
+ | `loadConfig(path)` / `validateConfig(obj)` | Read and validate config. |
56
+ | `mergeAll(docs)` / `mergeTolerant(a, b)` | Tolerant merge of OpenAPI documents. |
57
+ | `computeSince(docs, floor)` | `{ ops, gatedOps, gatedFields }` |
58
+ | `checkChain(files)` / `checkPair(a, b)` | oasdiff gate. |
59
+ | `openEnums` | openapi-typescript `transform` hook for `x-extensible-enum`. |
60
+
61
+ ## Pipeline, per major
62
+
63
+ 1. Select specs with the same major and version `>=` floor. The floor spec must exist.
64
+ 2. `oasdiff breaking <prev> <next> --fail-on ERR` for each consecutive pair.
65
+ 3. Tolerant merge (rules below). `info.version` of the merged doc becomes `"<floor> - <newest>"`.
66
+ 4. Since maps.
67
+ 5. `openapi-typescript` on the merged doc with the `openEnums` transform.
68
+
69
+ ## Merge rules
70
+
71
+ | Element | Rule |
72
+ | ----------------------------------------- | --------------------------------------------------------- |
73
+ | paths, operations, properties, parameters | union |
74
+ | `required` array on a schema | intersection (required only if every version requires it) |
75
+ | `required` boolean on a parameter or body | AND |
76
+ | parameter present in only some versions | never required (path params stay required) |
77
+ | `enum`, primitive arrays | union of values, order kept |
78
+ | everything else (descriptions, formats …) | newest wins |
79
+
80
+ The inputs must be non-breaking relative to each other (step 2); otherwise the union can describe a shape no server ever had.
81
+
82
+ ## Since maps
83
+
84
+ - `gatedOps`: `"<method> <path>" → first version that has it`, only for versions above the floor.
85
+ - `gatedFields`: `"<Schema>.<property>" → first version`, only for properties added to a schema that already existed in an earlier version (fields born with their schema are covered by the operation that returns it).
86
+ - `ops`: every operation key in the merged contract.
87
+
88
+ ## Generated files
89
+
90
+ | File | Content |
91
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
92
+ | `<outDir>/v<n>/schema.d.ts` | openapi-typescript types of the merged contract; extensible enums are open unions |
93
+ | `<outDir>/v<n>/meta.ts` | `meta = { major, floor, newest, specs, ops, gatedOps, gatedFields }` plus `Op`, `GatedOp`, `GatedField` types |
94
+ | `<outDir>/v<n>/merged.openapi.json` | the merged document (useful to inspect or diff) |
95
+ | `<outDir>/v<n>/client.ts` | the runtime bound to this major: `ApiVersionProvider`, `useApi`, `useSupports`, `useHasField`, `useSunset`, `Feature`, `createApi`, types `Paths`, `GatedOp`, `GatedField`, `Api` |
96
+ | `<outDir>/index.ts` | `majors`, `SupportedMajor`, and `PathsV<n>`, `GatedOpV<n>`, `GatedFieldV<n>` aliases |
97
+
98
+ ## Runtime API
99
+
100
+ Exported from the generated `client.ts` (bound to your types) and, unbound, from `openapi-versions` / `openapi-versions/core`:
101
+
102
+ | Symbol | Meaning |
103
+ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `resolveCompat(version, majors, { prereleaseAsRelease? })` | Returns `{ status: "ok", version, major, backendAhead, supports(op), hasField(field) }` or `{ status: "backend-too-old", floor }`, `{ status: "app-too-old", supportedMajors }`, `{ status: "invalid-version" }` |
105
+ | `createApi({ baseUrl, fetch?, prereleaseAsRelease? })` (unbound: `createApi(meta, opts)`) | Reads `<baseUrl>/version`, returns `Api`; throws if not `ok` |
106
+ | `Api` | `{ compat, client: { core, gated }, $api, $gated, gate() }` |
107
+ | `$api` | openapi-react-query client typed **without** gated operations |
108
+ | `$gated` | `{ queryOptions, useMutation }` typed with everything |
109
+ | `gate(options)` | Replaces `queryFn` with `skipToken` when the backend lacks the operation |
110
+ | `createApiClient({ baseUrl, fetch?, onVersionHeader?, onSunsetHeader? })` | The single openapi-fetch client; middleware reads `X-Api-Version` and `Sunset` |
111
+
112
+ React part:
113
+
114
+ | Symbol | Meaning |
115
+ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `<ApiVersionProvider baseUrl fetch? loading? renderBlocked? prereleaseAsRelease?>` | Fetches `/version` (React Query key `["api-version"]`, stale 5 min, refetch on focus), renders `renderBlocked(compat)` when not `ok`, re-evaluates when `X-Api-Version` changes |
117
+ | `useApi()` | The `Api` object; throws outside the provider |
118
+ | `useSupports(op)` / `useHasField(field)` | Booleans |
119
+ | `<Feature requires fallback?>` | Renders children only if the operation is supported |
120
+ | `useSunset()` | `Date | undefined`from the`Sunset` header |
121
+
122
+ ## Backend contract
123
+
124
+ - Additive changes only inside a major (oasdiff gate).
125
+ - Growable enums declared `x-extensible-enum`.
126
+ - Each major under its own prefix; `GET <prefix>/version` → `{ "version": "<semver>" }`; `X-Api-Version` on every response; real semver.
127
+ - `/api/v1/version` reports the version of the v1 **contract**, not of the deployment; after v2 ships, v1 is frozen (patch versions only).
128
+ - A major being retired sends `Sunset: <HTTP-date>` (RFC 8594) on all its responses.
129
+ - Each release publishes its spec as a CI artifact from the release commit.
@@ -0,0 +1,21 @@
1
+ # Troubleshooting
2
+
3
+ | Symptom | Cause | Fix |
4
+ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
5
+ | `Breaking-change gate cannot run: oasdiff not found` (or `WARN breaking-change gate skipped`) | oasdiff not on `PATH`, or older than 1.34.0 | Install it (`brew install oasdiff`) or set `OASDIFF_BIN=/abs/path/oasdiff`. The error only appears with `"gate": "required"`, `--require-gate` or `--check-only`; otherwise codegen warns and continues |
6
+ | `Floor 1.3.0 has no spec file (oldest found: 1.4.0)` | The floor needs its own `specs/1.3.0.json` | Add the spec or lower/raise the floor to an existing one |
7
+ | `No spec found for major 1 at or above floor 1.3.0` | Wrong `specsDir`, or files not named `<semver>.json` | Check the directory and file names; non-semver names are ignored |
8
+ | `config: majors.1.floor 2.0.0 is not in major 1` | Floor belongs to another major | Fix the floor or the major key |
9
+ | `FAIL v1: a -> b` | A spec pair is not additive | [How-to: breaking-change failure](how-to.md#handle-a-breaking-change-failure) |
10
+ | oasdiff reports `response-property-enum-value-added` | A closed `enum` gained a value | Declare it `x-extensible-enum` (see how-to) or move it to a new major |
11
+ | Calling a newer operation through `$api` does not compile | By design: it exists only on `$gated` | Use `$gated` with `gate()` or `<Feature>` |
12
+ | `useSuspenseQuery(gate(...))` does not compile | A gated query may be skipped | Use `useQuery` |
13
+ | Enum type collapses to `string` and loses autocompletion in response data | `openapi-fetch`'s `Readable<T>` maps over `string & {}` | Apply the one-line patch in `patches/` to `openapi-typescript-helpers` with `patch-package` (add `"postinstall": "patch-package"` to your app). Until it is fixed upstream |
14
+ | App says "app too old" for a date-style version such as `2026.10.1` | Valid semver with major 2026 | The backend must report real `major.minor.patch` |
15
+ | `GET /version failed: 404` | `/version` resolved from the host root | `baseUrl` must include the major prefix; `/version` is read relative to it (`https://host/api/v1/version`) |
16
+ | Endless refetching after a deploy | Version header seen again before React re-rendered | The provider records the new version synchronously; do not clone this logic without that |
17
+ | Staging on `1.6.0-rc.1` hides 1.6 features | A prerelease sorts before its release | Pass `prereleaseAsRelease` to `ApiVersionProvider` / `createApi` for staging builds |
18
+ | A gated field is `undefined` on a new backend | Fields added after the floor are typed optional and may be absent | Use `hasField()` and handle absence |
19
+ | `src/generated` missing, imports fail | Codegen not run | `pnpm codegen` |
20
+ | `Cannot read properties of undefined (reading 'createKeywordTypeNode')` when generating | openapi-typescript 7 needs the TypeScript 5 or 6 JS compiler API; TypeScript 7 (native, now `latest` on npm) does not expose it (upstream #2841) | Install `typescript@<7` (5 or 6) |
21
+ | `Cannot find module 'openapi-versions'` from a generated `client.ts` | Runtime not installed, or `runtimeImport` wrong | `npm i openapi-versions`, or set `runtimeImport` in the config |