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.
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/dist/chunk-4O4PFYPM.js +288 -0
- package/dist/chunk-4O4PFYPM.js.map +1 -0
- package/dist/chunk-QZ47EPTW.js +96 -0
- package/dist/chunk-QZ47EPTW.js.map +1 -0
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -0
- package/dist/codegen.d.ts +104 -0
- package/dist/codegen.js +29 -0
- package/dist/codegen.js.map +1 -0
- package/dist/core.d.ts +146 -0
- package/dist/core.js +19 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/docs/README.md +22 -0
- package/docs/adr/0001-separate-package-not-upstream.md +16 -0
- package/docs/adr/0002-extensible-enums-via-x-extensible-enum.md +16 -0
- package/docs/adr/0003-gating-model.md +16 -0
- package/docs/adr/0004-oasdiff-breaking-gate.md +16 -0
- package/docs/adr/0005-patch-openapi-typescript-helpers.md +16 -0
- package/docs/adr/0006-tooling-mise-pnpm-biome.md +16 -0
- package/docs/adr/0007-mit-license.md +16 -0
- package/docs/adr/README.md +13 -0
- package/docs/adr/template.md +16 -0
- package/docs/concepts.md +67 -0
- package/docs/ecosystem.md +39 -0
- package/docs/getting-started.md +99 -0
- package/docs/how-to.md +155 -0
- package/docs/reference.md +129 -0
- package/docs/troubleshooting.md +21 -0
- package/docs/upstream-drafts.md +166 -0
- package/openapi-versions.schema.json +57 -0
- package/package.json +114 -4
- package/patches/openapi-typescript-helpers@0.1.0.patch +51 -0
- package/skills/openapi-versions-add-spec/SKILL.md +37 -0
- package/skills/openapi-versions-gate-feature/SKILL.md +34 -0
- package/skills/openapi-versions-setup/SKILL.md +35 -0
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../codegen/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Usage: openapi-versions [--config openapi-versions.config.json] [--check-only] [--require-gate]\n * --check-only run only the oasdiff breaking-change gate, write nothing\n * --require-gate fail if oasdiff is missing instead of skipping the gate (use in CI)\n */\nimport { build } from \"./build\";\nimport { loadConfig } from \"./config\";\n\nconst args = process.argv.slice(2);\nconst configPath = args.includes(\"--config\") ? args[args.indexOf(\"--config\") + 1] : \"openapi-versions.config.json\";\n\ntry {\n await build(loadConfig(configPath), {\n checkOnly: args.includes(\"--check-only\"),\n gate: args.includes(\"--require-gate\") ? \"required\" : undefined,\n });\n} catch (e) {\n console.error(`\\n${(e as Error).message}`);\n process.exit(1);\n}\n"],"mappings":";;;;;;;AASA,IAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,IAAM,aAAa,KAAK,SAAS,UAAU,IAAI,KAAK,KAAK,QAAQ,UAAU,IAAI,CAAC,IAAI;AAEpF,IAAI;AACF,QAAM,MAAM,WAAW,UAAU,GAAG;AAAA,IAClC,WAAW,KAAK,SAAS,cAAc;AAAA,IACvC,MAAM,KAAK,SAAS,gBAAgB,IAAI,aAAa;AAAA,EACvD,CAAC;AACH,SAAS,GAAG;AACV,UAAQ,MAAM;AAAA,EAAM,EAAY,OAAO,EAAE;AACzC,UAAQ,KAAK,CAAC;AAChB;","names":[]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { OpenAPITSOptions } from 'openapi-typescript';
|
|
2
|
+
|
|
3
|
+
/** Oldest oasdiff the gate is documented for (tested with 1.34.1). */
|
|
4
|
+
declare const MIN_OASDIFF_VERSION = "1.34.0";
|
|
5
|
+
type OasdiffProbe = {
|
|
6
|
+
ok: true;
|
|
7
|
+
bin: string;
|
|
8
|
+
version: string;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
bin: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
/** Looks for a usable oasdiff: present, and at least MIN_OASDIFF_VERSION. */
|
|
15
|
+
declare function probeOasdiff(): OasdiffProbe;
|
|
16
|
+
interface BreakingResult {
|
|
17
|
+
from: string;
|
|
18
|
+
to: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
report: string;
|
|
21
|
+
}
|
|
22
|
+
declare function checkPair(fromFile: string, toFile: string): Omit<BreakingResult, "from" | "to">;
|
|
23
|
+
declare function checkChain(files: {
|
|
24
|
+
version: string;
|
|
25
|
+
file: string;
|
|
26
|
+
}[]): BreakingResult[];
|
|
27
|
+
|
|
28
|
+
/** auto: run the oasdiff gate when oasdiff is installed, warn and skip otherwise. required: fail if it cannot run. off: never run it. */
|
|
29
|
+
type GateMode = "auto" | "required" | "off";
|
|
30
|
+
interface Config {
|
|
31
|
+
specsDir: string;
|
|
32
|
+
outDir: string;
|
|
33
|
+
majors: Record<string, {
|
|
34
|
+
floor: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Module the generated client.ts imports the runtime from. Default: the package name. */
|
|
37
|
+
runtimeImport?: string;
|
|
38
|
+
/** Breaking-change gate. Default "auto". */
|
|
39
|
+
gate?: GateMode;
|
|
40
|
+
}
|
|
41
|
+
declare function validateConfig(raw: unknown, source?: string): Config;
|
|
42
|
+
declare function loadConfig(path: string): Config;
|
|
43
|
+
|
|
44
|
+
/** `gate` overrides config.gate (the CLI's --require-gate). */
|
|
45
|
+
declare function build(config: Config, { checkOnly, log, gate }?: {
|
|
46
|
+
checkOnly?: boolean;
|
|
47
|
+
log?: (msg: string) => void;
|
|
48
|
+
gate?: GateMode;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
major: number;
|
|
51
|
+
specs: string[];
|
|
52
|
+
breaking: ReturnType<typeof checkChain>;
|
|
53
|
+
}[]>;
|
|
54
|
+
declare class BreakingChangeError extends Error {
|
|
55
|
+
report: Awaited<ReturnType<typeof build>>;
|
|
56
|
+
constructor(report: Awaited<ReturnType<typeof build>>);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tolerant merge of several OpenAPI documents of the SAME major version.
|
|
61
|
+
*
|
|
62
|
+
* Result describes every input version at once:
|
|
63
|
+
* - paths, operations, properties, parameters: UNION
|
|
64
|
+
* - `required` arrays (schema objects): INTERSECTION
|
|
65
|
+
* - `required` booleans (params, bodies): AND (true only if every version says true)
|
|
66
|
+
* - enums: UNION of values
|
|
67
|
+
* - everything else (descriptions, formats…): newest wins
|
|
68
|
+
*
|
|
69
|
+
* Inputs MUST be non-breaking relative to each other (checked by oasdiff first),
|
|
70
|
+
* otherwise the union can describe a shape no server ever had.
|
|
71
|
+
*/
|
|
72
|
+
type Json = any;
|
|
73
|
+
declare function mergeTolerant(older: Json, newer: Json): Json;
|
|
74
|
+
declare const mergeAll: (docs: Json[]) => any;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* openapi-typescript `transform` hook for EXTENSIBLE enums.
|
|
78
|
+
*
|
|
79
|
+
* The backend declares enums that may grow within a major with `x-extensible-enum`
|
|
80
|
+
* (plain `enum` stays closed: adding a value to it is a breaking change, and oasdiff
|
|
81
|
+
* flags it). Extensible enums become OPEN unions:
|
|
82
|
+
* "active" | "invited" | (string & {})
|
|
83
|
+
* Autocomplete still lists the known values, a value added by a newer backend
|
|
84
|
+
* compiles, and `switch` statements need a `default` branch.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
declare const openEnums: NonNullable<OpenAPITSOptions["transform"]>;
|
|
88
|
+
|
|
89
|
+
interface VersionedDoc {
|
|
90
|
+
version: string;
|
|
91
|
+
doc: any;
|
|
92
|
+
}
|
|
93
|
+
declare function computeSince(group: VersionedDoc[], floor: string): {
|
|
94
|
+
ops: string[];
|
|
95
|
+
gatedOps: {
|
|
96
|
+
[k: string]: string;
|
|
97
|
+
};
|
|
98
|
+
gatedFields: {
|
|
99
|
+
[k: string]: string;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export { BreakingChangeError, MIN_OASDIFF_VERSION, build, checkChain, checkPair, computeSince, loadConfig, mergeAll, mergeTolerant, openEnums, probeOasdiff, validateConfig };
|
|
104
|
+
export type { BreakingResult, Config, GateMode, OasdiffProbe, VersionedDoc };
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BreakingChangeError,
|
|
3
|
+
MIN_OASDIFF_VERSION,
|
|
4
|
+
build,
|
|
5
|
+
checkChain,
|
|
6
|
+
checkPair,
|
|
7
|
+
computeSince,
|
|
8
|
+
loadConfig,
|
|
9
|
+
mergeAll,
|
|
10
|
+
mergeTolerant,
|
|
11
|
+
openEnums,
|
|
12
|
+
probeOasdiff,
|
|
13
|
+
validateConfig
|
|
14
|
+
} from "./chunk-4O4PFYPM.js";
|
|
15
|
+
export {
|
|
16
|
+
BreakingChangeError,
|
|
17
|
+
MIN_OASDIFF_VERSION,
|
|
18
|
+
build,
|
|
19
|
+
checkChain,
|
|
20
|
+
checkPair,
|
|
21
|
+
computeSince,
|
|
22
|
+
loadConfig,
|
|
23
|
+
mergeAll,
|
|
24
|
+
mergeTolerant,
|
|
25
|
+
openEnums,
|
|
26
|
+
probeOasdiff,
|
|
27
|
+
validateConfig
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=codegen.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Client, Middleware } from 'openapi-fetch';
|
|
2
|
+
import { HttpMethod } from 'openapi-typescript-helpers';
|
|
3
|
+
import { skipToken } from '@tanstack/react-query';
|
|
4
|
+
import { OpenapiQueryClient } from 'openapi-react-query';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `paths` with the gated operations removed (turned into `method?: never`, exactly how
|
|
8
|
+
* openapi-typescript marks a method a path does not have). A client typed with this
|
|
9
|
+
* cannot call an operation newer than the floor: TypeScript rejects it, so every call
|
|
10
|
+
* to a newer operation has to go through the gated client, next to a capability check.
|
|
11
|
+
*/
|
|
12
|
+
type StripOps<P, Ops extends string> = {
|
|
13
|
+
[Path in keyof P]: {
|
|
14
|
+
[K in keyof P[Path] as K extends HttpMethod ? (`${K} ${Path & string}` extends Ops ? never : K) : K]: P[Path][K];
|
|
15
|
+
} & {
|
|
16
|
+
[K in HttpMethod as `${K} ${Path & string}` extends Ops ? K : never]?: never;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One openapi-fetch client, exposed twice:
|
|
22
|
+
* - `core`: typed with gated operations removed (safe on every supported backend)
|
|
23
|
+
* - `gated`: full merged types, for operations that need a capability check
|
|
24
|
+
* Both are the SAME runtime object; only the types differ.
|
|
25
|
+
*
|
|
26
|
+
* A middleware reads `X-Api-Version` on every response so the app notices a backend
|
|
27
|
+
* deploy during a session (web tab left open, mobile app resumed), and `Sunset`
|
|
28
|
+
* so it can warn before the backend stops serving this major.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** `P` = generated `paths`, `G` = union of gated operation keys */
|
|
32
|
+
type CorePaths<P extends {}, G extends string> = StripOps<P, G>;
|
|
33
|
+
interface ApiClient<P extends {}, G extends string> {
|
|
34
|
+
core: Client<CorePaths<P, G>>;
|
|
35
|
+
gated: Client<P>;
|
|
36
|
+
}
|
|
37
|
+
interface ApiHeaderHandlers {
|
|
38
|
+
onVersionHeader?: (version: string) => void;
|
|
39
|
+
onSunsetHeader?: (sunset: Date) => void;
|
|
40
|
+
}
|
|
41
|
+
/** Reads X-Api-Version (deploys mid-session) and Sunset (major being retired) on every response. */
|
|
42
|
+
declare function apiHeadersMiddleware({ onVersionHeader, onSunsetHeader }: ApiHeaderHandlers): Middleware;
|
|
43
|
+
/**
|
|
44
|
+
* `baseUrl` includes the major: https://host/api/v1. The backend serves each major
|
|
45
|
+
* under its own prefix, with its own /version, so one app build talks to one major.
|
|
46
|
+
*/
|
|
47
|
+
declare function createApiClient<P extends {}, G extends string>(opts: {
|
|
48
|
+
baseUrl: string;
|
|
49
|
+
fetch?: typeof globalThis.fetch;
|
|
50
|
+
} & ApiHeaderHandlers): ApiClient<P, G>;
|
|
51
|
+
/** GET {baseUrl}/version, e.g. https://host/api/v1/version. Resolved RELATIVE to the base path. */
|
|
52
|
+
declare function fetchBackendVersion(baseUrl: string, fetchImpl?: typeof fetch): Promise<string>;
|
|
53
|
+
|
|
54
|
+
interface MajorMeta {
|
|
55
|
+
readonly major: number;
|
|
56
|
+
readonly floor: string;
|
|
57
|
+
readonly newest: string;
|
|
58
|
+
readonly ops: readonly string[];
|
|
59
|
+
readonly gatedOps: Readonly<Record<string, string>>;
|
|
60
|
+
readonly gatedFields: Readonly<Record<string, string>>;
|
|
61
|
+
}
|
|
62
|
+
type Compat = {
|
|
63
|
+
status: "ok";
|
|
64
|
+
version: string;
|
|
65
|
+
major: number;
|
|
66
|
+
/** backend is newer than the newest spec this client was built with (forward compatibility) */
|
|
67
|
+
backendAhead: boolean;
|
|
68
|
+
supports(op: string): boolean;
|
|
69
|
+
hasField(field: string): boolean;
|
|
70
|
+
} | {
|
|
71
|
+
status: "backend-too-old";
|
|
72
|
+
version: string;
|
|
73
|
+
major: number;
|
|
74
|
+
floor: string;
|
|
75
|
+
} | {
|
|
76
|
+
status: "app-too-old";
|
|
77
|
+
version: string;
|
|
78
|
+
major: number;
|
|
79
|
+
supportedMajors: number[];
|
|
80
|
+
} | {
|
|
81
|
+
status: "invalid-version";
|
|
82
|
+
version: string;
|
|
83
|
+
};
|
|
84
|
+
interface CompatOptions {
|
|
85
|
+
/**
|
|
86
|
+
* Treat `1.6.0-rc.1` as `1.6.0` (staging running a release candidate gets the new features).
|
|
87
|
+
* Default false: an rc of 1.6.0 counts as 1.5.x, which is the safe reading of semver.
|
|
88
|
+
*/
|
|
89
|
+
prereleaseAsRelease?: boolean;
|
|
90
|
+
}
|
|
91
|
+
declare function resolveCompat(rawVersion: string, majors: Readonly<Record<number, MajorMeta>>, { prereleaseAsRelease }?: CompatOptions): Compat;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The object components and loaders use: resolved compatibility + typed
|
|
95
|
+
* openapi-react-query clients + gating.
|
|
96
|
+
*
|
|
97
|
+
* $api operations every supported backend has (floor and older)
|
|
98
|
+
* $gated operations added after the floor: queries go through gate(),
|
|
99
|
+
* mutations are rendered inside <Feature>
|
|
100
|
+
*
|
|
101
|
+
* Generic over the generated types: `P` = `paths`, `G` = union of gated operation keys.
|
|
102
|
+
* Apps get it pre-bound from the generated `v<major>/client.ts`.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
type OkCompat = Extract<Compat, {
|
|
106
|
+
status: "ok";
|
|
107
|
+
}>;
|
|
108
|
+
type QueryOptionsLike = {
|
|
109
|
+
queryKey: readonly unknown[];
|
|
110
|
+
queryFn?: unknown;
|
|
111
|
+
};
|
|
112
|
+
type Gated<O extends QueryOptionsLike> = Omit<O, "queryFn"> & {
|
|
113
|
+
queryFn: O["queryFn"] | typeof skipToken;
|
|
114
|
+
};
|
|
115
|
+
interface Api<P extends {}, G extends string> {
|
|
116
|
+
compat: OkCompat;
|
|
117
|
+
client: ApiClient<P, G>;
|
|
118
|
+
$api: OpenapiQueryClient<StripOps<P, G>>;
|
|
119
|
+
$gated: Pick<OpenapiQueryClient<P>, "queryOptions" | "useMutation">;
|
|
120
|
+
/**
|
|
121
|
+
* Replace queryFn with skipToken when the connected backend lacks the operation.
|
|
122
|
+
* The operation is read from the key openapi-react-query builds ([method, path, init]),
|
|
123
|
+
* and the result type includes skipToken, so useSuspenseQuery rejects it.
|
|
124
|
+
*/
|
|
125
|
+
gate<O extends QueryOptionsLike>(options: O): Gated<O>;
|
|
126
|
+
}
|
|
127
|
+
declare function buildApi<P extends {}, G extends string>(compat: OkCompat, client: ApiClient<P, G>): Api<P, G>;
|
|
128
|
+
type CreateApiOptions = {
|
|
129
|
+
baseUrl: string;
|
|
130
|
+
fetch?: typeof fetch;
|
|
131
|
+
} & CompatOptions;
|
|
132
|
+
/** Reads `<baseUrl>/version`, resolves compatibility, throws if the backend is not supported. */
|
|
133
|
+
declare function createApi<P extends {}, G extends string>(meta: MajorMeta, opts: CreateApiOptions): Promise<Api<P, G>>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Bind the runtime to one generated contract. Normally called by the generated
|
|
137
|
+
* `v<major>/client.ts`; `P` = paths, `G` = gated operation keys.
|
|
138
|
+
*/
|
|
139
|
+
declare function createVersionAwareCore<P extends {}, G extends string>(meta: MajorMeta): {
|
|
140
|
+
meta: MajorMeta;
|
|
141
|
+
createApi: (opts: CreateApiOptions) => Promise<Api<P, G>>;
|
|
142
|
+
buildApi: (compat: OkCompat, client: ApiClient<P, G>) => Api<P, G>;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export { apiHeadersMiddleware, buildApi, createApi, createApiClient, createVersionAwareCore, fetchBackendVersion, resolveCompat };
|
|
146
|
+
export type { Api, ApiClient, Compat, CompatOptions, CorePaths, CreateApiOptions, Gated, MajorMeta, OkCompat, StripOps };
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiHeadersMiddleware,
|
|
3
|
+
buildApi,
|
|
4
|
+
createApi,
|
|
5
|
+
createApiClient,
|
|
6
|
+
createVersionAwareCore,
|
|
7
|
+
fetchBackendVersion,
|
|
8
|
+
resolveCompat
|
|
9
|
+
} from "./chunk-QZ47EPTW.js";
|
|
10
|
+
export {
|
|
11
|
+
apiHeadersMiddleware,
|
|
12
|
+
buildApi,
|
|
13
|
+
createApi,
|
|
14
|
+
createApiClient,
|
|
15
|
+
createVersionAwareCore,
|
|
16
|
+
fetchBackendVersion,
|
|
17
|
+
resolveCompat
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=core.js.map
|
package/dist/core.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CompatOptions, Compat, OkCompat, MajorMeta, Api, CreateApiOptions, ApiClient } from './core.js';
|
|
2
|
+
export { CorePaths, Gated, StripOps, apiHeadersMiddleware, buildApi, createApi, createApiClient, createVersionAwareCore, fetchBackendVersion, resolveCompat } from './core.js';
|
|
3
|
+
import * as react from 'react';
|
|
4
|
+
import { ReactNode } from 'react';
|
|
5
|
+
import 'openapi-fetch';
|
|
6
|
+
import 'openapi-typescript-helpers';
|
|
7
|
+
import '@tanstack/react-query';
|
|
8
|
+
import 'openapi-react-query';
|
|
9
|
+
|
|
10
|
+
declare const API_VERSION_KEY: readonly ["api-version"];
|
|
11
|
+
interface ApiVersionProviderProps extends CompatOptions {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
fetch?: typeof globalThis.fetch;
|
|
15
|
+
/** shown while /version loads */
|
|
16
|
+
loading?: ReactNode;
|
|
17
|
+
/** shown when the backend is outside the supported range */
|
|
18
|
+
renderBlocked?: (compat: Exclude<Compat, OkCompat>) => ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `P` = generated paths, `G` = gated operation keys, `F` = gated field keys */
|
|
22
|
+
declare function createVersionAware<P extends {}, G extends string, F extends string>(meta: MajorMeta): {
|
|
23
|
+
ApiVersionProvider: ({ baseUrl, children, fetch: fetchImpl, loading, renderBlocked, ...compatOpts }: ApiVersionProviderProps) => react.JSX.Element;
|
|
24
|
+
useApi: () => Api<P, G>;
|
|
25
|
+
useSunset: () => Date | undefined;
|
|
26
|
+
useSupports: (op: G) => boolean;
|
|
27
|
+
useHasField: (field: F) => boolean;
|
|
28
|
+
Feature: ({ requires, children, fallback }: {
|
|
29
|
+
requires: G;
|
|
30
|
+
children: react.ReactNode;
|
|
31
|
+
fallback?: react.ReactNode;
|
|
32
|
+
}) => react.JSX.Element;
|
|
33
|
+
meta: MajorMeta;
|
|
34
|
+
createApi: (opts: CreateApiOptions) => Promise<Api<P, G>>;
|
|
35
|
+
buildApi: (compat: OkCompat, client: ApiClient<P, G>) => Api<P, G>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export { API_VERSION_KEY, Api, ApiClient, Compat, CompatOptions, CreateApiOptions, MajorMeta, OkCompat, createVersionAware };
|
|
39
|
+
export type { ApiVersionProviderProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiHeadersMiddleware,
|
|
3
|
+
buildApi,
|
|
4
|
+
createApi,
|
|
5
|
+
createApiClient,
|
|
6
|
+
createVersionAwareCore,
|
|
7
|
+
fetchBackendVersion,
|
|
8
|
+
resolveCompat
|
|
9
|
+
} from "./chunk-QZ47EPTW.js";
|
|
10
|
+
|
|
11
|
+
// src/react/ApiVersionProvider.tsx
|
|
12
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { createContext, useContext, useMemo, useRef, useState } from "react";
|
|
14
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
15
|
+
var API_VERSION_KEY = ["api-version"];
|
|
16
|
+
function defaultBlocked(c) {
|
|
17
|
+
switch (c.status) {
|
|
18
|
+
case "app-too-old":
|
|
19
|
+
return /* @__PURE__ */ jsxs("p", { children: [
|
|
20
|
+
"This app does not support API ",
|
|
21
|
+
c.version,
|
|
22
|
+
". Please update the app."
|
|
23
|
+
] });
|
|
24
|
+
case "backend-too-old":
|
|
25
|
+
return /* @__PURE__ */ jsxs("p", { children: [
|
|
26
|
+
"This app needs API ",
|
|
27
|
+
c.floor,
|
|
28
|
+
" or newer (connected: ",
|
|
29
|
+
c.version,
|
|
30
|
+
")."
|
|
31
|
+
] });
|
|
32
|
+
case "invalid-version":
|
|
33
|
+
return /* @__PURE__ */ jsxs("p", { children: [
|
|
34
|
+
'Unrecognised API version "',
|
|
35
|
+
c.version,
|
|
36
|
+
'".'
|
|
37
|
+
] });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function createReactBindings(meta) {
|
|
41
|
+
const majors = { [meta.major]: meta };
|
|
42
|
+
const ApiContext = createContext(null);
|
|
43
|
+
const SunsetContext = createContext(void 0);
|
|
44
|
+
function ApiVersionProvider({
|
|
45
|
+
baseUrl,
|
|
46
|
+
children,
|
|
47
|
+
fetch: fetchImpl,
|
|
48
|
+
loading = null,
|
|
49
|
+
renderBlocked = defaultBlocked,
|
|
50
|
+
...compatOpts
|
|
51
|
+
}) {
|
|
52
|
+
const queryClient = useQueryClient();
|
|
53
|
+
const current = useRef(void 0);
|
|
54
|
+
const [sunset, setSunset] = useState(void 0);
|
|
55
|
+
const client = useMemo(
|
|
56
|
+
() => createApiClient({
|
|
57
|
+
baseUrl,
|
|
58
|
+
fetch: fetchImpl,
|
|
59
|
+
// Backend deployed mid-session: store the new version, refetch everything else.
|
|
60
|
+
onVersionHeader: (v) => {
|
|
61
|
+
if (current.current === void 0 || v === current.current) return;
|
|
62
|
+
current.current = v;
|
|
63
|
+
queryClient.setQueryData(API_VERSION_KEY, v);
|
|
64
|
+
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] !== API_VERSION_KEY[0] });
|
|
65
|
+
},
|
|
66
|
+
// same timestamp -> same state, no re-render
|
|
67
|
+
onSunsetHeader: (d) => setSunset((prev) => prev?.getTime() === d.getTime() ? prev : d)
|
|
68
|
+
}),
|
|
69
|
+
[baseUrl, fetchImpl, queryClient]
|
|
70
|
+
);
|
|
71
|
+
const version = useQuery({
|
|
72
|
+
queryKey: API_VERSION_KEY,
|
|
73
|
+
queryFn: () => fetchBackendVersion(baseUrl, fetchImpl),
|
|
74
|
+
staleTime: 5 * 6e4,
|
|
75
|
+
refetchOnWindowFocus: true
|
|
76
|
+
// mobile resume / tab refocus re-checks the backend
|
|
77
|
+
});
|
|
78
|
+
current.current = version.data;
|
|
79
|
+
const compat = useMemo(
|
|
80
|
+
() => version.data ? resolveCompat(version.data, majors, compatOpts) : void 0,
|
|
81
|
+
[version.data, compatOpts.prereleaseAsRelease]
|
|
82
|
+
);
|
|
83
|
+
const api = useMemo(
|
|
84
|
+
() => compat?.status === "ok" ? buildApi(compat, client) : void 0,
|
|
85
|
+
[compat, client]
|
|
86
|
+
);
|
|
87
|
+
if (!compat) return /* @__PURE__ */ jsx(Fragment, { children: loading });
|
|
88
|
+
if (compat.status !== "ok") return /* @__PURE__ */ jsx(Fragment, { children: renderBlocked(compat) });
|
|
89
|
+
return /* @__PURE__ */ jsx(ApiContext.Provider, { value: api, children: /* @__PURE__ */ jsx(SunsetContext.Provider, { value: sunset, children }) });
|
|
90
|
+
}
|
|
91
|
+
function useApi() {
|
|
92
|
+
const ctx = useContext(ApiContext);
|
|
93
|
+
if (!ctx) throw new Error("useApi must be used inside <ApiVersionProvider>");
|
|
94
|
+
return ctx;
|
|
95
|
+
}
|
|
96
|
+
const useSunset = () => useContext(SunsetContext);
|
|
97
|
+
const useSupports = (op) => useApi().compat.supports(op);
|
|
98
|
+
const useHasField = (field) => useApi().compat.hasField(field);
|
|
99
|
+
function Feature({ requires, children, fallback = null }) {
|
|
100
|
+
return /* @__PURE__ */ jsx(Fragment, { children: useSupports(requires) ? children : fallback });
|
|
101
|
+
}
|
|
102
|
+
return { ApiVersionProvider, useApi, useSunset, useSupports, useHasField, Feature };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/index.ts
|
|
106
|
+
function createVersionAware(meta) {
|
|
107
|
+
return { ...createVersionAwareCore(meta), ...createReactBindings(meta) };
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
API_VERSION_KEY,
|
|
111
|
+
apiHeadersMiddleware,
|
|
112
|
+
buildApi,
|
|
113
|
+
createApi,
|
|
114
|
+
createApiClient,
|
|
115
|
+
createVersionAware,
|
|
116
|
+
createVersionAwareCore,
|
|
117
|
+
fetchBackendVersion,
|
|
118
|
+
resolveCompat
|
|
119
|
+
};
|
|
120
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/ApiVersionProvider.tsx","../src/index.ts"],"sourcesContent":["/**\n * React layer: version detection with React Query, capability gating, and\n * re-evaluation when the backend version changes during a session.\n *\n * `createReactBindings(meta)` returns the provider and hooks bound to one generated\n * contract (`P` = paths, `G` = gated operation keys, `F` = gated field keys).\n */\n\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { createContext, type ReactNode, useContext, useMemo, useRef, useState } from \"react\";\nimport { type Api, buildApi, type OkCompat } from \"../core/api\";\nimport { createApiClient, fetchBackendVersion } from \"../core/client\";\nimport { type Compat, type CompatOptions, type MajorMeta, resolveCompat } from \"../core/compat\";\n\nexport const API_VERSION_KEY = [\"api-version\"] as const;\n\nexport interface ApiVersionProviderProps extends CompatOptions {\n baseUrl: string;\n children: ReactNode;\n fetch?: typeof globalThis.fetch;\n /** shown while /version loads */\n loading?: ReactNode;\n /** shown when the backend is outside the supported range */\n renderBlocked?: (compat: Exclude<Compat, OkCompat>) => ReactNode;\n}\n\nfunction defaultBlocked(c: Exclude<Compat, OkCompat>): ReactNode {\n switch (c.status) {\n case \"app-too-old\":\n return <p>This app does not support API {c.version}. Please update the app.</p>;\n case \"backend-too-old\":\n return (\n <p>\n This app needs API {c.floor} or newer (connected: {c.version}).\n </p>\n );\n case \"invalid-version\":\n return <p>Unrecognised API version \"{c.version}\".</p>;\n }\n}\n\nexport function createReactBindings<P extends {}, G extends string, F extends string>(meta: MajorMeta) {\n const majors = { [meta.major]: meta };\n const ApiContext = createContext<Api<P, G> | null>(null);\n /** Date after which the backend stops serving this app's major (from the Sunset header) */\n const SunsetContext = createContext<Date | undefined>(undefined);\n\n function ApiVersionProvider({\n baseUrl,\n children,\n fetch: fetchImpl,\n loading = null,\n renderBlocked = defaultBlocked,\n ...compatOpts\n }: ApiVersionProviderProps) {\n const queryClient = useQueryClient();\n const current = useRef<string | undefined>(undefined);\n const [sunset, setSunset] = useState<Date | undefined>(undefined);\n\n const client = useMemo(\n () =>\n createApiClient<P, G>({\n baseUrl,\n fetch: fetchImpl,\n // Backend deployed mid-session: store the new version, refetch everything else.\n onVersionHeader: (v) => {\n if (current.current === undefined || v === current.current) return;\n // update synchronously: the refetches below answer before React re-renders,\n // and would otherwise each trigger another invalidation (refetch loop)\n current.current = v;\n queryClient.setQueryData(API_VERSION_KEY, v);\n queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] !== API_VERSION_KEY[0] });\n },\n // same timestamp -> same state, no re-render\n onSunsetHeader: (d) => setSunset((prev) => (prev?.getTime() === d.getTime() ? prev : d)),\n }),\n [baseUrl, fetchImpl, queryClient],\n );\n\n const version = useQuery({\n queryKey: API_VERSION_KEY,\n queryFn: () => fetchBackendVersion(baseUrl, fetchImpl),\n staleTime: 5 * 60_000,\n refetchOnWindowFocus: true, // mobile resume / tab refocus re-checks the backend\n });\n current.current = version.data;\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: compatOpts is an inline object; only prereleaseAsRelease is read\n const compat = useMemo(\n () => (version.data ? resolveCompat(version.data, majors, compatOpts) : undefined),\n [version.data, compatOpts.prereleaseAsRelease],\n );\n // a new object only when the version changes\n const api = useMemo<Api<P, G> | undefined>(\n () => (compat?.status === \"ok\" ? buildApi<P, G>(compat, client) : undefined),\n [compat, client],\n );\n\n if (!compat) return <>{loading}</>;\n if (compat.status !== \"ok\") return <>{renderBlocked(compat)}</>;\n return (\n <ApiContext.Provider value={api!}>\n <SunsetContext.Provider value={sunset}>{children}</SunsetContext.Provider>\n </ApiContext.Provider>\n );\n }\n\n /** const { $api, $gated, gate } = useApi() */\n function useApi(): Api<P, G> {\n const ctx = useContext(ApiContext);\n if (!ctx) throw new Error(\"useApi must be used inside <ApiVersionProvider>\");\n return ctx;\n }\n\n /**\n * Set once a response carries `Sunset`: the backend will stop serving this major.\n * Show a soft \"update the app before <date>\" banner; the hard block comes when the\n * major is gone (another major answers /version, or the prefix is removed).\n */\n const useSunset = () => useContext(SunsetContext);\n\n /** Feature-flag style check: is this operation available on the connected backend? */\n const useSupports = (op: G) => useApi().compat.supports(op);\n const useHasField = (field: F) => useApi().compat.hasField(field);\n\n function Feature({ requires, children, fallback = null }: { requires: G; children: ReactNode; fallback?: ReactNode }) {\n return <>{useSupports(requires) ? children : fallback}</>;\n }\n\n return { ApiVersionProvider, useApi, useSunset, useSupports, useHasField, Feature };\n}\n","/**\n * Runtime of the version-aware client: core + React bindings.\n * Apps do not call this directly; the generated `v<major>/client.ts` does, with its own types.\n */\n\nimport { createVersionAwareCore } from \"./core\";\nimport type { MajorMeta } from \"./core/compat\";\nimport { createReactBindings } from \"./react/ApiVersionProvider\";\n\nexport * from \"./core\";\nexport { API_VERSION_KEY, type ApiVersionProviderProps } from \"./react/ApiVersionProvider\";\n\n/** `P` = generated paths, `G` = gated operation keys, `F` = gated field keys */\nexport function createVersionAware<P extends {}, G extends string, F extends string>(meta: MajorMeta) {\n return { ...createVersionAwareCore<P, G>(meta), ...createReactBindings<P, G, F>(meta) };\n}\n"],"mappings":"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","names":[]}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Version-aware API client: docs
|
|
2
|
+
|
|
3
|
+
A codegen extension for the [openapi-ts](https://openapi-ts.dev/) ecosystem ([openapi-typescript](https://openapi-ts.dev/), [openapi-fetch](https://openapi-ts.dev/openapi-fetch/), [openapi-react-query](https://openapi-ts.dev/openapi-react-query/)) that lets **one client build work against a range of minor versions of one API major**. It merges every supported minor spec into one tolerant contract, records which version introduced each operation and field, and exposes a small runtime that hides features the connected backend does not have yet.
|
|
4
|
+
|
|
5
|
+
| Read this | When |
|
|
6
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
7
|
+
| [concepts.md](concepts.md) | You need the mental model: majors, minors, floor, gating, tolerant reading |
|
|
8
|
+
| [getting-started.md](getting-started.md) | First setup in an app |
|
|
9
|
+
| [how-to.md](how-to.md) | A task: add a spec, gate a feature, raise a floor, add an enum, move to a new major, wire CI |
|
|
10
|
+
| [reference.md](reference.md) | Exact config, CLI, programmatic API, merge rules, generated files, runtime API |
|
|
11
|
+
| [ecosystem.md](ecosystem.md) | How this fits next to openapi-ts, what to upstream |
|
|
12
|
+
| [troubleshooting.md](troubleshooting.md) | An error message or surprising behaviour |
|
|
13
|
+
| [adr/](adr/README.md) | Why a design decision was made |
|
|
14
|
+
|
|
15
|
+
LLM agents: the same tasks are packaged as skills in [`../skills`](../skills) (`openapi-versions-setup`, `openapi-versions-add-spec`, `openapi-versions-gate-feature`) and summarised in [`../AGENTS.md`](../AGENTS.md).
|
|
16
|
+
|
|
17
|
+
## The idea in four lines
|
|
18
|
+
|
|
19
|
+
1. The backend only makes **additive** changes inside a major (checked by [oasdiff](https://github.com/oasdiff/oasdiff)). Breaking changes mean a new major under a new URL prefix.
|
|
20
|
+
2. Codegen merges the specs from the app's **floor** (oldest supported minor) up to the newest spec, even an unreleased one.
|
|
21
|
+
3. At runtime the app reads `GET <base>/version` and decides what is available. Operations newer than the backend are off, like feature flags.
|
|
22
|
+
4. Anything newer than the app (new fields, new enum values) is ignored or typed open, so old apps keep working on new backends.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0001. Ship as a separate package, upstream only small fixes
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The package builds on openapi-typescript, openapi-fetch and openapi-react-query. Upstreaming it would put multi-spec merge, an oasdiff gate, runtime gating, a React layer and a CLI into a monorepo whose maintainers have no stated process for features of that size.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Keep `openapi-versions` as its own package. Upstream only narrow fixes (the `Readable`/`Writable` primitive guard, openapi-ts/openapi-typescript#2766) and rely on upstream features where they exist (loose enums, #2415).
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Own release cadence and no sign-off needed, but we track upstream 0.x breaking changes ourselves (Renovate groups the openapi-ts packages) and discoverability is lower. Revisit if upstream adopts a plugin or transform hook that covers codegen.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0002. Open enums are declared with `x-extensible-enum`
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Within a major a backend may add enum values, so an older client must tolerate unknown ones, while closed enums must stay closed so removing a value is still breaking.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
An enum is open only when the spec marks it `x-extensible-enum`. Codegen emits it as `"a" | "b" | (string & {})`. Consumers must give a `switch` over such an enum a `default` branch. Hand-editing a spec to pass the breaking gate is not allowed (see 0004).
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
The open/closed intent lives in the backend spec. openapi-fetch's `Readable<T>` collapses `string & {}` to an object type without the fix in 0005. openapi-typescript 7.14 will emit loose unions for `additionalProperties: true` string enums (#2415); evaluate whether it can replace our `transform` hook.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0003. Newer operations go through `$gated` + `gate()` or `<Feature>`
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
One build runs against backends from the floor up to the newest spec. Calling an operation the backend lacks fails at runtime.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Operations introduced after the floor are absent from the plain `$api` type. Queries use `$gated` with `gate()`, mutations use `<Feature>`. Components never compare version numbers; they ask `useSupports` / `useHasField`. The floor needs its own spec file.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Missing-feature handling is a compile-time concern. Raising the floor turns gated operations into ordinary ones, and dead gates become type errors to clean up.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0004. oasdiff gates every consecutive spec pair
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The whole model assumes minors within a major are additive. A silent breaking change would make the merged contract wrong.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Codegen runs `oasdiff breaking --fail-on ERR` on each consecutive pair of specs within a major and fails on a breaking pair. The fix is in the backend, an `x-extensible-enum`, or a new major, never an edit to a spec. oasdiff is pinned in `mise.toml` and CI; `MIN_OASDIFF_VERSION` guards other environments.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
An external binary is a hard dependency of codegen. Renovate does not automerge oasdiff bumps.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0005. Patch `openapi-typescript-helpers` until upstream ships the fix
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`Readable<T>` and `Writable<T>` map over `string & {}`, collapsing open enums. The fix is open upstream as openapi-ts/openapi-typescript#2766, unreleased.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Carry the one-line fix as a pnpm patch (`patchedDependencies`, `patches/openapi-typescript-helpers@0.1.0.patch`), which replaced patch-package. Do not post a duplicate upstream PR.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
A patch is not applied inside a consumer's `node_modules`, so consumers must patch themselves (documented in troubleshooting). Delete the patch, `patches/` and rule 7 of AGENTS.md once a helpers release contains #2766. Tracked by an issue labelled `blocked-upstream`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0006. mise, pnpm, Biome, Vitest, Changesets, Renovate; no turbo yet
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The repo had no pinned toolchain, lint, CI or release flow. Upstream uses pnpm and turbo.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
mise pins node, pnpm and oasdiff. pnpm is the package manager (`packageManager` pinned). Biome does lint and format. Vitest runs tests. Changesets drive versions and changelogs, released from GitHub Actions. Renovate updates dependencies with a minimum release age. Turbo is deferred: it is a single package with a short task chain.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Revisit turbo if the repo splits into a workspace (core, codegen, playground).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0007. MIT license
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-25
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`package.json` said `UNLICENSED` while the repo is public, and a license is required before the first npm release. The package builds on openapi-typescript, openapi-fetch and openapi-react-query, which are MIT, and [ADR 0001](0001-separate-package-not-upstream.md) keeps the door open to upstreaming small fixes.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
License the package under MIT. `LICENSE` holds the text, `package.json` sets `"license": "MIT"`.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Matches the openapi-ts ecosystem and lets fixes move upstream without relicensing. Offers no explicit patent grant; revisit (Apache-2.0) only if that becomes a requirement.
|
|
@@ -0,0 +1,13 @@
|
|
|
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 |
|