ts-procedures 8.4.0 → 8.6.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/build/codegen/bin/cli.d.ts +4 -0
- package/build/codegen/bin/cli.js +16 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +18 -0
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.js +2 -0
- package/build/codegen/bin/flag-specs.js.map +1 -1
- package/build/codegen/bin/flag-specs.test.js +9 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -1
- package/build/codegen/collect-models.d.ts +14 -3
- package/build/codegen/collect-models.js +15 -5
- package/build/codegen/collect-models.js.map +1 -1
- package/build/codegen/collect-models.test.js +21 -2
- package/build/codegen/collect-models.test.js.map +1 -1
- package/build/codegen/emit-index.js +13 -0
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +25 -0
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.js +45 -8
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +86 -4
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/index.d.ts +10 -0
- package/build/codegen/index.js +3 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +4 -0
- package/build/codegen/pipeline.js +3 -0
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/targets/_shared/target-run.d.ts +10 -0
- package/build/codegen/targets/ts/run.js +11 -2
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.js +97 -1
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -1
- package/docs/client-and-codegen.md +62 -0
- package/docs/client-error-handling.md +87 -0
- package/docs/handoffs/2026-06-08-dx-round2-declines.md +45 -0
- package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
- package/docs/http-integrations.md +25 -0
- package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
- package/docs/superpowers/plans/2026-06-08-codegen-dx-surfacing.md +428 -0
- package/docs/superpowers/specs/2026-06-08-dx-feedback-round-2-design.md +376 -0
- package/package.json +1 -1
- package/src/codegen/__fixtures__/users-envelope.json +9 -0
- package/src/codegen/bin/cli.test.ts +27 -0
- package/src/codegen/bin/cli.ts +18 -0
- package/src/codegen/bin/flag-specs.test.ts +11 -0
- package/src/codegen/bin/flag-specs.ts +2 -0
- package/src/codegen/collect-models.test.ts +24 -2
- package/src/codegen/collect-models.ts +22 -5
- package/src/codegen/emit-index.test.ts +34 -0
- package/src/codegen/emit-index.ts +19 -0
- package/src/codegen/emit-scope.test.ts +94 -4
- package/src/codegen/emit-scope.ts +53 -8
- package/src/codegen/index.ts +13 -0
- package/src/codegen/pipeline.ts +7 -0
- package/src/codegen/targets/_shared/target-run.ts +10 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +6 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +6 -0
- package/src/codegen/targets/ts/run.ts +18 -1
- package/src/codegen/targets/ts/shared-models.test.ts +109 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# DX Feedback Round 2 — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-08
|
|
4
|
+
**Status:** Triage approved. **Workstream A ✅ shipped** (2026-06-08, commits `0d53329`,
|
|
5
|
+
`ec9ea04`, `ced4b86`, `8ad5e9f` on `master`; 1077 tests green). **Round closed 2026-06-08:**
|
|
6
|
+
B → docs (already authorable), C documented as a boundary (no API), D high-value docs shipped
|
|
7
|
+
(#6 recipe, #10/#8 prose; #7/#12/CORS deferred), E ✗ dropped, #13/#11 decline reply sent. Net:
|
|
8
|
+
Workstream A was the only *code* — everything else was already-shipped capability to document or
|
|
9
|
+
out of our lane. See roadmap for the per-item landing.
|
|
10
|
+
**Source:** Downstream-developer feedback from building the demo server (`src/server`)
|
|
11
|
+
and wiring its generated client into the client layer. Findings #6–#15 + three
|
|
12
|
+
cross-cutting notes. (Findings #1–#5 were resolved in 8.4.0 / 8.5.0 — see
|
|
13
|
+
`2026-06-05-dx-feedback-round-design.md`.)
|
|
14
|
+
|
|
15
|
+
This round is deliberately **not** a single spec. The findings sort by *boundary*,
|
|
16
|
+
not by the severity label the downstream assigned, and the boundary determines how
|
|
17
|
+
each is treated:
|
|
18
|
+
|
|
19
|
+
1. **"The capability exists; it's just invisible in the generated output."** Pure
|
|
20
|
+
codegen *surfacing*. Unambiguously ours, low-risk, high-value. → **Workstream A**.
|
|
21
|
+
2. **"The typed error survives our throw but is flattened downstream."** Cross-cutting
|
|
22
|
+
error-handling design; the actual blocker lives in mvc-kit, not here. → **Workstream
|
|
23
|
+
C** (a design brainstorm, not a code sprint).
|
|
24
|
+
3. **"Our codegen faithfully reflects a schema the downstream authored
|
|
25
|
+
inconsistently."** Mislabeled as a codegen gap; fixing it would mean codegen
|
|
26
|
+
*overriding* the author's contract. → **Declined** (documented, no code).
|
|
27
|
+
|
|
28
|
+
The guiding principle for this round: ts-procedures owns the *transport, schema, and
|
|
29
|
+
codegen surface*. It does **not** own the consumer's state-management ergonomics
|
|
30
|
+
(mvc-kit) or the consumer's schema-authoring choices. We surface and document what we
|
|
31
|
+
own; we decline to absorb the rest, with written rationale so downstream isn't left
|
|
32
|
+
guessing.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Disposition at a glance
|
|
37
|
+
|
|
38
|
+
| # | Severity (theirs) | Disposition | Workstream |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| #8 | HIGH | **Do** — surface the per-call options bag (`signal`/`timeout`) in the generated per-route artifact | A |
|
|
41
|
+
| #9 | MEDIUM | **Do** — emit `void` for input-less routes instead of `unknown` | A |
|
|
42
|
+
| #10 | MEDIUM | **Do (shallow half)** — surface declared `Errors` + `isApiError` guard on the throwing path | A |
|
|
43
|
+
| #15 | LOW | **Do** — emit a per-scope client interface (`MessagesClient`, …) | A |
|
|
44
|
+
| #6 | LOW | **Document** — already authorable today (omit `res.body` → `Promise<void>`, no model, 204); fold into D | D |
|
|
45
|
+
| #14 | HIGH | **Brainstorm** — error-mapping seam; hold the line that flattening is mvc-kit's gap | C |
|
|
46
|
+
| #10 (deep half) | MEDIUM | **Brainstorm** — why `.safe()` is avoided ties into C | C |
|
|
47
|
+
| #7 | LOW | **Document** — `Type.Record` can't be `$id`-shared | D |
|
|
48
|
+
| #12 | LOW | **Document** — form Model owns a value type + `toBody()`; partly inherent | D |
|
|
49
|
+
| basePath/CORS | MEDIUM | **Document** (+ optional dev-time warning) | D |
|
|
50
|
+
| correlation-id | MEDIUM | **Dropped** — `onRequestStart` + function-form `factoryContext` + client headers already cover it; no new API earns its surface | — |
|
|
51
|
+
| #13 | MEDIUM | **Decline code** (document convention) — downstream schema inconsistency | — |
|
|
52
|
+
| #11 | LOW | **Decline code** (document the `as Partial` bridge) — relationship not in JSON Schema | — |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Workstream A — Codegen DX surfacing (ready to plan)
|
|
57
|
+
|
|
58
|
+
Four findings, one cohesive batch: every one is "the capability already exists (or is
|
|
59
|
+
a one-line detection); make it visible/ergonomic in the generated output." All touch
|
|
60
|
+
`emit-scope.ts` / `emit-types.ts` / `emit-index.ts` and the client `types.ts`. Lowest
|
|
61
|
+
risk, highest signal. **This is the one workstream ready for a TDD plan now.**
|
|
62
|
+
|
|
63
|
+
### A.1 — #9: input-less routes are typed `unknown`, forcing a noise `({})` arg
|
|
64
|
+
|
|
65
|
+
**Problem.** Routes with no pathParams/query/body bind their first param as `unknown`
|
|
66
|
+
(`emit-scope.ts:536,679` — `paramsTypeName` defaults to `'unknown'`). Because the type
|
|
67
|
+
is `unknown` (not `void`/optional), consumers must pass a placeholder `({})` —
|
|
68
|
+
`api.auth.Me({})`, `api.users.ListUsers({})` — and worse, `unknown` accepts garbage
|
|
69
|
+
(`api.auth.Me({ nonsense: 1 })` type-checks). Six call sites across five files; the
|
|
70
|
+
single most-repeated papercut.
|
|
71
|
+
|
|
72
|
+
**Decision.** When a route has **zero present input channels**, emit `void` as the
|
|
73
|
+
param type — `bindCallable<void, T>` / `bindCallableTyped<void, T, E>` — so the callable
|
|
74
|
+
is `(params: void, options?) => Promise<T>`. Detection point is trivial: after
|
|
75
|
+
`presentChannels` is built (`emit-scope.ts:514-520` API, `664-670` http-stream; the RPC
|
|
76
|
+
`refs['Params'] ?? 'unknown'` path at `398-399,408-409`), branch on
|
|
77
|
+
`presentChannels.length === 0`. **No runtime change** — only the emitted `TParams` type
|
|
78
|
+
arg changes.
|
|
79
|
+
|
|
80
|
+
> **Verified (2026-06-08, `tsc --strict`).** A required parameter of type `void` *can*
|
|
81
|
+
> be omitted at the call site: `Me()` compiles, `Me({})` / `Me({ nonsense: 1 })` are type
|
|
82
|
+
> errors. So the existing `(params, options?)` shape needs no restructuring — `void` for
|
|
83
|
+
> `TParams` is sufficient. The one wart: passing options on a no-input route reads
|
|
84
|
+
> `Me(undefined, { signal })`. That awkwardness is rare (no-input + explicit signal) and
|
|
85
|
+
> is the price of a **uniform** call shape across all routes; the plan should call it out
|
|
86
|
+
> but I recommend keeping the uniform shape rather than special-casing no-input routes to
|
|
87
|
+
> `(options?) =>` (which would need runtime rewiring and break shape uniformity).
|
|
88
|
+
|
|
89
|
+
**Alternatives considered.** *Optional `params?: SomeEmptyObject`.* Rejected: still
|
|
90
|
+
accepts a stray object. *Drop params entirely → `(options?) => T` for no-input routes.*
|
|
91
|
+
Rejected: nicer `Me({ signal })` ergonomics but breaks call-shape uniformity and needs
|
|
92
|
+
runtime rewiring of the binder. *Leave as-is, document `({})`.* Rejected: highest-frequency
|
|
93
|
+
papercut, and the type fix is a single branch per route kind.
|
|
94
|
+
|
|
95
|
+
### A.2 — #8: the per-call `AbortSignal`/`timeout` seam is invisible in the generated artifact
|
|
96
|
+
|
|
97
|
+
**Problem.** Every bound callable is `(params, options?: ProcedureCallOptions) =>
|
|
98
|
+
Promise<T>` and `ProcedureCallOptions` carries `signal`/`timeout`/`headers`/`basePath`/
|
|
99
|
+
`meta` (`client/types.ts:218-230`). But the generated per-route namespace surfaces only
|
|
100
|
+
the first param (`.Req`); the JSDoc emitted at `emit-scope.ts:412-419,640-644,822-833`
|
|
101
|
+
never mentions the second `options` argument. The result is a real downstream
|
|
102
|
+
mis-modeling: resources state as fact "there's no per-call AbortSignal seam" and **every
|
|
103
|
+
resource omits cancellation**, violating their own P7. The seam was reachable all along
|
|
104
|
+
— just not through the surface they treat as the contract.
|
|
105
|
+
|
|
106
|
+
**Decision.** Surface the options bag in the generated per-route artifact — **JSDoc is
|
|
107
|
+
the primary, highest-value half**:
|
|
108
|
+
1. Add a JSDoc line to every bound callable mentioning `options?.signal` / `options?.timeout`
|
|
109
|
+
(and that the second arg is `ProcedureCallOptions`), so it shows on hover *at the call
|
|
110
|
+
site* — which is where the consumer reads the contract.
|
|
111
|
+
2. *(Open — likely drop.)* A per-route `export type Options = ProcedureCallOptions` alias
|
|
112
|
+
next to `.Req`. On reflection this is probably **over-production**: the alias is
|
|
113
|
+
byte-identical on every route, so it adds generated noise (`SendMessage.Options` is just
|
|
114
|
+
`ProcedureCallOptions`) without naming anything route-specific. The plan should default to
|
|
115
|
+
JSDoc-only and emit the alias only if a concrete discoverability gap remains after the
|
|
116
|
+
JSDoc lands. Readable generated output > a redundant alias on every route.
|
|
117
|
+
|
|
118
|
+
**Alternatives considered.** *Docs only (external).* Rejected: the whole finding is that the
|
|
119
|
+
capability is undiscoverable *from the generated namespace* — the place a consumer treats as
|
|
120
|
+
the contract. The JSDoc has to land in the generated code. *Per-route alias as the headline
|
|
121
|
+
fix.* Demoted (see above) — repetition isn't discoverability.
|
|
122
|
+
|
|
123
|
+
### A.3 — #10 (shallow half): declared typed errors are invisible on the throwing path
|
|
124
|
+
|
|
125
|
+
**Problem.** A route that declares `errors` already gets `export type Errors = <union>`
|
|
126
|
+
emitted (`emit-scope.ts:363-384`), but the `ETyped` union is wired **only** to `.safe()`
|
|
127
|
+
(`client/types.ts:293-298`); the throwing callable resolves to plain `Promise<TResponse>`.
|
|
128
|
+
Since the data layer uses the throwing form (per its P2/P5), TypeScript gives no signal
|
|
129
|
+
about which typed errors a call can throw — the declared `Conflict` on `CreateUser`/
|
|
130
|
+
`UpdateUser` is never handled. The route-level taxonomy codegen computes is dead weight
|
|
131
|
+
on the common path.
|
|
132
|
+
|
|
133
|
+
**Decision (shallow half only).** *Grounding correction (2026-06-08):* the per-route
|
|
134
|
+
`Errors` type is **already emitted and reachable** — `Users.GetUser.Errors` (namespace) /
|
|
135
|
+
`GetUserErrors` (flat), confirmed in `emit-scope.test.ts:740,750`. And because the generated
|
|
136
|
+
error classes are **real exported classes**, `if (e instanceof Api.Errors.Conflict)` already
|
|
137
|
+
works on the throwing path. So #10 is **not** a missing-type problem — it's pure
|
|
138
|
+
*discoverability*. That moves the bulk of #10 into the docs pass (Workstream D); the only
|
|
139
|
+
code in Workstream A is a one-line JSDoc breadcrumb:
|
|
140
|
+
1. The per-route callable's JSDoc names its declared errors and points at `Scope.Route.Errors`
|
|
141
|
+
+ "narrow with `instanceof` on the throwing path, or use `.safe()` for a `Result`."
|
|
142
|
+
2. *(Optional, deferred.)* A generic `isProcedureError(e, Cls)` guard in the client runtime
|
|
143
|
+
(sugar over `instanceof`; classes are tagged `__tsProceduresTyped` at `client/call.ts:170`).
|
|
144
|
+
Held back unless docs + the breadcrumb prove insufficient — `instanceof` already works, so
|
|
145
|
+
a guard is ergonomics, not capability. Don't over-produce.
|
|
146
|
+
|
|
147
|
+
**The deep half is Workstream C.** *Why* the data layer avoids `.safe()` at all (it
|
|
148
|
+
defeats mvc-kit's async tracking) is the cross-cutting error-handling question — not
|
|
149
|
+
solvable by a codegen tweak. Do **not** conflate the two.
|
|
150
|
+
|
|
151
|
+
**Alternatives considered.** *Attach `ETyped` to the throwing signature too.* Rejected:
|
|
152
|
+
TS can't express "throws X" — the union would have to ride a phantom return position and
|
|
153
|
+
would mislead. A guard + reachable `Errors` type is the honest surface.
|
|
154
|
+
|
|
155
|
+
### A.4 — #15: no per-scope client interface, forcing `as unknown as typeof api` casts
|
|
156
|
+
|
|
157
|
+
**Problem.** The aggregate client is the only exported shape, so a Resource that uses one
|
|
158
|
+
scope must type its dependency as the whole `typeof api`
|
|
159
|
+
(`users.resource.ts:26`), and every test fake force-casts a two-method partial
|
|
160
|
+
`as unknown as typeof api` (`thread-messages.resource.test.ts:16`,
|
|
161
|
+
`threads.resource.test.ts:30`). The double-cast is the tell that the injected port is far
|
|
162
|
+
wider than the dependency used — defeating the type safety the generated client exists to
|
|
163
|
+
provide, exactly at the DI seam.
|
|
164
|
+
|
|
165
|
+
**Decision.** Emit a per-scope client interface alongside the aggregate — the bound
|
|
166
|
+
callables for that scope as a named type — produced where `emit-index.ts` already
|
|
167
|
+
assembles the scope namespaces. A Resource then types its dependency as the narrow port
|
|
168
|
+
(`constructor(client: MessagesClient = api.messages)`) and a fake implements just that
|
|
169
|
+
interface with no cast.
|
|
170
|
+
|
|
171
|
+
> **Derive from the existing factory return type (verified 2026-06-08).** `emit-index.ts`
|
|
172
|
+
> already emits `create${Service}Bindings(client)` returning `{ users: …, posts: … }` and
|
|
173
|
+
> already uses `ReturnType<typeof ${factoryName}>` (lines 128,145). So the per-scope client
|
|
174
|
+
> type is just an indexed access into that — **zero duplication, reuses the pattern already in
|
|
175
|
+
> the file**:
|
|
176
|
+
> ```ts
|
|
177
|
+
> export type ${Service}Client = ReturnType<typeof create${Service}Bindings>
|
|
178
|
+
> export type UsersClient = ${Service}Client['users'] // one per scope
|
|
179
|
+
> ```
|
|
180
|
+
> This does **not** collide with the `Api.Users` *type* namespace (Params/Response/…) — that
|
|
181
|
+
> namespace is the route data types; `UsersClient` is the callable bundle. The `*Client` suffix
|
|
182
|
+
> cleanly distinguishes the two. No parallel scheme, no hand-written interface — the types fall
|
|
183
|
+
> out of the factory the file already emits. A Resource then writes
|
|
184
|
+
> `constructor(client: UsersClient = api.users)` and a two-method fake satisfies `UsersClient`
|
|
185
|
+
> with no cast.
|
|
186
|
+
|
|
187
|
+
**Alternatives considered.** *Hand-written per-Resource port type (consumer side).*
|
|
188
|
+
Works, but every consumer reinvents (and mis-scopes) it — codegen already knows the exact
|
|
189
|
+
scope shape, so it should emit it.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Workstream B — No-content response (#6) — → DOCS (folded into D, 2026-06-08)
|
|
194
|
+
|
|
195
|
+
**Problem.** The downstream invented phantom bodies for naturally-empty responses:
|
|
196
|
+
`Logout` carries a dead-weight `LogoutResponse` model (call site discards it), `LeaveThread`
|
|
197
|
+
returns a `ThreadList` it didn't need. They believed an empty response couldn't be declared.
|
|
198
|
+
|
|
199
|
+
**Vetted (2026-06-08) — no-content is ALREADY fully authorable today; #6 is a docs gap, not a
|
|
200
|
+
build.** The capability exists end-to-end:
|
|
201
|
+
- **Authoring type:** `schema.res` and `res.body` are both optional (`src/types.ts:100-102`).
|
|
202
|
+
An author can omit `res` (or `res.body`) entirely.
|
|
203
|
+
- **Handler type:** `HttpReturn<TRes>` falls through to `void` when there's no body
|
|
204
|
+
(`src/create-http.ts:17-21`), so the handler types as `Promise<void>` and `return undefined`
|
|
205
|
+
type-checks (`create-http.test.ts:110-119`).
|
|
206
|
+
- **Envelope:** omitting `res.body` emits an absent/empty `res` (`hono/docs/http-doc.ts:14-27`).
|
|
207
|
+
- **Codegen:** empty `res` → `client.bindCallable<void, void>`, **no** response model
|
|
208
|
+
(`emit-scope.test.ts:1253-1256`).
|
|
209
|
+
- **Runtime:** `undefined` return → clean 204 (`hono/handlers/http.ts:126`;
|
|
210
|
+
`http.test.ts:70-82`).
|
|
211
|
+
|
|
212
|
+
So `CreateHttp('Logout', { /* no res */ }, async () => undefined)` already gives a
|
|
213
|
+
`Promise<void>` client, no model, 204 at runtime — exactly what they wanted. They invented a
|
|
214
|
+
body only because the "declare nothing" path **wasn't visible**. Same shape as Workstream E:
|
|
215
|
+
capability exists; the gap is discoverability. **No new API (no `Type.Void()` marker, no
|
|
216
|
+
status-only seam needed).** Fold a short recipe into the **D** docs pass: "for 204 / no-content,
|
|
217
|
+
omit `schema.res.body` and return `undefined` — you get `Promise<void>` and no generated model."
|
|
218
|
+
|
|
219
|
+
**Out of scope.** Consumer-side response *projection* (the `CreateThread` / `Pick`-one-field
|
|
220
|
+
observation) — the procedure's call, not the call site's; the Resource caches the full
|
|
221
|
+
projection anyway. Flagged in the feedback as "no action needed."
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Workstream C — Error-handling, cross-cutting (brainstorm, not a build)
|
|
226
|
+
|
|
227
|
+
**Covers:** #14 (fire-and-forget command error dead-ends) + the "typed errors flattened
|
|
228
|
+
by mvc-kit async tracking" cross-cutting note + the *deep* half of #10 (why `.safe()` is
|
|
229
|
+
avoided).
|
|
230
|
+
|
|
231
|
+
**Boundary stance (to defend in the brainstorm).** ts-procedures **already does its job
|
|
232
|
+
correctly**: it throws `instanceof`-catchable typed classes. The error "dead-ends"
|
|
233
|
+
because *mvc-kit's async tracker* collapses the typed instance into a closed 10-value
|
|
234
|
+
`code` union and keeps the original only on a private field. That is mvc-kit's gap — the
|
|
235
|
+
feedback itself says "either side alone closes the gap." We should not bolt on features to
|
|
236
|
+
paper over a downstream flattening problem.
|
|
237
|
+
|
|
238
|
+
**The one genuinely ts-procedures-side ask:** a client-level error **transform/map** seam.
|
|
239
|
+
We already have a global `onError` hook, but it is an *observer* (`client/hooks.ts`,
|
|
240
|
+
`client/types.ts:118-122`) — it cannot transform or swallow. The open question is whether
|
|
241
|
+
a transform hook (or `errorMap` on `createClient`) earns its surface area, or whether
|
|
242
|
+
mvc-kit surfacing `cause` on its `TaskState` is the cleaner fix that makes our side a
|
|
243
|
+
no-op.
|
|
244
|
+
|
|
245
|
+
**Why a brainstorm, not a plan.** This is a real API-design decision with overlap against
|
|
246
|
+
existing hooks and the error taxonomy. It needs the requirements explored before any code.
|
|
247
|
+
The brainstorm should produce either (a) a spec for a transform seam with a crisp boundary,
|
|
248
|
+
or (b) a documented decision that the fix is mvc-kit's, with our side limited to documenting
|
|
249
|
+
the existing typed-throw contract.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Workstream D — Documentation pass (small session)
|
|
254
|
+
|
|
255
|
+
- **#7 — `Type.Record` opaque to `$id` sharing.** Accurate and inherent: a bare record has
|
|
256
|
+
no `$id`, so its value type is always re-inlined. Not a blocker (it rides inside an
|
|
257
|
+
`$id`-bearing parent like `Thread`). **Document** the limitation in the shared-models
|
|
258
|
+
docs; do **not** teach `--share-models` to synthesize names for records (scope creep for
|
|
259
|
+
a non-blocker).
|
|
260
|
+
- **basePath/CORS footgun.** A cross-origin absolute `basePath` issues a plain
|
|
261
|
+
cross-origin `fetch`; with no server CORS the first call fails with a generic `Failed to
|
|
262
|
+
fetch` and nothing on the client surface hints why. **Document** a one-line callout in the
|
|
263
|
+
codegen `basePath` docs (browser + different origin ⇒ server CORS *or* same-origin dev
|
|
264
|
+
proxy). *Optional small code add:* the fetch adapter detects a cross-origin `basePath` and
|
|
265
|
+
emits a clearer dev-time error than the browser's generic one. The HTTP↔WS auth-seam
|
|
266
|
+
asymmetry half is cross-logged with ts-channels; our half is the basePath/CORS doc.
|
|
267
|
+
- **#12 — form shape not derivable.** The feedback admits this is "partly inherent (draft
|
|
268
|
+
state ≠ request body)." **Document** that form Models are expected to own a value type +
|
|
269
|
+
`toBody()` mapper (`LocationFormModel.toBody` as the recommended shape). No codegen change.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Workstream E — Framework-level correlation / request id — ✗ DROPPED (2026-06-08)
|
|
274
|
+
|
|
275
|
+
**Was:** promoted from the declined body-level `clientId` ask to a transport-level Hono-builder
|
|
276
|
+
feature (a `requestId` config block: read-or-mint an id, echo it on the response header for all
|
|
277
|
+
four kinds, expose `ctx.requestId`).
|
|
278
|
+
|
|
279
|
+
**Why dropped (brainstorm outcome).** The existing primitives already deliver the *entire*
|
|
280
|
+
feature with no new API — a dedicated block buys only ~6 saved lines and a named knob, which
|
|
281
|
+
doesn't earn permanent public surface on a transport library. Concretely:
|
|
282
|
+
- **Read + mint + echo (server):** `onRequestStart(c)` gets the Hono `Context` and can
|
|
283
|
+
`c.req.header(name) ?? crypto.randomUUID()`, `c.set(...)`, and `c.header(name, id)` — the
|
|
284
|
+
header set there persists onto every response, streams included.
|
|
285
|
+
- **Expose on `ctx` (server):** `factoryContext` already accepts a **function form** receiving
|
|
286
|
+
`c` (`hono/types.ts:42-49`), so `factoryContext: (c) => ({ ...base, requestId: c.get('requestId') })`
|
|
287
|
+
gives handlers `ctx.requestId` with zero framework change.
|
|
288
|
+
- **Outbound id (client):** function-valued `headers` (shipped 8.5.0) + `onBeforeRequest`
|
|
289
|
+
already attach/rotate a correlation header per request.
|
|
290
|
+
|
|
291
|
+
The gap was **discoverability, not capability** — and the user's call (2026-06-08) was that even
|
|
292
|
+
a docs recipe isn't warranted: the two-seam composition is a normal use of documented primitives.
|
|
293
|
+
Same boundary as #13/#11 — the library does not grow surface to wrap what it already enables.
|
|
294
|
+
|
|
295
|
+
**Decision: no feature, no recipe, no spec.** This consumes the original downstream
|
|
296
|
+
correlation-id ask (transport-level) in full.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Declined (no code change) — with rationale
|
|
301
|
+
|
|
302
|
+
These are the "don't bend" set. We faithfully reflect the author's schema; we don't
|
|
303
|
+
override it.
|
|
304
|
+
|
|
305
|
+
- **#13 — Create/Update spell "empty" two ways (`undefined` vs `string | null`).** Root
|
|
306
|
+
cause is downstream schema inconsistency: `Type.Optional(Type.String())` on create vs
|
|
307
|
+
`Type.Optional(Type.Union([String, Null]))` on update (`user.schema.ts:40` vs `:53`;
|
|
308
|
+
`location.schema.ts:28` vs `:39`). Codegen reflects exactly what they wrote. The
|
|
309
|
+
suggested "codegen emits a per-body normalizer / widens the optional type" would have us
|
|
310
|
+
**override the author's contract** — the opposite of our job. **Decline the code change.**
|
|
311
|
+
Remedy is for the author to make the pair consistent (both `string | undefined` *or* both
|
|
312
|
+
`string | null`). We will document the convention so consumers stop rediscovering it
|
|
313
|
+
field-by-field.
|
|
314
|
+
|
|
315
|
+
- **#11 — `UpdateXBody extends Partial<Entity>`.** `collect-models.ts:61-94` does zero
|
|
316
|
+
structural inference and *cannot*: the Partial/subset relationship isn't expressible in
|
|
317
|
+
the JSON Schema we receive (it's flattened to an independent `$id` model before codegen
|
|
318
|
+
sees it). We can't synthesize a relationship the wire format doesn't carry. **Decline the
|
|
319
|
+
codegen change.** We will document the intended `as Partial<Entity>` bridge so consumers
|
|
320
|
+
don't each reinvent (and mis-scope) it.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Session roadmap (sequencing)
|
|
325
|
+
|
|
326
|
+
1. ✅ **DONE — Workstream A** (`docs/superpowers/plans/2026-06-08-codegen-dx-surfacing.md`)
|
|
327
|
+
— #9, #8, #10-breadcrumb, #15 shipped 2026-06-08. **Process lesson (encode in future
|
|
328
|
+
plans):** changing `__fixtures__/users-envelope.json` is **cross-target** — it feeds every
|
|
329
|
+
target's e2e/integration tests, so a new route regenerates the Kotlin *and* Swift goldens
|
|
330
|
+
(`UPDATE_GOLDENS=1`), not just a 5→6 count bump. Pre-flight for any plan that inlines test
|
|
331
|
+
code should also grep the referenced test fixtures (the real `emit-index.test.ts` scopes are
|
|
332
|
+
`users`/`billing`/`adminUsers`, not the assumed `users`/`posts`), not only production-file
|
|
333
|
+
line anchors.
|
|
334
|
+
2. ✗ **DROPPED — Workstream E** (correlation id). Brainstormed 2026-06-08; existing primitives
|
|
335
|
+
cover it, no new API or docs warranted. See the Workstream E section above.
|
|
336
|
+
3. → **DOCS — Workstream B** (no-content #6). Vetted 2026-06-08; already authorable today,
|
|
337
|
+
folded into D as a recipe. See the Workstream B section above.
|
|
338
|
+
4. ✅ **DONE — Workstream C** (error-handling). Documented as a boundary, **no new API**:
|
|
339
|
+
`docs/client-error-handling.md` §11 "Design note: typed errors and your state layer" — we throw
|
|
340
|
+
instanceof-catchable classes; a state layer that flattens them is the state layer's concern;
|
|
341
|
+
`onError` is an observer by design.
|
|
342
|
+
5. ✅ **DONE (high-value) — Workstream D docs.** #6 no-content recipe →
|
|
343
|
+
`docs/http-integrations.md`; #10/#8 discoverability prose → `docs/client-error-handling.md`
|
|
344
|
+
§3a. **Deferred (optional follow-up):** #7 (`$id`/Record), #12 (form `toBody`), basePath/CORS
|
|
345
|
+
+ cross-origin adapter warning — lower-value, pull in on request.
|
|
346
|
+
6. ✅ **DONE — Decline reply** → `docs/handoffs/2026-06-08-dx-round2-declines.md` (#13/#11 with
|
|
347
|
+
the schema-authoring remedies, framed remedy-first).
|
|
348
|
+
|
|
349
|
+
**Net for the round:** Workstream A (codegen DX) was the only *code*; everything else is either
|
|
350
|
+
already-shipped capability that needs documenting (B, #6) or out of our lane (E dropped; #13/#11
|
|
351
|
+
declined; C likely mvc-kit's). That's the boundary holding — the library surfaces and documents
|
|
352
|
+
what it owns, and doesn't grow to wrap what it already enables.
|
|
353
|
+
|
|
354
|
+
Each workstream produces working, testable software (or a documented decision) on its own —
|
|
355
|
+
no cross-workstream barriers.
|
|
356
|
+
|
|
357
|
+
## Testing strategy (per workstream, filled in at plan time)
|
|
358
|
+
|
|
359
|
+
- **A:** fixture tests asserting (#9) input-less routes emit `void` and reject a stray
|
|
360
|
+
object; (#8) generated JSDoc + `Options` alias surface the options bag; (#10) `Errors`
|
|
361
|
+
type reachable at call site + `is${Service}Error` guard narrows; (#15) per-scope
|
|
362
|
+
interface satisfied by a two-method fake with no cast. Regression: routes *with* input
|
|
363
|
+
unchanged.
|
|
364
|
+
- **B:** no code — the no-content path already has coverage (`create-http.test.ts:110-119`,
|
|
365
|
+
`http.test.ts:70-82`, `emit-scope.test.ts:1253-1256`); the docs recipe should compile against
|
|
366
|
+
those existing behaviors.
|
|
367
|
+
- **C:** determined by the brainstorm outcome.
|
|
368
|
+
- **D:** doc review only.
|
|
369
|
+
|
|
370
|
+
## Out of scope (this round)
|
|
371
|
+
|
|
372
|
+
- mvc-kit's half of the error-flattening fix (surface `cause` on `TaskState`) — their repo.
|
|
373
|
+
- ts-channels WS correlation / `?token=` auth-seam asymmetry — cross-logged there.
|
|
374
|
+
- Consumer-side response projection (`Pick`-one-field) — procedure's call, not ours.
|
|
375
|
+
- A first-class body-level `clientId`/idempotency convention — superseded by Workstream E's
|
|
376
|
+
transport-level approach.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-procedures",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
|
|
5
5
|
"main": "build/exports.js",
|
|
6
6
|
"types": "build/exports.d.ts",
|
|
@@ -191,6 +191,15 @@
|
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
193
|
"errors": []
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"kind": "api",
|
|
197
|
+
"name": "Heartbeat",
|
|
198
|
+
"scope": "users",
|
|
199
|
+
"method": "GET",
|
|
200
|
+
"fullPath": "/users/heartbeat",
|
|
201
|
+
"jsonSchema": { "req": {}, "res": {} },
|
|
202
|
+
"errors": []
|
|
194
203
|
}
|
|
195
204
|
],
|
|
196
205
|
"errors": [
|
|
@@ -555,3 +555,30 @@ describe('--share-models', () => {
|
|
|
555
555
|
expect(parseArgs(['--out', 'g', '--url', 'u'], cfg as CodegenConfig).sharedTypesImport).toEqual(cfg.sharedTypesImport)
|
|
556
556
|
})
|
|
557
557
|
})
|
|
558
|
+
|
|
559
|
+
describe('--shared-models-module and --strict-shared-models', () => {
|
|
560
|
+
it('parses --shared-models-module into sharedModelsModule', () => {
|
|
561
|
+
const parsed = parseArgs(['--out', 'gen', '--file', 'e.json', '--shared-models-module', '@app/schemas'])
|
|
562
|
+
expect(parsed.sharedModelsModule).toBe('@app/schemas')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('parses --strict-shared-models as a boolean (default false)', () => {
|
|
566
|
+
expect(parseArgs(['--out', 'gen', '--file', 'e.json']).strictSharedModels).toBe(false)
|
|
567
|
+
expect(
|
|
568
|
+
parseArgs(['--out', 'gen', '--file', 'e.json', '--strict-shared-models']).strictSharedModels,
|
|
569
|
+
).toBe(true)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('CLI --shared-models-module overrides a config value', () => {
|
|
573
|
+
const parsed = parseArgs(
|
|
574
|
+
['--out', 'gen', '--file', 'e.json', '--shared-models-module', '@cli/pkg'],
|
|
575
|
+
{ sharedModelsModule: '@config/pkg' },
|
|
576
|
+
)
|
|
577
|
+
expect(parsed.sharedModelsModule).toBe('@cli/pkg')
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('strictSharedModels is seeded from config when the flag is absent', () => {
|
|
581
|
+
const parsed = parseArgs(['--out', 'gen', '--file', 'e.json'], { strictSharedModels: true })
|
|
582
|
+
expect(parsed.strictSharedModels).toBe(true)
|
|
583
|
+
})
|
|
584
|
+
})
|
package/src/codegen/bin/cli.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface CodegenConfig {
|
|
|
30
30
|
unsupportedUnions?: 'throw' | 'fallback'
|
|
31
31
|
shareModels?: boolean
|
|
32
32
|
sharedTypesImport?: SharedTypesImportMap
|
|
33
|
+
sharedModelsModule?: string
|
|
34
|
+
strictSharedModels?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface ParsedArgs {
|
|
@@ -51,6 +53,8 @@ export interface ParsedArgs {
|
|
|
51
53
|
unsupportedUnions?: 'throw' | 'fallback'
|
|
52
54
|
shareModels: boolean
|
|
53
55
|
sharedTypesImport?: SharedTypesImportMap
|
|
56
|
+
sharedModelsModule?: string
|
|
57
|
+
strictSharedModels: boolean
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// ---------------------------------------------------------------------------
|
|
@@ -170,6 +174,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
170
174
|
let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
|
|
171
175
|
let shareModels = config?.shareModels ?? true
|
|
172
176
|
const sharedTypesImport = config?.sharedTypesImport
|
|
177
|
+
let sharedModelsModule: string | undefined = config?.sharedModelsModule
|
|
178
|
+
let strictSharedModels = config?.strictSharedModels ?? false
|
|
173
179
|
let configPath: string | undefined
|
|
174
180
|
|
|
175
181
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -260,6 +266,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
260
266
|
shareModels = true
|
|
261
267
|
} else if (arg === '--no-share-models') {
|
|
262
268
|
shareModels = false
|
|
269
|
+
} else if (arg === '--shared-models-module') {
|
|
270
|
+
sharedModelsModule = argv[++i]
|
|
271
|
+
} else if (arg === '--strict-shared-models') {
|
|
272
|
+
strictSharedModels = true
|
|
263
273
|
} else if (arg === '--config') {
|
|
264
274
|
configPath = argv[++i]
|
|
265
275
|
} else if (arg !== undefined && arg.startsWith('--')) {
|
|
@@ -336,6 +346,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
336
346
|
...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
|
|
337
347
|
shareModels,
|
|
338
348
|
...(sharedTypesImport !== undefined ? { sharedTypesImport } : {}),
|
|
349
|
+
...(sharedModelsModule !== undefined ? { sharedModelsModule } : {}),
|
|
350
|
+
strictSharedModels,
|
|
339
351
|
}
|
|
340
352
|
}
|
|
341
353
|
|
|
@@ -425,6 +437,9 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
|
|
|
425
437
|
...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
|
|
426
438
|
shareModels: parsed.shareModels,
|
|
427
439
|
...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
|
|
440
|
+
...(parsed.sharedModelsModule !== undefined ? { sharedModelsModule: parsed.sharedModelsModule } : {}),
|
|
441
|
+
strictSharedModels: parsed.strictSharedModels,
|
|
442
|
+
logger: (message: string) => { console.log(message) },
|
|
428
443
|
...kotlinWiring,
|
|
429
444
|
...swiftWiring,
|
|
430
445
|
})
|
|
@@ -555,6 +570,9 @@ async function main(): Promise<void> {
|
|
|
555
570
|
...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
|
|
556
571
|
shareModels: parsed.shareModels,
|
|
557
572
|
...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
|
|
573
|
+
...(parsed.sharedModelsModule !== undefined ? { sharedModelsModule: parsed.sharedModelsModule } : {}),
|
|
574
|
+
strictSharedModels: parsed.strictSharedModels,
|
|
575
|
+
logger: (message: string) => { console.log(message) },
|
|
558
576
|
...kotlinWiring,
|
|
559
577
|
...swiftWiring,
|
|
560
578
|
})
|
|
@@ -24,4 +24,15 @@ describe('flag-specs', () => {
|
|
|
24
24
|
expect(KNOWN_FLAGS).not.toContain('--help')
|
|
25
25
|
expect(KNOWN_FLAGS).not.toContain('-h')
|
|
26
26
|
})
|
|
27
|
+
|
|
28
|
+
it('catalogs the shared-models convention + strict flags', () => {
|
|
29
|
+
expect(KNOWN_FLAGS).toContain('--shared-models-module')
|
|
30
|
+
expect(KNOWN_FLAGS).toContain('--strict-shared-models')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('documents the new flags in --help output', () => {
|
|
34
|
+
const help = formatHelp()
|
|
35
|
+
expect(help).toContain('--shared-models-module')
|
|
36
|
+
expect(help).toContain('--strict-shared-models')
|
|
37
|
+
})
|
|
27
38
|
})
|
|
@@ -27,6 +27,8 @@ export const FLAG_SPECS: readonly FlagSpec[] = [
|
|
|
27
27
|
{ name: '--client-import-path', arg: '<path>', description: 'Override the client runtime import path', group: 'Codegen' },
|
|
28
28
|
{ name: '--share-models', description: 'Hoist $id-bearing schemas into a shared _models.ts', group: 'Codegen', default: 'on' },
|
|
29
29
|
{ name: '--no-share-models', description: 'Inline every type per route (legacy behaviour)', group: 'Codegen' },
|
|
30
|
+
{ name: '--shared-models-module', arg: '<module>', description: 'Re-export every $id model from one module (convention; sharedTypesImport overrides)', group: 'Codegen' },
|
|
31
|
+
{ name: '--strict-shared-models', description: 'Fail if any $id model would be generated as a local twin', group: 'Codegen' },
|
|
30
32
|
{ name: '--jsdoc', description: 'Emit JSDoc on generated types', group: 'Codegen', default: 'on' },
|
|
31
33
|
{ name: '--no-jsdoc', description: 'Suppress JSDoc', group: 'Codegen' },
|
|
32
34
|
{ name: '--enum-style', arg: '<union|enum>', description: 'How to emit enums (namespace mode)', group: 'Codegen' },
|
|
@@ -40,7 +40,29 @@ it('does NOT throw for ordinary schemas without the reserved prefix', () => {
|
|
|
40
40
|
|
|
41
41
|
it('resolveModelImports tags mapped models and leaves others generated', () => {
|
|
42
42
|
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
43
|
-
const mapped = resolveModelImports(models, {
|
|
43
|
+
const mapped = resolveModelImports(models, {
|
|
44
|
+
sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } },
|
|
45
|
+
})
|
|
44
46
|
expect(mapped[0]?.import).toEqual({ module: '@shared/schemas', name: 'Message' })
|
|
45
|
-
expect(resolveModelImports(models
|
|
47
|
+
expect(resolveModelImports(models)[0]?.import).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('resolveModelImports falls back to sharedModelsModule when no map entry matches', () => {
|
|
51
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
52
|
+
const resolved = resolveModelImports(models, { sharedModelsModule: '@app/schemas' })
|
|
53
|
+
expect(resolved[0]?.import).toEqual({ module: '@app/schemas', name: 'Message' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('resolveModelImports: explicit map entry wins over the convention', () => {
|
|
57
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
58
|
+
const resolved = resolveModelImports(models, {
|
|
59
|
+
sharedTypesImport: { 'urn:msg': { module: '@override/pkg', name: 'Msg' } },
|
|
60
|
+
sharedModelsModule: '@app/schemas',
|
|
61
|
+
})
|
|
62
|
+
expect(resolved[0]?.import).toEqual({ module: '@override/pkg', name: 'Msg' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('resolveModelImports: empty-string convention is treated as unset (generated locally)', () => {
|
|
66
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
67
|
+
expect(resolveModelImports(models, { sharedModelsModule: '' })[0]?.import).toBeUndefined()
|
|
46
68
|
})
|
|
@@ -93,16 +93,33 @@ export function collectModels(routes: AnyHttpRouteDoc[]): CollectedModel[] {
|
|
|
93
93
|
})
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Options controlling how collected models are resolved to external imports. */
|
|
97
|
+
export interface ResolveModelImportsOptions {
|
|
98
|
+
/** Per-`$id` external import. A matching entry wins over `sharedModelsModule`. */
|
|
99
|
+
sharedTypesImport?: SharedTypesImportMap
|
|
100
|
+
/** Single module every otherwise-unmapped `$id` model re-exports from (convention). */
|
|
101
|
+
sharedModelsModule?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
/**
|
|
97
|
-
* Tags each collected model with its external import
|
|
98
|
-
*
|
|
105
|
+
* Tags each collected model with its external import. Precedence:
|
|
106
|
+
* 1. an explicit `sharedTypesImport[$id]` entry (per-type override / rename),
|
|
107
|
+
* 2. otherwise, when `sharedModelsModule` is set, the convention
|
|
108
|
+
* `{ module: sharedModelsModule, name: model.name }` — every shared model
|
|
109
|
+
* re-exports from one module under its derived name (`$id`/`title` === export),
|
|
110
|
+
* 3. otherwise `import` stays undefined and the model is generated locally.
|
|
99
111
|
*/
|
|
100
112
|
export function resolveModelImports(
|
|
101
113
|
models: CollectedModel[],
|
|
102
|
-
|
|
114
|
+
options: ResolveModelImportsOptions = {},
|
|
103
115
|
): ResolvedModel[] {
|
|
116
|
+
const { sharedTypesImport = {}, sharedModelsModule } = options
|
|
104
117
|
return models.map((model) => {
|
|
105
|
-
const mapped =
|
|
106
|
-
|
|
118
|
+
const mapped = sharedTypesImport[model.id]
|
|
119
|
+
if (mapped) return { ...model, import: mapped }
|
|
120
|
+
if (sharedModelsModule != null && sharedModelsModule !== '') {
|
|
121
|
+
return { ...model, import: { module: sharedModelsModule, name: model.name } }
|
|
122
|
+
}
|
|
123
|
+
return { ...model }
|
|
107
124
|
})
|
|
108
125
|
}
|
|
@@ -180,6 +180,40 @@ describe('emitIndexFile', () => {
|
|
|
180
180
|
})
|
|
181
181
|
})
|
|
182
182
|
|
|
183
|
+
describe('per-scope client types (DX #15)', () => {
|
|
184
|
+
const groups = [usersGroup, billingGroup]
|
|
185
|
+
|
|
186
|
+
it('emits the aggregate client type from the factory return type', () => {
|
|
187
|
+
const out = emitIndexFile(groups, { serviceName: 'Api' })
|
|
188
|
+
expect(out).toContain('export type ApiClient = ReturnType<typeof createApiBindings>')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('emits one per-scope client type as an indexed access into the aggregate', () => {
|
|
192
|
+
const out = emitIndexFile(groups, { serviceName: 'Api' })
|
|
193
|
+
expect(out).toContain("export type UsersClient = ApiClient['users']")
|
|
194
|
+
expect(out).toContain("export type BillingClient = ApiClient['billing']")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('honors a custom serviceName in the client type names', () => {
|
|
198
|
+
const out = emitIndexFile(groups, { serviceName: 'Catalog' })
|
|
199
|
+
expect(out).toContain('export type CatalogClient = ReturnType<typeof createCatalogBindings>')
|
|
200
|
+
expect(out).toContain("export type UsersClient = CatalogClient['users']")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('documents the aggregate and per-scope client types so the DI-seam intent is visible in the generated file', () => {
|
|
204
|
+
const out = emitIndexFile(groups, { serviceName: 'Api' })
|
|
205
|
+
// Aggregate type carries a one-line summary.
|
|
206
|
+
expect(out).toContain('/** Full typed client surface — every scope of `Api`. */')
|
|
207
|
+
// Each per-scope type names the scope and points at the DI use case.
|
|
208
|
+
expect(out).toContain(
|
|
209
|
+
'/** Narrow port for the `users` scope — inject as a DI seam without casting the aggregate client. */',
|
|
210
|
+
)
|
|
211
|
+
expect(out).toContain(
|
|
212
|
+
'/** Narrow port for the `billing` scope — inject as a DI seam without casting the aggregate client. */',
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
183
217
|
describe('clientImportPath', () => {
|
|
184
218
|
it('uses custom clientImportPath in both import statements', () => {
|
|
185
219
|
const output = emitIndexFile([usersGroup], { clientImportPath: '@my-app/client' })
|