3232 (schemas, runtime, per-namespace surface, barrel) plus the conformance tests.
3333
3434### How the emitter is structured
35+
3536- ` emitter.tsx ` — ` $onEmit ` : emits ` schemas.ts ` (Alloy components, the original
3637 path), the static runtime files, and the per-namespace surface files, all as
3738 sibling ` <ts.SourceFile> ` children of one ` <Output> ` .
4950 documented call paths and routes always match the emitted client.
5051
5152### Grouping (reproduce this)
53+
5254The ` OpenMeter ` service surfaces every operation through an ` *Endpoints `
5355interface that ` extends ` the resource's interface in its ** source** namespace
5456(e.g. ` OpenMeter.PlansEndpoints extends ProductCatalog.PlanOperations ` ). The op
@@ -64,6 +66,7 @@ a display string like "Metering Events" → stutter) or by `op.namespace` (alway
6466` OpenMeter ` ).
6567
6668### Nested sub-clients (reproduce this)
69+
6770The SDK nests sub-clients (` customers.charges.list() ` ,
6871` customers.credits.grants.list() ` ) from the ** source namespace chain** below the
6972group's top namespace. Walking ` sourceInterfaces[0].namespace ` up to the global
@@ -92,12 +95,13 @@ segments, so `create-credit-grant` under `credits.grants` → `create`.
9295** OpenAPI invariance is the hard gate** for any ` .tsp ` change: regenerate and
9396confirm the ` output/definitions/.../*.yaml ` hashes are unchanged. Namespace
9497nesting of operation interfaces is OpenAPI-neutral (paths/tags/operationIds are
95- explicit); moving a * model * is not. Watch for namespace collisions — nesting
98+ explicit); moving a _ model _ is not. Watch for namespace collisions — nesting
9699` Customers.Billing ` shadows the global ` Billing ` namespace for unqualified refs
97100in ` Customers ` -scoped files; alias around it (` Common.BillingRoot ` ) rather than
98101renaming.
99102
100103### Naming rules (reproduce these)
104+
101105- ** func name** = full camelCase operationId: ` get-meter ` → ` getMeter ` .
102106- ** facade method** = operationId with the group's resource noun(s) and the
103107 cross-cutting ` metering ` qualifier stripped: ` get-meter ` → ` get ` ;
@@ -121,12 +125,12 @@ renaming.
121125
122126## Commands
123127
124- | Task | Command |
125- | ------| ---------|
126- | Build the emitter | ` pnpm --filter @openmeter/typespec-typescript run build ` |
127- | Regenerate SDK from TypeSpec | ` pnpm --filter @openmeter/api-spec-aip run generate ` |
128- | Run the SDK conformance tests | ` pnpm run test:sdk ` |
129- | Install / refresh lockfile | ` pnpm install --config.confirmModulesPurge=false ` |
128+ | Task | Command |
129+ | ----------------------------- | -------------------------------------------------------- |
130+ | Build the emitter | ` pnpm --filter @openmeter/typespec-typescript run build ` |
131+ | Regenerate SDK from TypeSpec | ` pnpm --filter @openmeter/api-spec-aip run generate ` |
132+ | Run the SDK conformance tests | ` pnpm run test:sdk ` |
133+ | Install / refresh lockfile | ` pnpm install --config.confirmModulesPurge=false ` |
130134
131135The emitter is bound by ** package name** (` @openmeter/typespec-typescript ` ) in
132136` packages/aip/tspconfig.yaml ` (both the ` emit: ` list and the ` options: ` key). The
@@ -140,22 +144,26 @@ exact shape the generator must reproduce. Its tests are the conformance target
140144the generated SDK is "done" when it passes them.
141145
142146### Casing: wire-native, no transformation
147+
143148The AIP API is ** snake_case end to end** (enforced by the AIP casing lint rule).
144149Request/response bodies are snake_case in TypeSpec, OpenAPI, and the emitted zod
145150schemas alike. There is ** no snake↔camel transform layer** — the JS object shape
146151is the wire shape. Do not add ` .transform() ` or case-converting middleware.
147152
148153### No runtime validation of responses
154+
149155Responses are typed via ky's ` .json<T>() ` , never ` schema.parse() ` . Validating
150156responses turns additive (non-breaking) API fields into client breakage. zod is
151157retained only for type derivation (` z.input ` /` z.output ` ) and the one
152158` baseError.safeParse ` in the error path. Request bodies are likewise not parsed
153159(the server owns defaults and validation).
154160
155161### Documented types: generated from TypeSpec, verified against zod
162+
156163** zod schemas and TypeScript types are separate artifacts with one source.** Both
157164are generated from the same TypeSpec, but neither is derived from the other at the
158165type level:
166+
159167- ` models/schemas.ts ` — zod schemas (runtime validation in the error path, query/
160168 path coercion). The runtime artifact.
161169- ` models/types.ts ` — concrete TypeScript interfaces (the public surface that
@@ -174,6 +182,7 @@ TypeSpec directly gives clean concrete types (`id: string`,
174182the TypeSpec ` @doc ` , decoupled from zod.
175183
176184` tsTypeOf ` leaf mapping (must match ` zodBaseSchemaParts ` or the guard fails):
185+
177186- scalars → ` string ` / ` number ` / ` boolean ` ; ** int64/uint64 → ` bigint ` ** (zod uses
178187 ` z.coerce.bigint() ` ); everything else numeric → ` number ` .
179188- ** dates/times/durations → ` string ` ** (wire-native; RFC 3339, never ` Date ` ).
@@ -184,6 +193,7 @@ the TypeSpec `@doc`, decoupled from zod.
184193 union: ` (A | B)[] ` ); open records → ` Record<string, V> ` .
185194
186195Structural rules the interface emitter follows:
196+
187197- ** Optionality follows OUTPUT** : a defaulted field is optional-in / required-out,
188198 so ` prop.optional && prop.defaultValue === undefined ` decides the ` ? ` .
189199- ** No-wire-prop models alias** to their mapped structure
@@ -193,14 +203,14 @@ Structural rules the interface emitter follows:
193203- ** ` extends ` ** the base interface when the model has a ` baseModel ` , so inherited
194204 fields/docs propagate (` BadRequest extends BaseError ` ).
195205- ** Open records** (` ...Record<…> ` ) get an index signature (` [key: string]: V ` ).
196- - ** Unions stay on ` z.output ` ** at the * response * layer (e.g. ` GetAppResponse ` ) — a
206+ - ** Unions stay on ` z.output ` ** at the _ response _ layer (e.g. ` GetAppResponse ` ) — a
197207 discriminated union has no single interface, so wiring it to one would be wrong.
198208
199209** Conformance guard (the oracle).** Every emitted type — both ` interface ` s ** and**
200210the no-wire-prop ` type ` aliases — is paired with a mutual-assignability check in
201211` models/types.assert.ts `
202212(` [X] extends [z.output<…>] ? [z.output<…>] extends [X] ? true : {__error} ` ). This
203- is the * only * place ` types.ts ` and ` schemas.ts ` meet: it proves the directly-walked
213+ is the _ only _ place ` types.ts ` and ` schemas.ts ` meet: it proves the directly-walked
204214TS type is type-equivalent to the zod schema, turning any divergence (wrong leaf,
205215wrong optionality, header leak, open-record gap) into a ** build error** . ` tsc ` is
206216the oracle. The alias branch must guard too: unlike a former ` z.output ` alias
@@ -227,6 +237,7 @@ clean `prop?: T` output-shaped optionality (defaulted fields required, no
227237depend on ` .describe() ` surviving into the runtime schemas.
228238
229239### Factoring: what the generator emits, and how often
240+
230241- ** ONCE** (shared runtime in ` lib/ ` + ` core.ts ` ): the base ` Client ` /transport
231242 (one ` ky.create ` ), the ` request() ` envelope, ` Result ` /` ok ` /` err ` /` unwrap ` ,
232243 the curated ` RequestOptions ` , the encoders (` encodePath ` , ` toURLSearchParams ` ,
@@ -242,6 +253,7 @@ depend on `.describe()` surviving into the runtime schemas.
242253 functions, so the funcs modules stay free of type declarations and guards.
243254
244255### Void responses must not call ` .json() `
256+
245257The 10 operations whose ` Response ` is ` void ` (` !op.hasResponse ` — every
246258` delete* ` plus ` events.ingest ` , which return ` 204 No Content ` / ` 202 Accepted `
247259with an empty body) terminate with ` request(async () => { await http(client).<verb>(…) }) ` ,
@@ -264,9 +276,11 @@ directly in `tests/` and is not re-emitted by `generate` — do not delete it
264276expecting a regen to restore it.
265277
266278### Request types: direct TS, input-variant interfaces
279+
267280Request/response types are direct TS, not ` z.input ` /` z.output ` , and live in
268281` models/operations/<ns>.ts ` (re-exported from the barrel under their existing
269282public names). The split mirrors the model types:
283+
270284- ** Response** → the documented output interface (or ` void ` , or the one
271285 ` z.output ` union ` GetAppResponse ` ).
272286- ** Request body** → the body model's interface, or its ** ` …Input ` variant**
@@ -293,6 +307,7 @@ public names). The split mirrors the model types:
293307 identically, so widening only the request ** type** is sufficient.
294308
295309Two traps the generator handles:
310+
296311- ** Name collision** : the op request type ` <Base>Request ` collides with a body
297312 model interface of the same name (e.g. ` CreateMeterRequest ` ). The body is
298313 imported under a ` <Name>Body ` alias so the local request declaration owns the
@@ -311,6 +326,7 @@ too-strict request type (refing the output `X` where `XInput` was needed) still
311326satisfies the one-directional ` [X] extends [z.input] ` , so the shipped guards
312327can't catch picking the wrong variant or ` computeDivergentModels ` under-marking.
313328Two independent checks close this:
329+
314330- The 20 conformance tests construct real requests (end-to-end).
315331- ** Coverage probe** (the authoritative recipe — do NOT use a regex reachability
316332 tracer; it false-matches identifiers inside ` .regex(/…/) ` and ` .describe() ` ):
@@ -320,11 +336,13 @@ Two independent checks close this:
320336 compiled by tsc; zero failures = every request-reachable model is covered.
321337
322338### Dual surface
339+
323340Every operation exists twice: a standalone func in ` funcs/ ` returning
324341` Result<T> ` (tree-shakeable, non-throwing) and a thin method on the namespace
325342façade in ` sdk/ ` that ` unwrap ` s and throws. Both call the same func.
326343
327344### README
345+
328346` readme.ts ` emits the package ` README.md ` at the package root (` emitter-output-dir `
329347is the package root, so non-` src/ ` paths land there; ` package.json ` /` tests/ `
330348survive because ` writeOutput ` only writes listed paths). It is built from the
@@ -352,11 +370,13 @@ is already red for both subtrees), so do not pre-align tables in the emitter or
352370single out the README in ` .prettierignore ` .
353371
354372### RequestOptions is curated
373+
355374` RequestOptions = Pick<Options, 'signal' | 'headers' | 'timeout' | 'retry'> ` .
356375Do not widen it to the full ky ` Options ` — exposing ` searchParams ` /` json ` /` hooks ` /
357376` fetch ` /` prefix ` per call lets callers clobber transport internals.
358377
359378### Errors
379+
360380` toError ` maps ky failures to the domain ` HTTPError ` (RFC7807 ` problem+json ` ,
361381charset-tolerant Content-Type match; status-only fallback otherwise). ` Result ` 's
362382error type stays ` Error ` — the ky fork also throws ` TimeoutError ` /` NetworkError ` ,
@@ -365,6 +385,7 @@ so narrowing to `HTTPError` would be unsound. Callers narrow with
365385field-level validation errors are reachable via ` getField('invalid_parameters') ` .
366386
367387### Server URL templating
388+
368389` baseUrl ` is ** required** (no default). It may be a ` ServerList ` template with
369390` {region} ` /` {port} ` variables resolved via ` encodePath(baseUrl, serverVariables) ` ,
370391a concrete URL, or a ` URL ` object. ` region ` is typed to the enumerated ` Regions ` .
@@ -375,12 +396,15 @@ user-supplied `prefix` cannot redirect requests; the auth hook is appended
375396** after** user ` beforeRequest ` hooks so SDK auth wins.
376397
377398### ky is a fork — preserve its option names
399+
378400The vendored ` ky ` uses ` baseUrl ` /` prefix ` /` totalTimeout ` /` retryOnTimeout ` (not
379401mainline ky's ` prefixUrl ` ). The emitter's runtime must use the fork's names; do
380402not "correct" them to mainline ky.
381403
382404## Query serialization (verified against the server)
405+
383406` api/v3/filters/parse.go ` is the source of truth for filter encoding:
407+
384408- deep objects: ` page[size] ` , ` filter[key][eq] ` (bracketed)
385409- scalar ` filter[key]=v ` is shorthand for ` filter[key][eq]=v `
386410- array operands (` oeq ` /` ocontains ` ) are ** comma-joined into one param** ; the
@@ -389,6 +413,7 @@ not "correct" them to mainline ky.
389413 ` encodeSort ` flattens ` {by, order} ` to that form.
390414
391415## Tests
416+
392417The conformance tests (Vitest + ` @fetch-mock/vitest ` , matching the legacy SDK's
393418stack) are embedded in ` runtime-templates.ts ` and emitted into the generated
394419SDK. They are the generator's spec: it is "done" when these tests pass against
0 commit comments